From c9e0ccd2294bead7bb9d0c5ddbc3bad3b834ea33 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 3 Oct 2026 12:59:08 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01JsHiTmsN24NMF7zvEhwbip --- .github/workflows/tests.yml | 84 +++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 43bbc64fd39..467616a1f8a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -269,11 +269,56 @@ jobs: - name: "Tests" run: "make tests" + mutation-testing-changed-files: + name: "Mutation Testing: changed files" + runs-on: "ubuntu-latest" + if: github.event_name == 'pull_request' + + permissions: + contents: read + + env: + # The branch the diff is taken against - the branch this file lives on. + # Flipped by the "Open X.Y.x" commit, like the push branch above. + MUTATION_TESTING_BASE_BRANCH: "2.2.x" + + outputs: + base-branch: ${{ env.MUTATION_TESTING_BASE_BRANCH }} + any: ${{ steps.changed-files.outputs.any }} + files: ${{ steps.changed-files.outputs.files }} + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: "Checkout" + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + # The same files Infection picks with --git-diff-base and its default AM + # filter over its source directories + - name: "Find changed source files" + id: changed-files + run: | + git fetch --depth=1 origin "$MUTATION_TESTING_BASE_BRANCH" + base=$(git merge-base "origin/$MUTATION_TESTING_BASE_BRANCH" HEAD || echo "origin/$MUTATION_TESTING_BASE_BRANCH") + files=$(git diff "$base" --diff-filter=AM --name-only -- src build/PHPStan/Build | grep '\.php$' || true) + echo "$files" + { + if [ -n "$files" ]; then echo "any=true"; else echo "any=false"; fi + echo 'files<> "$GITHUB_OUTPUT" + mutation-testing: name: "Mutation Testing" runs-on: "ubuntu-latest" - needs: ["tests"] - if: github.event_name == 'pull_request' + needs: ["tests", "mutation-testing-changed-files"] + if: github.event_name == 'pull_request' && needs.mutation-testing-changed-files.outputs.any == 'true' strategy: fail-fast: false @@ -328,11 +373,6 @@ jobs: > infection.json5 jq < infection.json5 - - name: "Determine default branch" - id: default-branch - run: | - echo "name=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')" >> "$GITHUB_OUTPUT" - - name: "Restore result cache" uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: @@ -341,10 +381,34 @@ jobs: restore-keys: | result-cache-v1-${{ matrix.php-version }}- + # Infection reads the coverage report of every source file it lists, and + # the whole of src/ outgrew the runner's memory - only the mutated files + # need it + - name: "Restrict coverage to the changed source files" + env: + CHANGED_FILES: ${{ needs.mutation-testing-changed-files.outputs.files }} + run: | + echo "$CHANGED_FILES" > "$RUNNER_TEMP/changed-files.txt" + php -- "$RUNNER_TEMP/changed-files.txt" <<'PHP' + load('phpunit.xml'); + $include = $document->getElementsByTagName('source')->item(0)->getElementsByTagName('include')->item(0); + while ($include->firstChild !== null) { + $include->removeChild($include->firstChild); + } + foreach (file($argv[1], FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $file) { + $include->appendChild($document->createElement('file', $file)); + } + $document->save('phpunit-mutation-testing.xml'); + PHP + grep -A5 "