|
| 1 | +# Mutation testing: every guard broken on purpose, to see that it notices. |
| 2 | +# |
| 3 | +# A green suite says the code does what the guards check. It does not say the |
| 4 | +# guards check anything - that sentence needs each one broken on purpose, and |
| 5 | +# internal/guard/mutation/entries.py holds one entry per break. Until |
| 6 | +# 2026-10-15 the entries lived outside the repository and ran on one machine, |
| 7 | +# where a full pass took over an hour and was rarely run. Of 1779 entries that |
| 8 | +# day, 236 had been proven against the code as it stood. |
| 9 | +# |
| 10 | +# Two kinds of run, one path through the same jobs: |
| 11 | +# |
| 12 | +# - weekly, and by hand from the Actions tab: every entry, cut into sixteen |
| 13 | +# parts that run side by side. Red when a mutation is not caught. This is |
| 14 | +# what catches a guard that stopped reaching its code through a change in |
| 15 | +# a third file, which no selection by file can see. |
| 16 | +# - on a pull request: only the entries the change reaches - the file an |
| 17 | +# entry mutates, the file its guard is in, or the entry itself. Reported |
| 18 | +# and never red for now, while the runner earns trust on these machines. |
| 19 | +# |
| 20 | +# The plan job decides how many entries and parts there are, each part judges |
| 21 | +# its own share in disposable copies of the tree, and the report job puts the |
| 22 | +# parts together. A part that never reports is a failure there rather than a |
| 23 | +# smaller run. |
| 24 | +name: mutation |
| 25 | + |
| 26 | +on: |
| 27 | + pull_request: |
| 28 | + schedule: |
| 29 | + # Sunday night. Monday morning already holds the fuzzing job of ci.yml at |
| 30 | + # 04:17, the OSV scan at 05:17 and the tool versions at 06:00, and this |
| 31 | + # run takes sixteen of the twenty jobs a free plan runs at once. |
| 32 | + - cron: "37 1 * * 0" |
| 33 | + workflow_dispatch: |
| 34 | + |
| 35 | +permissions: |
| 36 | + contents: read |
| 37 | + |
| 38 | +# A newer push to the same pull request makes the older run worthless, so it |
| 39 | +# is cancelled rather than left to hold sixteen machines. |
| 40 | +concurrency: |
| 41 | + group: mutation-${{ github.event.pull_request.number || github.ref }} |
| 42 | + cancel-in-progress: true |
| 43 | + |
| 44 | +env: |
| 45 | + GO_VERSION: "1.27.2" |
| 46 | + # Built as the test job builds on Linux, so a verdict here is about the |
| 47 | + # guards CI actually runs. Entries on files only a cgo build or only |
| 48 | + # Windows compiles come back UNJUDGED, said out loud in the report. |
| 49 | + CGO_ENABLED: "0" |
| 50 | + |
| 51 | +jobs: |
| 52 | + plan: |
| 53 | + name: which entries |
| 54 | + runs-on: ubuntu-latest |
| 55 | + timeout-minutes: 10 |
| 56 | + outputs: |
| 57 | + entries: ${{ steps.plan.outputs.entries }} |
| 58 | + parts: ${{ steps.plan.outputs.parts }} |
| 59 | + shards: ${{ steps.plan.outputs.shards }} |
| 60 | + steps: |
| 61 | + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 62 | + with: |
| 63 | + persist-credentials: false |
| 64 | + # The whole history, because a pull request is compared with its |
| 65 | + # base and the base has to be here to be compared with. |
| 66 | + fetch-depth: 0 |
| 67 | + |
| 68 | + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 |
| 69 | + with: |
| 70 | + python-version: "3.14" |
| 71 | + |
| 72 | + - name: what the runner needs |
| 73 | + run: pip install -r .github/requirements-mutation.txt |
| 74 | + |
| 75 | + - name: the plan |
| 76 | + id: plan |
| 77 | + env: |
| 78 | + BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} |
| 79 | + run: | |
| 80 | + python internal/guard/mutation/mutate.py --plan ${BASE:+--changed "$BASE"} >> "$GITHUB_OUTPUT" |
| 81 | + if grep -qx 'entries=0' "$GITHUB_OUTPUT"; then |
| 82 | + echo "No mutation entry is reached by this change, so none was run." >> "$GITHUB_STEP_SUMMARY" |
| 83 | + fi |
| 84 | +
|
| 85 | + run: |
| 86 | + name: part ${{ matrix.shard }} |
| 87 | + needs: plan |
| 88 | + if: needs.plan.outputs.entries != '0' |
| 89 | + runs-on: ubuntu-latest |
| 90 | + # A part of the weekly run is about 110 entries at three at a time. The |
| 91 | + # first run measures what that costs here, and this ceiling is far above |
| 92 | + # it so that a hang ends as a part that did not report. |
| 93 | + timeout-minutes: 120 |
| 94 | + strategy: |
| 95 | + fail-fast: false |
| 96 | + matrix: |
| 97 | + shard: ${{ fromJSON(needs.plan.outputs.shards) }} |
| 98 | + steps: |
| 99 | + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 100 | + with: |
| 101 | + persist-credentials: false |
| 102 | + fetch-depth: 0 |
| 103 | + |
| 104 | + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 |
| 105 | + with: |
| 106 | + go-version: ${{ env.GO_VERSION }} |
| 107 | + check-latest: false |
| 108 | + cache: true |
| 109 | + |
| 110 | + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 |
| 111 | + with: |
| 112 | + python-version: "3.14" |
| 113 | + |
| 114 | + - name: what the runner needs |
| 115 | + run: pip install -r .github/requirements-mutation.txt |
| 116 | + |
| 117 | + - name: the entries of this part |
| 118 | + # Never red on its own. The verdicts go to the JSON file and the |
| 119 | + # report job decides - and a step that crashed before writing it is |
| 120 | + # found there as a part that did not report. |
| 121 | + continue-on-error: true |
| 122 | + env: |
| 123 | + BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} |
| 124 | + SHARD: ${{ matrix.shard }} |
| 125 | + PARTS: ${{ needs.plan.outputs.parts }} |
| 126 | + run: > |
| 127 | + python internal/guard/mutation/mutate.py ${BASE:+--changed "$BASE"} |
| 128 | + --shard "$SHARD/$PARTS" --workers 3 --json "result-$SHARD.json" |
| 129 | +
|
| 130 | + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 |
| 131 | + if: always() |
| 132 | + with: |
| 133 | + name: mutation-result-${{ matrix.shard }} |
| 134 | + path: result-*.json |
| 135 | + if-no-files-found: ignore |
| 136 | + retention-days: 14 |
| 137 | + |
| 138 | + report: |
| 139 | + name: what the run found |
| 140 | + needs: [plan, run] |
| 141 | + if: always() && needs.plan.result == 'success' && needs.plan.outputs.entries != '0' |
| 142 | + runs-on: ubuntu-latest |
| 143 | + timeout-minutes: 10 |
| 144 | + steps: |
| 145 | + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 146 | + with: |
| 147 | + persist-credentials: false |
| 148 | + |
| 149 | + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 |
| 150 | + with: |
| 151 | + python-version: "3.14" |
| 152 | + |
| 153 | + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 |
| 154 | + # A run in which no part reported has nothing to download, and that |
| 155 | + # is for the report to say rather than for this step to fail on. |
| 156 | + continue-on-error: true |
| 157 | + with: |
| 158 | + pattern: mutation-result-* |
| 159 | + path: results |
| 160 | + merge-multiple: true |
| 161 | + |
| 162 | + - name: the verdict |
| 163 | + env: |
| 164 | + ENTRIES: ${{ needs.plan.outputs.entries }} |
| 165 | + PARTS: ${{ needs.plan.outputs.parts }} |
| 166 | + # Red on the weekly run and on a run by hand. On a pull request the |
| 167 | + # same findings are warnings until the runner has earned trust here. |
| 168 | + GATE: ${{ github.event_name != 'pull_request' && '--gate' || '' }} |
| 169 | + run: > |
| 170 | + python internal/guard/mutation/report.py --dir results |
| 171 | + --entries "$ENTRIES" --shards "$PARTS" $GATE |
0 commit comments