1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
name: mutants
on:
pull_request:
merge_group:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
# Hermeticity: proptest picks a random seed per run unless pinned, which makes
# mutation outcomes non-deterministic. See scripts/check-mutation-gate.sh.
PROPTEST_RNG_SEED: "20260817"
jobs:
# Same rationale as main.yml's `changes` job: a doc-only or workflow-only PR
# cannot change cargo-mutants' verdict, and a full baseline build is the
# expensive part of this workflow, so skip it rather than pay for nothing.
# `schedule`/`workflow_dispatch` have no PR to diff against, so `nightly` below
# is never gated on this output. `merge_group` gives `paths-filter` no base to
# diff against either (same as main.yml's `changes` job) -- the filter step is
# skipped there and `pr-diff` below runs unconditionally instead.
changes:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
outputs:
rust: ${{ steps.filter.outputs.rust }}
steps:
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v3
id: filter
if: github.event_name != 'merge_group'
with:
filters: |
rust:
- '**/*.rs'
- '**/Cargo.toml'
- 'Cargo.lock'
- '.cargo/mutants.toml'
- '.github/workflows/mutants.yml'
# Gates every PR on the mutants introduced by that PR only. Typical PR: 5-20
# mutants, 2-8 min. Measured on this repo, a 492-line diff produced 73 mutants.
#
# Runs on `merge_group` too (main.yml `changes` job's rationale: the queue validates a
# *combination* no single pull request tested, so it never filters). `github.base_ref` is only
# populated for `pull_request`/`pull_request_target` events, so the base ref is resolved per
# event instead: `merge_group.base_sha` pins to the queue entry's actual base commit rather than
# whatever `origin/main`'s tip has moved to by the time this job runs.
pr-diff:
needs: changes
if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
# Pin: mutants.out/ file formats are explicitly documented as unstable.
tool: cargo-mutants@27.1.0
- name: Resolve mutation-gate base ref
id: base
run: |
if [ "${{ github.event_name }}" = "merge_group" ]; then
echo "ref=${{ github.event.merge_group.base_sha }}" >> "$GITHUB_OUTPUT"
else
echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
fi
# scripts/check-mutation-gate.sh is the single source of truth for this command (AGENTS.md
# §10: a rule enforced by eye, or reimplemented ad hoc in a workflow, belongs in a script
# both this job and a contributor's local run call) -- it also carries the `--workspace`
# rationale (in-diff otherwise scopes to the root `reconcile` package only) and the
# PROPTEST_RNG_SEED hermeticity note.
- run: ./scripts/check-mutation-gate.sh "${{ steps.base.outputs.ref }}"
- uses: actions/upload-artifact@v4
if: always()
with:
name: mutants-incremental
path: mutants.out
# Mirrors main.yml's `ci-success`, for the same reason: branch protection pins a required
# check by job name, so this workflow needs its own stable aggregate rather than pointing
# protection straight at `pr-diff`. Pointing at `pr-diff` directly is doubly wrong here --
# `changes` skips it on a doc-only or workflow-only PR (header comment above), and a required
# check that a skip leaves pending forever blocks the merge instead of passing it. `if:
# always()` and the skip/failure treatment are exactly main.yml's `ci-success` reasoning;
# see that job's comment for the detail.
#
# Also gated on `merge_group`, not just `pull_request`: without it, this job -- and therefore
# the required check -- never runs on a merge-queue entry, and branch protection blocks the
# queue on a check that can't report either way.
mutants-success:
name: mutants-success
if: always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group')
needs:
- changes
- pr-diff
runs-on: ubuntu-latest
steps:
- name: Fail if any job failed or was cancelled
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "::error::one or more mutation-gate jobs failed or were cancelled"
exit 1
# Full workspace sweep (~1400 mutants), sharded 8 ways so each job lands at
# ~20-35 min. Reported as a trend, NOT gated: a whole-repo mutation score is a
# number to watch, not a wall — hence `continue-on-error` and no `mutants-success`
# dependency (contrast `pr-diff` above, which `mutants-success` does require).
#
# `!= 'merge_group'` is load-bearing now that `merge_group` is a trigger (added for `pr-diff`
# above): without it, this condition -- originally just "not a PR" -- would also match every
# merge-queue entry and run the full ~60 min 8-shard sweep on each one, which `pr-diff` (already
# gating the queue on the incremental diff) makes redundant.
nightly:
if: github.event_name != 'pull_request' && github.event_name != 'merge_group'
runs-on: ubuntu-latest
timeout-minutes: 60
continue-on-error: true
strategy:
fail-fast: false
matrix:
shard:
steps:
- uses: actions/checkout@v3
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-mutants@27.1.0
# round-robin spreads cheap/expensive mutants evenly across shards;
# the default `slice` groups them, so one shard gets all the slow ones.
#
# No `--iterate`: it skips mutants recorded as already-caught in
# mutants.out/previously_caught.txt, but this job checks out fresh and never restores
# that file from a prior run's artifact, so the file is always empty and the flag is
# presently a no-op. Don't add it back without also restoring the artifact -- and if
# that restoration is ever added, keep a periodic run with no `--iterate` alongside it
# (upstream cargo-mutants recommends this), since an always-iterating job never re-checks
# a mutant it once caught.
- run: >
cargo mutants --workspace
--shard ${{ matrix.shard }}/8 --sharding round-robin
--baseline=skip --timeout 300
- uses: actions/upload-artifact@v4
if: always()
with:
name: mutants-nightly-${{ matrix.shard }}
path: mutants.out