From ba719b56671a154cd9434f92261f5e691c90f3ac Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Thu, 12 Nov 2026 10:06:10 +0100 Subject: [PATCH] ci: skip the installation matrix on documentation-only pull requests CI already skips its test jobs when a pull request touches nothing outside the documentation paths, but the six-job installation matrix still runs. Move the detector into a reusable workflow so both callers share one implementation, and gate the installation matrix on its answer. The gating is deliberately asymmetric. CI's jobs are ordinary named jobs, so a job-level condition skips them and still reports each required context as skipped. The installation job is a matrix, and GitHub evaluates a job-level `if` before expanding it: skipping the job emits a single unexpanded check, so the six required contexts never report and the pull request hangs at "Expected" forever. That job therefore always expands, and the predicate moves to its steps. Both paths fail safe. Work is skipped only when the detector ran to success and positively reported code=false; failure, cancellation, a skip or an empty output all run the suite, so a broken detector costs time rather than coverage. --- .github/workflows/ci.yml | 46 +------------------ .github/workflows/detect-code-changes.yml | 56 +++++++++++++++++++++++ .github/workflows/install.yml | 24 ++++++++-- 3 files changed, 78 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/detect-code-changes.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 524bfaf69f..76366bc17d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,51 +10,7 @@ jobs: # Every test job runs unless this job succeeded and positively reported # code=false, so a broken detector costs time rather than coverage. changes: - name: Detect code changes - runs-on: ubuntu-latest - outputs: - code: ${{ steps.filter.outputs.code }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Decide whether anything testable changed - id: filter - env: - BASE_REF: ${{ github.base_ref }} - run: | - # Conservative by construction: only a recognised documentation path - # votes "no code". Anything unrecognised -- and every push build, which - # has no base to diff against -- runs the full suite. - if [ -z "$BASE_REF" ]; then - echo "Push build: running everything." - echo "code=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - git fetch --no-tags origin "$BASE_REF" - base=$(git merge-base FETCH_HEAD HEAD) - # --no-renames: a rename is reported as its destination alone, so moving - # a module under docs/ would otherwise hide the deleted code path. - changed=$(git diff --no-renames --name-only "$base" HEAD) - - echo "Changed files:" - echo "$changed" - - # `grep -v` exits 1 when everything was filtered out, which is exactly - # the documentation-only case, so it must not trip `set -e`. - testable=$(echo "$changed" | grep -Ev '(\.md$|^docs/|^\.github/ISSUE_TEMPLATE/)' || true) - - if [ -z "$testable" ]; then - echo "Documentation only: skipping the test jobs." - echo "code=false" >> "$GITHUB_OUTPUT" - else - echo "Testable changes present:" - echo "$testable" - echo "code=true" >> "$GITHUB_OUTPUT" - fi + uses: ./.github/workflows/detect-code-changes.yml unit-tests: name: Unit Tests diff --git a/.github/workflows/detect-code-changes.yml b/.github/workflows/detect-code-changes.yml new file mode 100644 index 0000000000..29af4aadc5 --- /dev/null +++ b/.github/workflows/detect-code-changes.yml @@ -0,0 +1,56 @@ +name: Detect code changes + +on: + workflow_call: + outputs: + code: + description: '"true" when anything outside the documentation paths changed.' + value: ${{ jobs.filter.outputs.code }} + +jobs: + filter: + name: Detect code changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Decide whether anything testable changed + id: filter + env: + BASE_REF: ${{ github.base_ref }} + run: | + # Conservative by construction: only a recognised documentation path + # votes "no code". Anything unrecognised -- and every push build, which + # has no base to diff against -- runs the full suite. + if [ -z "$BASE_REF" ]; then + echo "Push build: running everything." + echo "code=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git fetch --no-tags origin "$BASE_REF" + base=$(git merge-base FETCH_HEAD HEAD) + # --no-renames: a rename is reported as its destination alone, so moving + # a module under docs/ would otherwise hide the deleted code path. + changed=$(git diff --no-renames --name-only "$base" HEAD) + + echo "Changed files:" + echo "$changed" + + # `grep -v` exits 1 when everything was filtered out, which is exactly + # the documentation-only case, so it must not trip `set -e`. + testable=$(echo "$changed" | grep -Ev '(\.md$|^docs/|^\.github/ISSUE_TEMPLATE/)' || true) + + if [ -z "$testable" ]; then + echo "Documentation only: skipping the test jobs." + echo "code=false" >> "$GITHUB_OUTPUT" + else + echo "Testable changes present:" + echo "$testable" + echo "code=true" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index 25bde9f831..95b9ea57d5 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -7,8 +7,18 @@ on: branches: [ main, '3.0' ] jobs: + # The matrix runs unless this job succeeded and positively reported + # code=false, so a broken detector costs time rather than coverage. + changes: + uses: ./.github/workflows/detect-code-changes.yml + test: name: ${{ matrix.py }} - ${{ matrix.os }} + needs: changes + # The matrix must expand even when there is nothing to install: a job-level + # skip reports one unexpanded check, so the six required contexts would + # never appear and every documentation-only PR would hang at "Expected". + if: always() runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -23,23 +33,31 @@ jobs: py: "3.14" steps: + # The predicate is written once here, as a step output, rather than + # repeated on every step below. + - name: Decide whether to install + id: gate + run: echo "skip=${{ needs.changes.result == 'success' && needs.changes.outputs.code == 'false' }}" >> "$GITHUB_OUTPUT" + - name: Checkout code + if: steps.gate.outputs.skip != 'true' uses: actions/checkout@v4 - name: Setup Python + if: steps.gate.outputs.skip != 'true' uses: actions/setup-python@v5 with: python-version: ${{ matrix.py }} - name: Free up disk space (Linux only) - if: runner.os == 'Linux' + if: runner.os == 'Linux' && steps.gate.outputs.skip != 'true' run: | sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc sudo apt-get clean sudo rm -rf ~/.cache/pip /tmp/* /var/tmp/* - name: Install pufferlib (Linux) - if: runner.os == 'Linux' + if: runner.os == 'Linux' && steps.gate.outputs.skip != 'true' run: | python -m pip install -U pip "setuptools<81" wheel numpy # CPU-only torch. The CUDA stack is ~3 GB of wheels and no longer @@ -48,7 +66,7 @@ jobs: pip install -e . --no-build-isolation - name: Install pufferlib (macOS) - if: runner.os != 'Linux' + if: runner.os != 'Linux' && steps.gate.outputs.skip != 'true' run: | python -m pip install -U pip # Deliberately a plain install: macOS wheels are already CPU-only, so