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:
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:
i.e. e2e/e2e-preview may be onlysuccess 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.
## 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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 theci-passedgate so an intentional skip passes while a real failure/cancel (or a broken filter) still blocks.Design
changesjob (new, first, cheap). Usesdorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706(v4.0.2, pinned SHA) withpredicate-quantifier: 'every'over a small safe allow-list:e2e_needed = NOT skippable.everymeansskippableis 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, rootbuild.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 totrueif unsure" rule, expressed as an allow-list of provably-safe paths rather than a guess at what is unsafe. Non-pull_requestevents (the traffic-controller'sworkflow_dispatchre-trigger) also default totrue.v4.0.2 is required because
predicate-quantifierdoes not exist in v3.0.2.Gating.
e2eande2e-previeweach getneeds: changes+if: needs.changes.outputs.e2e_needed == 'true', so the whole matrix (all API legs / both preview shards) runs or skips together.Rewritten
ci-passedgate. Replaces the oldcontains(needs.*.result, 'skipped') -> exit 1(which would fail on any skip) with an explicit condition — BLOCK (exit 1) iff:i.e.
e2e/e2e-previewmay be onlysuccessorskipped; every other required job must besuccess.changesis added toneeds. Branch protection's required context (CI passed) is unchanged.Truth-table
changeschangesjob itself failedValidation
actionlintclean; YAML parses..github/workflows/ci.yml, which is outside the allow-list, soe2e_needed=trueand E2E runs on this PR — exercising the run + gate-success path live.pull_requesttomain, anddorny/paths-filterdiffs the whole PR against its base, so any PR editingci.yml— including this one — ise2e_needed=true; a real skip first appears once the filter is onmain). Instead the exact gateif: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
🤖 Generated with Claude Code