name: Mutation Testing
on:
schedule:
- cron: "0 4 * * 1" workflow_dispatch:
permissions:
contents: read
env:
KILL_RATE_FLOOR: "0.70"
jobs:
sweep:
name: Mutation Sweep (${{ matrix.shard.name }})
runs-on: ubuntu-latest
timeout-minutes: 200
permissions:
contents: read
strategy:
fail-fast: false
matrix:
shard:
- name: primitives
files: --file src/crypto.rs --file src/pkce.rs --file src/session.rs
features: ""
- name: dpop
files: --file src/dpop.rs
features: ""
- name: ssrf-identity
files: --file src/ssrf.rs --file src/identity.rs
features: ""
- name: flows
files: --file src/client.rs --file src/par.rs --file src/discovery.rs --file src/store.rs
features: ""
- name: integrations
files: --file 'src/integrations/*.rs'
features: "--features axum,actix,tower"
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with:
persist-credentials: false
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
- name: Cargo Cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-mutants-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-mutants-
- name: Install cargo-mutants
uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf with:
tool: cargo-mutants
- name: Run mutation sweep (${{ matrix.shard.name }})
run: >
cargo mutants -j2 --timeout 300 --no-shuffle
${{ matrix.shard.files }}
${{ matrix.shard.features }}
|| code=$?; [ "${code:-0}" -eq 0 ] || [ "${code:-0}" -eq 2 ] || [ "${code:-0}" -eq 3 ] || exit "${code}"
- name: Enforce kill-rate floor
if: success()
run: |
python3 -c "
import json, sys, os
name = '${{ matrix.shard.name }}'
floor = float(os.environ['KILL_RATE_FLOOR'])
report = json.load(open('mutants.out/outcomes.json'))
outcomes = report['outcomes']
# Per-outcome classification lives in the serialized 'summary' field;
# aggregate counts sit on the top-level report object. Baseline builds
# are excluded from the mutant population.
def classify(o):
summary = o.get('summary', '')
if isinstance(summary, dict):
return summary.get('type', '')
return summary
caught = sum(1 for o in outcomes if classify(o) == 'CaughtMutant')
timeout = sum(1 for o in outcomes if classify(o) == 'Timeout')
unviable = sum(1 for o in outcomes if classify(o) == 'Unviable')
mutants = [o for o in outcomes if o.get('scenario') != 'Baseline']
viable = len(mutants) - unviable
rate = caught / viable if viable else 0.0
print(f\"Shard '{name}' kill rate: {caught}/{viable} = {rate:.1%} (timeouts: {timeout})\")
survivors = [o for o in mutants if classify(o) == 'MissedMutant']
for s in survivors:
# Scenario::Mutant(Mutant) serializes as an externally-tagged enum;
# line/column live under .span.start (the 2026-09-07 first sweep
# printed ':None' — the Mutant object has no top-level 'line').
mut = s.get('scenario', {}).get('Mutant', {})
line = mut.get('span', {}).get('start', {}).get('line', '?')
print(f\" survivor: {mut.get('file')}:{line} - {mut.get('replacement')}\")
if rate < floor:
print('::error::shard kill rate below floor')
sys.exit(1)
"
- name: Upload shard report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with:
name: mutation-report-${{ matrix.shard.name }}
path: mutants.out/
retention-days: 30