Skip to content

Commit 4b2a02f

Browse files
ondrejmirtesclaude
andcommitted
Mutation testing: diff against a configured branch, cover only the changed files
The diff base came from the repository's default branch, so when 2.3.x became the default on 2026-09-02 every pull request against 2.2.x was diffed against 2.3.x and Infection got hundreds of "changed" files. The base is now set in the workflow, per branch, like its push branch. Infection also opens the coverage report of every source file the index lists, whatever the diff, and the report of all of src/ (3.6 GB of XML) outgrew the runner: the job died with "The runner has received a shutdown signal" on most pull requests that change a source file. Coverage is now collected only for the files Infection mutates - the same files it picks with --git-diff-base and its AM filter - and the job is skipped when there are none. Locally, for a change of IntersectionType.php, Infection's peak RSS drops from 12.1 GB to 0.43 GB with the same mutants generated and killed, and the coverage run takes half the time. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsHiTmsN24NMF7zvEhwbip
1 parent 3abeb0a commit 4b2a02f

1 file changed

Lines changed: 74 additions & 9 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -269,11 +269,56 @@ jobs:
269269
- name: "Tests"
270270
run: "make tests"
271271

272+
mutation-testing-changed-files:
273+
name: "Mutation Testing: changed files"
274+
runs-on: "ubuntu-latest"
275+
if: github.event_name == 'pull_request'
276+
277+
permissions:
278+
contents: read
279+
280+
env:
281+
# The branch the diff is taken against - the branch this file lives on.
282+
# Flipped by the "Open X.Y.x" commit, like the push branch above.
283+
MUTATION_TESTING_BASE_BRANCH: "2.2.x"
284+
285+
outputs:
286+
base-branch: ${{ env.MUTATION_TESTING_BASE_BRANCH }}
287+
any: ${{ steps.changed-files.outputs.any }}
288+
files: ${{ steps.changed-files.outputs.files }}
289+
290+
steps:
291+
- name: Harden the runner (Audit all outbound calls)
292+
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
293+
with:
294+
egress-policy: audit
295+
296+
- name: "Checkout"
297+
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
298+
with:
299+
persist-credentials: false
300+
301+
# The same files Infection picks with --git-diff-base and its default AM
302+
# filter over its source directories
303+
- name: "Find changed source files"
304+
id: changed-files
305+
run: |
306+
git fetch --depth=1 origin "$MUTATION_TESTING_BASE_BRANCH"
307+
base=$(git merge-base "origin/$MUTATION_TESTING_BASE_BRANCH" HEAD || echo "origin/$MUTATION_TESTING_BASE_BRANCH")
308+
files=$(git diff "$base" --diff-filter=AM --name-only -- src build/PHPStan/Build | grep '\.php$' || true)
309+
echo "$files"
310+
{
311+
if [ -n "$files" ]; then echo "any=true"; else echo "any=false"; fi
312+
echo 'files<<EOF'
313+
echo "$files"
314+
echo 'EOF'
315+
} >> "$GITHUB_OUTPUT"
316+
272317
mutation-testing:
273318
name: "Mutation Testing"
274319
runs-on: "ubuntu-latest"
275-
needs: ["tests"]
276-
if: github.event_name == 'pull_request'
320+
needs: ["tests", "mutation-testing-changed-files"]
321+
if: github.event_name == 'pull_request' && needs.mutation-testing-changed-files.outputs.any == 'true'
277322

278323
strategy:
279324
fail-fast: false
@@ -328,11 +373,6 @@ jobs:
328373
> infection.json5
329374
jq < infection.json5
330375
331-
- name: "Determine default branch"
332-
id: default-branch
333-
run: |
334-
echo "name=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')" >> "$GITHUB_OUTPUT"
335-
336376
- name: "Restore result cache"
337377
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
338378
with:
@@ -341,10 +381,33 @@ jobs:
341381
restore-keys: |
342382
result-cache-v1-${{ matrix.php-version }}-
343383
384+
# Infection reads the coverage report of every source file it lists, and
385+
# the whole of src/ outgrew the runner's memory - only the mutated files
386+
# need it
387+
- name: "Restrict coverage to the changed source files"
388+
env:
389+
CHANGED_FILES: ${{ needs.mutation-testing-changed-files.outputs.files }}
390+
run: |
391+
echo "$CHANGED_FILES" > "$RUNNER_TEMP/changed-files.txt"
392+
php -r '
393+
$document = new DOMDocument();
394+
$document->load("phpunit.xml");
395+
$include = $document->getElementsByTagName("source")->item(0)->getElementsByTagName("include")->item(0);
396+
while ($include->firstChild !== null) {
397+
$include->removeChild($include->firstChild);
398+
}
399+
foreach (file($argv[1], FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $file) {
400+
$include->appendChild($document->createElement("file", $file));
401+
}
402+
$document->save("phpunit-mutation-testing.xml");
403+
' "$RUNNER_TEMP/changed-files.txt"
404+
grep -A5 "<source" phpunit-mutation-testing.xml
405+
344406
# see https://infection.github.io/guide/command-line-options.html#coverage
345407
- name: "Create coverage in parallel"
346408
run: |
347409
php -d pcov.enabled=1 tests/vendor/bin/paratest \
410+
--configuration=phpunit-mutation-testing.xml \
348411
--passthru-php="'-d' 'pcov.enabled=1'" \
349412
--coverage-xml=tmp/coverage/coverage-xml \
350413
--coverage-clover=tmp/coverage/clover.xml \
@@ -357,10 +420,12 @@ jobs:
357420
path: tmp/coverage
358421

359422
- name: "Run infection"
423+
env:
424+
BASE_BRANCH: ${{ needs.mutation-testing-changed-files.outputs.base-branch }}
360425
run: |
361-
git fetch --depth=1 origin ${{ steps.default-branch.outputs.name }}
426+
git fetch --depth=1 origin "$BASE_BRANCH"
362427
infection \
363-
--git-diff-base=origin/${{ steps.default-branch.outputs.name }} \
428+
--git-diff-base="origin/$BASE_BRANCH" \
364429
--git-diff-lines \
365430
--coverage=tmp/coverage \
366431
--skip-initial-tests \

0 commit comments

Comments
 (0)