name: Mutants
on:
pull_request:
paths-ignore:
- "**.md"
- "docs/**"
push:
branches: [main]
paths-ignore:
- "**.md"
- "docs/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
in-diff:
name: Mutants in changed code
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install the pinned toolchain
run: rustup toolchain install
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-mutants
- name: Compute diff
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
base="$(scripts/mutants-base.sh "$EVENT_NAME" "$BASE_REF" "$BEFORE")"
echo "Diffing against $base"
# Force standard a/ b/ prefixes. `diff.mnemonicPrefix` emits `i/` and `w/` instead, which
# cargo-mutants does not recognise: it matches no files, logs "No mutants to filter" and
# EXITS 0, a silent pass.
git -c diff.mnemonicPrefix=false -c diff.noprefix=false \
diff "$base".. > mutants.diff
wc -l mutants.diff
if [ -s mutants.diff ] && ! grep -q '^diff --git a/' mutants.diff; then
echo "::error::diff lacks a/ b/ prefixes — cargo-mutants would silently match nothing"
head -3 mutants.diff
exit 1
fi
- name: Mutants
id: mutants
run: |
set -euo pipefail
# Every exit path records WHY, so Summarise can tell "tested nothing" from "tested
# everything and found nothing".
echo "ran" > mutants-status.txt
if [ ! -s mutants.diff ]; then
echo "empty" > mutants-status.txt
echo "::notice::empty diff — nothing to mutate"
exit 0
fi
selected=$(cargo mutants --list --in-diff mutants.diff | wc -l)
echo "$selected" > mutants-selected.txt
echo "Selected $selected mutants in changed regions"
if [ "$selected" -eq 0 ]; then
echo "none" > mutants-status.txt
echo "::notice::no mutants overlap the changed regions (test-, doc- or TOML-only change)"
exit 0
fi
# A broad diff (a reformat, a mass refactor) is a sweep, not a changed-code check, and
# would overrun the job. Measured on the runner (run 36363446362): ~3.5 min of baseline,
# then ~39s of wall clock per mutant at -j2, so 20 is ~16.5 min against the 20-minute
# timeout. Skip loudly rather than sample: a partial run reads as a pass.
MAX_SELECTED=20
if [ "$selected" -gt "$MAX_SELECTED" ]; then
echo "skipped" > mutants-status.txt
echo "::warning::Mutants skipped: $selected selected, over the limit of $MAX_SELECTED. A diff this broad is a sweep rather than a changed-code check and would exceed the job timeout. Nothing was mutation-tested for this push."
exit 0
fi
# Exit codes: 2 a mutant was MISSED (the finding); 3 a mutant TIMED OUT (usually an
# injected infinite loop, or a guard that bounds work — a real result, so counted as
# caught and passed); 4 the baseline failed; 5 the diff does not match the tree and 6 it
# is malformed, both harness bugs in "Compute diff" above rather than findings about
# safe-chains.
#
# No --timeout: cargo-mutants derives one from the measured baseline, so it adapts to
# the runner.
rc=0
cargo mutants --no-shuffle -vV -j2 --in-diff mutants.diff || rc=$?
if [ "$rc" -eq 3 ]; then
echo "::notice::timeouts counted as caught; see mutants.out/timeout.txt"
rc=0
fi
exit "$rc"
- name: Summarise
if: always()
run: |
set -euo pipefail
status="$(cat mutants-status.txt 2>/dev/null || echo unknown)"
selected="$(cat mutants-selected.txt 2>/dev/null || echo '?')"
{
echo '## Mutants in changed code'
echo
echo "_step outcome: ${{ steps.mutants.outcome }}_"
echo
case "$status" in
empty)
echo 'No diff to analyse. Nothing was mutation-tested.'
;;
none)
echo 'No mutants overlap the changed regions — a test-, docs- or TOML-only'
echo 'change. Nothing was mutation-tested, and that is expected.'
;;
skipped)
echo "**Skipped: $selected mutants selected, over the limit.**"
echo
echo 'The diff touches many functions, so `--in-diff` selects a large share of'
echo 'the tree. **Nothing was mutation-tested for this push.** To cover it'
echo 'deliberately, shard a manual run: `cargo mutants --in-diff <diff> --shard 0/4`'
;;
unknown)
echo 'The mutants step did not run — an earlier step failed (see "Compute diff").'
echo '**Nothing was mutation-tested.**'
;;
*)
if [ ! -d mutants.out ]; then
echo 'cargo-mutants started but produced no results directory — it failed'
echo 'before reporting (exit 4, 5 or 6; see the step log).'
echo '**Treat this as no coverage, not as a pass.**'
exit 0
fi
for kind in missed caught unviable timeout; do
f="mutants.out/$kind.txt"
n=0
[ -f "$f" ] && n=$(wc -l < "$f" | tr -d ' ')
echo "- **$kind**: $n"
done
if [ -s mutants.out/missed.txt ]; then
echo
echo '### Missed — a bug was inserted here and no test noticed'
echo
echo '```'
cat mutants.out/missed.txt
echo '```'
echo
echo 'Three legitimate responses: write a test that fails on it; judge it an'
echo '*equivalent* mutant that cannot change behaviour (record why in docs/agents/mutation-testing.md);'
echo 'or exclude the code in `.cargo/mutants.toml` with a comment saying why.'
fi
;;
esac
} >> "$GITHUB_STEP_SUMMARY"
- name: Archive mutants.out
if: always()
uses: actions/upload-artifact@v7
with:
name: mutants-in-diff
path: mutants.out
if-no-files-found: ignore
slice:
name: Mutants, rotating slice
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 20
env:
SLICES: 512
steps:
- uses: actions/checkout@v7
- name: Install the pinned toolchain
run: rustup toolchain install
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-mutants
- name: Mutants
run: |
set -euo pipefail
k=$(( ${{ github.run_number }} % SLICES ))
echo "$k" > mutants-slice.txt
echo "Slice $k of $SLICES"
rc=0
cargo mutants --no-shuffle -vV -j2 --shard "$k/$SLICES" || rc=$?
if [ "$rc" -eq 3 ]; then
echo "::notice::timeouts counted as caught; see mutants.out/timeout.txt"
rc=0
fi
exit "$rc"
- name: Summarise
if: always()
run: |
set -euo pipefail
k="$(cat mutants-slice.txt 2>/dev/null || echo '?')"
{
echo "## Mutants, slice $k of $SLICES"
echo
if [ ! -d mutants.out ]; then
echo 'No results directory: the run failed before reporting.'
echo '**Treat this as no coverage, not as a pass.**'
exit 0
fi
for kind in missed caught unviable timeout; do
f="mutants.out/$kind.txt"
n=0
[ -f "$f" ] && n=$(wc -l < "$f" | tr -d ' ')
echo "- **$kind**: $n"
done
if [ -s mutants.out/missed.txt ]; then
echo
echo '### Missed in this slice'
echo
echo '```'
cat mutants.out/missed.txt
echo '```'
echo
echo 'Old code, not this push: give each a test or an exclusion with its reason,'
echo 'as for any other miss.'
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Archive mutants.out
if: always()
uses: actions/upload-artifact@v7
with:
name: mutants-slice
path: mutants.out
if-no-files-found: ignore