ci: skip the E2E matrix for test-only/docs PRs via a paths-filter + skip-tolerant gate (#399) #402

Merged
JMR-dev merged 2 commits from ci-399-e2e-path-filter into main 2026-07-07 02:54:26 +00:00
JMR-dev commented 2026-07-07 01:57:10 +00:00 (Migrated from github.com)

What & why

Closes #399. Every PR — including test-only ones (new JVM tests under app/src/test/, docs, dev scripts) — currently triggers the full ~10-job E2E matrix, so test-only batches queue behind E2E and merges serialize. This path-filters the E2E matrix so PRs that don't touch app runtime or instrumented tests skip E2E and merge on the fast gate, and — the hard part — rewrites the ci-passed gate so an intentional skip passes while a real failure/cancel (or a broken filter) still blocks.

Design

changes job (new, first, cheap). Uses dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 (v4.0.2, pinned SHA) with predicate-quantifier: 'every' over a small safe allow-list:

skippable:
  - 'app/src/test/**'
  - '**/*.md'
  - 'docs/**'
  - 'scripts/**'
  - '.claude/**'

e2e_needed = NOT skippable. every means skippable is true only when every changed file is in that allow-list, so any file outside it — app/src/main/**, app/src/androidTest/**, app/build.gradle.kts, root build.gradle*/settings.gradle*, gradle/**, gradle.properties, app/schemas/**, app/proguard-rules.pro, another workflow, .github/scripts/**, … — forces E2E. This is the conservative "err toward running E2E / default to true if unsure" rule, expressed as an allow-list of provably-safe paths rather than a guess at what is unsafe. Non-pull_request events (the traffic-controller's workflow_dispatch re-trigger) also default to true.

v4.0.2 is required because predicate-quantifier does not exist in v3.0.2.

Gating. e2e and e2e-preview each get needs: changes + if: needs.changes.outputs.e2e_needed == 'true', so the whole matrix (all API legs / both preview shards) runs or skips together.

Rewritten ci-passed gate. Replaces the old contains(needs.*.result, 'skipped') -> exit 1 (which would fail on any skip) with an explicit condition — BLOCK (exit 1) iff:

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'

i.e. e2e/e2e-preview may be only success or skipped; every other required job must be success. changes is added to needs. Branch protection's required context (CI passed) is unchanged.

Truth-table

Scenario changes e2e / e2e-preview Gate
App/build/CI-config PR, E2E passes success success PASS
Test-only / docs PR (E2E path-filtered) success skipped PASS
Real E2E failure success failure BLOCK
E2E cancelled success cancelled BLOCK
changes job itself failed failure skipped BLOCK
Any of tct/static/debug/unit fails success any BLOCK

Validation

  • actionlint clean; YAML parses.
  • This PR touches .github/workflows/ci.yml, which is outside the allow-list, so e2e_needed=true and E2E runs on this PR — exercising the run + gate-success path live.
  • Skip path can't be shown on this PR (the workflow only triggers on pull_request to main, and dorny/paths-filter diffs the whole PR against its base, so any PR editing ci.yml — including this one — is e2e_needed=true; a real skip first appears once the filter is on main). Instead the exact gate if: was extracted from the YAML and evaluated against every scenario above (skip=pass, failure/cancel=block, changes-fail=block) plus the filter's decide logic — all 16 cases pass. The first test-only PR merged after this lands is the natural real-world confirmation.

Notes

  • Preserves #372 (E2E sharding), #388 (diagnostics), #391 (SDK-hardening) — untouched.
  • Auto-merge intentionally not armed: gate truth-table + path set are for coordinator review first.

🤖 Generated with Claude Code

## What & why Closes #399. Every PR — including test-only ones (new JVM tests under `app/src/test/`, docs, dev scripts) — currently triggers the full ~10-job E2E matrix, so test-only batches queue behind E2E and merges serialize. This path-filters the E2E matrix so PRs that don't touch app runtime or instrumented tests skip E2E and merge on the fast gate, and — the hard part — rewrites the `ci-passed` gate so an *intentional* skip passes while a real failure/cancel (or a broken filter) still blocks. ## Design **`changes` job (new, first, cheap).** Uses `dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706` (v4.0.2, pinned SHA) with **`predicate-quantifier: 'every'`** over a small **safe allow-list**: ``` skippable: - 'app/src/test/**' - '**/*.md' - 'docs/**' - 'scripts/**' - '.claude/**' ``` `e2e_needed = NOT skippable`. `every` means `skippable` is true only when **every** changed file is in that allow-list, so **any** file outside it — `app/src/main/**`, `app/src/androidTest/**`, `app/build.gradle.kts`, root `build.gradle*`/`settings.gradle*`, `gradle/**`, `gradle.properties`, `app/schemas/**`, `app/proguard-rules.pro`, another workflow, `.github/scripts/**`, … — forces E2E. This is the conservative "err toward running E2E / default to `true` if unsure" rule, expressed as an allow-list of *provably-safe* paths rather than a guess at what is unsafe. Non-`pull_request` events (the traffic-controller's `workflow_dispatch` re-trigger) also default to `true`. v4.0.2 is required because `predicate-quantifier` does not exist in v3.0.2. **Gating.** `e2e` and `e2e-preview` each get `needs: changes` + `if: needs.changes.outputs.e2e_needed == 'true'`, so the whole matrix (all API legs / both preview shards) runs or skips together. **Rewritten `ci-passed` gate.** Replaces the old `contains(needs.*.result, 'skipped') -> exit 1` (which would fail on *any* skip) with an explicit condition — BLOCK (`exit 1`) iff: ``` 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' ``` i.e. `e2e`/`e2e-preview` may be **only** `success` or `skipped`; every other required job must be `success`. `changes` is added to `needs`. Branch protection's required context (`CI passed`) is unchanged. ## Truth-table | Scenario | `changes` | e2e / e2e-preview | Gate | |---|---|---|---| | App/build/CI-config PR, E2E passes | success | success | **PASS** | | Test-only / docs PR (E2E path-filtered) | success | **skipped** | **PASS** | | Real E2E failure | success | failure | **BLOCK** | | E2E cancelled | success | cancelled | **BLOCK** | | `changes` job itself failed | **failure** | skipped | **BLOCK** | | Any of tct/static/debug/unit fails | success | any | **BLOCK** | ## Validation - **`actionlint` clean; YAML parses.** - **This PR touches `.github/workflows/ci.yml`**, which is outside the allow-list, so `e2e_needed=true` and **E2E runs on this PR** — exercising the run + gate-success path live. - **Skip path** can't be shown on this PR (the workflow only triggers on `pull_request` to `main`, and `dorny/paths-filter` diffs the *whole* PR against its base, so any PR editing `ci.yml` — including this one — is `e2e_needed=true`; a real skip first appears once the filter is on `main`). Instead the exact gate `if:` was extracted from the YAML and evaluated against every scenario above (skip=pass, failure/cancel=block, `changes`-fail=block) plus the filter's decide logic — **all 16 cases pass**. The first test-only PR merged after this lands is the natural real-world confirmation. ## Notes - Preserves #372 (E2E sharding), #388 (diagnostics), #391 (SDK-hardening) — untouched. - Auto-merge intentionally **not** armed: gate truth-table + path set are for coordinator review first. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.