From e668330151cfc41b63614c20500e3155e73e098c Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 6 Jul 2026 20:55:58 -0500 Subject: [PATCH] ci: skip the E2E matrix for test-only/docs PRs via a paths-filter + skip-tolerant gate (#399) Add a cheap `changes` job (dorny/paths-filter v4, pinned SHA) that sets e2e_needed=false only when EVERY changed file is in a safe allow-list (app/src/test/**, **/*.md, docs/**, scripts/**, .claude/**); anything else -- or any non-pull_request event -- defaults to true (conservative, "err toward running E2E"). Gate `e2e` and `e2e-preview` on needs.changes.outputs.e2e_needed so the whole matrix runs or skips together, and rewrite the `ci-passed` gate: it now BLOCKS on changes!=success, any of traffic-control-tests / static-analysis / debug-build / unit-tests !=success, or e2e/e2e-preview ==failure|cancelled -- while TOLERATING an intentional e2e/e2e-preview 'skipped'. So test-only/docs PRs go green on the fast gate, a real E2E failure/cancel still blocks, and a broken filter (changes!=success) still blocks. Branch protection ("CI passed") context is unchanged. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 110 ++++++++++++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1467be9..c00abe3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,56 @@ env: ANDROID_BUILD_TOOLS: "build-tools;37.0.0" jobs: + # Path-filter gate (issue #399): a cheap first job that decides whether the heavy E2E matrix + # (`e2e` + `e2e-preview`, ~10 emulator jobs) needs to run for this change. Test-only / docs / + # dev-script PRs then skip E2E and merge on the fast gate (unit + static) instead of queuing + # behind the emulator matrix. The `ci-passed` gate below is rewritten to treat an INTENTIONAL + # E2E skip as a pass while still blocking a real E2E failure/cancel or a broken filter. + changes: + name: Detect changed paths + runs-on: ubuntu-latest + # dorny/paths-filter lists a pull request's changed files via the GitHub API (no checkout), + # which needs pull-requests: read on top of the workflow-default contents: read. + permissions: + contents: read + pull-requests: read + outputs: + # 'true' -> run the E2E matrix; 'false' -> skip it (only for test-only/docs/script PRs). + e2e_needed: ${{ steps.decide.outputs.e2e_needed }} + steps: + - name: Filter changed paths (pull requests only) + id: filter + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + with: + # predicate-quantifier: 'every' makes the `skippable` filter true ONLY when EVERY changed + # file matches one of these safe patterns. We invert it below (e2e_needed = NOT skippable), + # so ANY file outside this small allow-list — app/src/main, app/src/androidTest, + # app/build.gradle.kts, root build.gradle*/settings.gradle*, gradle/** (incl. the version + # catalog & wrapper), gradle.properties, app/schemas, app/proguard-rules.pro, another + # workflow, .github/scripts, … — forces the E2E matrix to run. That is the conservative + # "err toward running E2E / default to true if unsure" rule: the skip list is an explicit + # allow-list of things that provably cannot affect app runtime or instrumented tests, + # never a guess about what is unsafe. + predicate-quantifier: 'every' + filters: | + skippable: + - 'app/src/test/**' + - '**/*.md' + - 'docs/**' + - 'scripts/**' + - '.claude/**' + - name: Decide whether the E2E matrix is needed + id: decide + run: | + if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ steps.filter.outputs.skippable }}" = "true" ]; then + echo "e2e_needed=false" >> "$GITHUB_OUTPUT" + echo "E2E matrix SKIPPED: every changed file is under a test-only / docs / script / .claude path." + else + echo "e2e_needed=true" >> "$GITHUB_OUTPUT" + echo "E2E matrix NEEDED: build/runtime/instrumented paths changed, or this is not a pull_request (conservative default)." + fi + # Runner-priority orchestration (traffic-control) has been EXTRACTED from this file. # The `traffic-control` job that used to run here first (ordering the heavy jobs below, which # each declared it as a `needs:` dependency) now lives VERBATIM in its own workflow at @@ -340,6 +390,12 @@ jobs: e2e: name: E2E + # Path-filter gate (issue #399): gating the whole job means every matrix leg runs — or is + # skipped — together. On an intentional skip the `ci-passed` gate treats e2e's 'skipped' + # result as OK (a real failure/cancel still blocks). `needs: changes` waits only on the + # seconds-long filter job. + needs: changes + if: needs.changes.outputs.e2e_needed == 'true' runs-on: ubuntu-latest strategy: fail-fast: false @@ -544,6 +600,11 @@ jobs: # 37 into the main `e2e` matrix and delete this job. e2e-preview: name: E2E (API 37 preview) + # Path-filter gate (issue #399): runs or skips together with the `e2e` matrix. On an + # intentional skip the `ci-passed` gate treats e2e-preview's 'skipped' result as OK; a real + # failure/cancel still blocks. Both shard legs fan in under this one gated job. + needs: changes + if: needs.changes.outputs.e2e_needed == 'true' runs-on: ubuntu-latest timeout-minutes: 35 # Sharded N=2 (docs/perf/api37-e2e-sharding-spike.md). The instrumented suite is split across @@ -791,20 +852,45 @@ jobs: # `traffic-control` is intentionally NOT listed here — it has been extracted to its own # (mothballed/disabled) workflow, .github/workflows/traffic-control.yml, and the heavy jobs # below no longer depend on it, so it plays no part in this gate. The `traffic-control-tests` - # job (its pure-Python decision-core unit tests) IS a required input below. Treating a - # 'skipped'/'cancelled' required job as a gate failure keeps this fail-safe (blocks the - # merge rather than letting an untested change through). - needs: [traffic-control-tests, static-analysis, debug-build, unit-tests, e2e, e2e-preview] + # job (its pure-Python decision-core unit tests) IS a required input below. + # + # E2E path-filter (issue #399): `e2e` / `e2e-preview` are SKIPPED for test-only/docs/script + # PRs (see the `changes` job). A skip there is INTENTIONAL and must PASS, so — unlike the + # naive `contains(needs.*.result, 'skipped')` gate this replaces — 'skipped' is TOLERATED for + # those two jobs only. The gate stays fail-safe by BLOCKING on: + # * changes != success (a broken/failed filter never waves a PR through) + # * traffic-control-tests / static-analysis / debug-build / unit-tests != success + # * e2e == failure OR cancelled (a real E2E failure/cancel still blocks) + # * e2e-preview == failure OR cancelled + # i.e. e2e/e2e-preview may be ONLY 'success' or 'skipped'; every other required job must be + # 'success'. (If `changes` itself fails, e2e/e2e-preview skip — but `changes != success` + # blocks the merge anyway, so a broken filter is never waved through.) + needs: [changes, traffic-control-tests, static-analysis, debug-build, unit-tests, e2e, e2e-preview] runs-on: ubuntu-latest steps: - - name: Verify every required job succeeded - if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + # Always echo every required job's result (and the filter decision) for debuggability, + # before the gate step decides pass/fail. + - name: Echo required job results run: | - echo "Required CI jobs did not all succeed:" + echo "Required-job results (an E2E 'skipped' is allowed ONLY via the #399 path-filter):" + echo " changes: ${{ needs.changes.result }} (e2e_needed=${{ needs.changes.outputs.e2e_needed }})" echo " traffic-control-tests: ${{ needs.traffic-control-tests.result }}" - echo " static-analysis: ${{ needs.static-analysis.result }}" - echo " debug-build: ${{ needs.debug-build.result }}" - echo " unit-tests: ${{ needs.unit-tests.result }}" - echo " e2e: ${{ needs.e2e.result }}" - echo " e2e-preview: ${{ needs.e2e-preview.result }}" + echo " static-analysis: ${{ needs.static-analysis.result }}" + echo " debug-build: ${{ needs.debug-build.result }}" + echo " unit-tests: ${{ needs.unit-tests.result }}" + echo " e2e: ${{ needs.e2e.result }}" + echo " e2e-preview: ${{ needs.e2e-preview.result }}" + - name: Fail unless every required job passed (an intentionally path-filtered E2E skip is OK) + if: >- + needs.changes.result != 'success' || + needs.traffic-control-tests.result != 'success' || + needs.static-analysis.result != 'success' || + needs.debug-build.result != 'success' || + needs.unit-tests.result != 'success' || + needs.e2e.result == 'failure' || needs.e2e.result == 'cancelled' || + needs.e2e-preview.result == 'failure' || needs.e2e-preview.result == 'cancelled' + run: | + echo "::error::Required CI jobs did not all succeed (see the results above)." + echo "A path-filtered E2E 'skipped' is allowed; an E2E 'failure'/'cancelled', or any" + echo "non-success in changes/traffic-control-tests/static-analysis/debug-build/unit-tests, is not." exit 1 -- 2.47.3