fix(ci): restructure the Pi job graph - full suite, one canonical job, deadlines, advisory canary#423
Conversation
…, deadlines, advisory canary Four CI-structure defects around the Pi jobs, fixed together because they all reshape the same job graph in .github/workflows/ci.yml. #405 - required PR CI named 6 of the 23 committed plugins/ca-pi/tools/test/ *.test.ts files, so policy, plan-mode, dispatch, background-jobs, activity, doctor, footer, notices, runtime-resolver, and windows-supervisor could all regress with every required check green. The new canonical Pi job runs an UNFILTERED `npm test`, and test_ci_impact.py now computes the partition from the workflow text and fails when any committed test file is unassigned to a required shard. #390 - the six-cell os x pi-version matrix re-ran package-metadata checking, typecheck, the deterministic Vitest suites, the static security/parity scripts, the bundle rebuild + staleness gate, the release package contract, and a 100-sample benchmark once per cell (1,423 runner-seconds on run 29870271440). Those steps consume neither matrix.os nor matrix.pi-version, so they move to a single canonical ca-pi-checks job; the matrix keeps only the installed-Pi admission boundary, live package RPC activation, and the cross-platform platform contract. ca-pi-checks is registered in BOTH ci-passed's `needs:` list AND its `required_results` string, and a new contract test asserts the two registrations agree for every job (only the advisory canary may diverge). The canonical job resolves its Pi version from the repository's own support policy rather than hardcoding it, so a Pi promotion cannot leave it pinned to a stale runtime. #399 - every Pi CI and promotion job now declares timeout-minutes derived from recorded runtimes with bounded headroom, and a contract test rejects a future Pi job that omits one. #381 - the [WATCH] upstream-compatibility canary reported a FAILED check run whenever upstream published a Pi outside the validated window (live on PR #420 while main was green). A job-level continue-on-error does not change the check conclusion, so it is removed; the two upstream probes now absorb their own failure at step level and an always-published receipt records the version and each probe outcome in the job summary. A break in the canary HARNESS still turns the check red. Closes #405 Closes #390 Closes #399 Closes #381 Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
The first required-CI run of the complete Pi Vitest suite (this PR's #405 fix) immediately caught a defect the old 6-of-23 file filter had been hiding: plan-mode.test.ts hardcodes "C:/repo" as the repositoryRoot argument to operatePlanFile(). node:path isAbsolute() is platform-native, so that string is absolute on Windows and RELATIVE on Linux and macOS - operatePlanFile() rejects a non-absolute root and returns OPERATION_FAILED, so four of the canonical plan-file bridge cases asserted nothing on the two hosts where they never ran. Observed on run 30129967015, job "[CHECK] | [PI ] | Host-independent adapter contract" (ubuntu-latest): FAIL test/plan-mode.test.ts > canonical plan-file bridge operations AssertionError: expected { ok: false } to deeply equal { ok: true, ...(2) } Test Files 1 failed | 22 passed (23) Tests 4 failed | 535 passed | 7 skipped (546) The fix is test-only: one REPOSITORY_ROOT constant picking a platform-absolute spelling. Production plan-mode.ts is unchanged and was always correct. Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
The Pi release guard requires any plugins/ca-pi/** change to advance the manifest version together with regenerated root metadata and a new exact Keep-a-Changelog heading. The previous commit's test-portability fix touches plugins/ca-pi/tools/test/, so 0.1.1 -> 0.1.2 (patch: test-only, no payload behavior change). ca-pi-v0.1.1 was never tagged, so the initial release version named in docs/pi-parity-testing.md moves with it. Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
Hosted verification + two follow-on commitsCI is green on run 30130997652. Recording what the hosted runs actually proved, and the two commits added after the first push. #405 immediately caught a real hidden defectThe very first hosted run of the unfiltered suite (30129967015, job Root cause: Fixed in This is exactly the class of regression #405 says the partial file filter was hiding, caught on the first run of the fix. Payload version gateTouching Note this guard is stricter than the ca / ca-sandbox ones — it fires on any #390 — measured runner-second reduction (AC-4)
-314 runner-seconds (-21%) with no loss of required verdict coverage, and ~90s off the Windows critical path. The remaining matrix cost is the cold #381 — verified live
The exact failure this issue is about, from PR #420's canary (job 89598028781): That is the probe step, not the harness — the toolchain and latest-Pi install both succeeded. Under this PR's structure that step carries Honest caveat: by the time this PR's run executed (~45 min later) #399 — deadlines observed in practiceAll Pi jobs completed far inside their bounds on this run: One flake seen and clearedRun 30130392781's |
Review of the #390 split found a real, undisclosed Windows coverage subtraction. Moving activation/commands/status/tool-guard into the single ubuntu ca-pi-checks job pulled two genuinely host-DEPENDENT suites out of required Windows CI: - activation.test.ts:388 `test.skipIf(process.platform !== "win32")` (fixed user-global update cache + symlinked-update-state rejection) now executed in NO required job on any platform. - commands.test.ts's four `symlink(..., win32 ? "junction" : "dir")` containment sites only ever exercised the POSIX "dir" branch, so the Windows junction escape vector lost its gate. Re-audited the whole Vitest tree against an explicit rule instead of a hand-kept list. A file is host-dependent exactly when it compares the LIVE `process.platform` against a literal - either a `.skipIf`/`.runIf` gate that skips the whole test off the wrong OS, or a ternary that selects a different operating-system primitive. A platform value that is INJECTED (`buildChildEnv({ platform: "win32" })`) or merely forwarded into a pure function (`platform: process.platform`) does not qualify: child-env, compaction, status and tool-guard run identical code on every host, so they stay on the canonical lane. That partition yields 11 host-dependent files; bridge/package/process-tree/runner-isolation already run per cell via test_pi_platform_contract.py, and the other seven are restored to the matrix. The host-independent savings #390 asked for are untouched - typecheck, the bundle build, the benchmark and the Python scripts stay factored out, so the matrix is still lighter than the 452s pre-split cell. Also tightens the #405 partition contract, which was file-granular and blind to a test's own gate - it reported activation.test.ts as "assigned" while the ubuntu lane skipped its only win32 case. Two new invariants, each proven to bite by mutation: - every file that branches on the live platform must run in a job that fans out over all three operating systems (asserted independently in test_ci_impact.py and in test_pi_package.py's pi_ci_contract_violations oracle, both deriving the set from the sources); - no committed Pi test may be disabled by a static `.skip`/`.todo`/`.only`, which is the other way a file stays "assigned" while contributing no verdict. A platform gate must be `.skipIf`/`.runIf` so the rule above can hold it. Two gaps this lane's own NEEDS-TRIAGE had deferred are closed here, since both weaken an attestation the lane created: - verify_pi_support.py REQUIRED_HOSTED_CHECKS now names "[CHECK] | [PI ] | Host-independent adapter contract" directly instead of reaching it only through Merge readiness. - .github/ci-impact-map.json gains the `pi-checks` id, and `pi-adapter`'s reproduce command is corrected: the matrix no longer runs the whole suite, so the accurate local repro is test_pi_platform_contract.py. Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
Resolves three collisions with the work merged since a3a2df2: - plugins/ca-pi/CHANGELOG.md — main shipped its own 0.1.2 (plan-mode test fix, #423). This branch's payload therefore advances to 0.1.3; main's 0.1.2 section is preserved verbatim beneath it. plugins/ca-pi/package.json and the generated root package.json bumped together via tools/build-host-packages.py. - .codearbiter/security-controls.md — kept this branch's ADR-0016/ADR-0017 Pi boundary rows over main's superseded ADR-0014 rows, dropped this branch's `curl | bash` nixpacks row (main closed it under #401), and kept main's new "Closed exceptions" and "Container image inputs" sections verbatim. Line endings reconstructed against origin/main's exact per-line bytes, so the file stays 57/13 and `git diff --check` clean. - .github/workflows/ci.yml — main's #423 added the contract that any Pi test file branching on the live platform must run in the three-OS fan-out. test/child-env.test.ts became platform-gated on this branch (its cleanup cases take a POSIX-only symlink path), so it joins that job's file list. Without it, test_ci_impact and test_pi_package both fail. Verified on the merged tree: ca-pi 558 passed / 1 skipped, tsc clean; .github/scripts 1064 OK / 5 skipped; payload gate 0.1.2 -> 0.1.3 advanced together; live Pi 0.80.10 contract PASS; git diff --check clean. Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
Four CI-structure defects around the Pi jobs, fixed together because they all reshape the same job graph in
.github/workflows/ci.yml. This lane is CI-config, so the tests are assertions in.github/scripts/test_ci_impact.py(WorkflowContractTest, extended in the file's existing style) plus the pre-existingpi_ci_contract_violations()oracle in.github/scripts/test_pi_package.py.Closes #405
Closes #390
Closes #399
Closes #381
What changed
#405 — run every Pi adapter suite in required PR CI
Required CI named 6 of the 23 committed
plugins/ca-pi/tools/test/*.test.tsfiles. The other 17 (policy, plan-mode, dispatch, background-jobs, activity, doctor, footer, notices, runtime-resolver, windows-supervisor, security, farm, compaction, child-env, final-arguments, process-tree, runner-isolation) could regress with every required merge-readiness check green.The new canonical Pi job runs an unfiltered
npm test.test_ci_impact.pynow computes the partition from the workflow text — a barenpm testin a required Pi job covers the whole suite, a filterednpm test -- test/a.test.tscovers only what it names — and fails when any committed file is unassigned. A second test proves the contract bites: neutering the unfiltered run must leave files unassigned.File-granular assignment is not enough, and review caught that. Two further holes are now closed:
activation.test.ts:388istest.skipIf(process.platform !== "win32"); an ubuntu-only lane reports the file as covered and skips the case. So a second contract asserts that every file which branches on the liveprocess.platformruns in a job that fans out over all three operating systems. It is asserted independently intest_ci_impact.py(os_matrix_pi_test_files) and inpi_ci_contract_violations(), both deriving the set from the sources rather than from a hand-kept list — both would have to be wrong to lose the coverage..skip/.todo/.onlyanywhere in the committed Pi suite. (.onlyis included because it silently mutes every sibling;.failsis deliberately allowed — it executes the body and asserts it throws, so it is a real verdict.) A genuine platform gate must be written as.skipIf/.runIf, which contract 1 then holds to running somewhere the gate opens.Local evidence that the previously-omitted set is runnable and green:
#390 — factor host-independent Pi checks out of the six-cell matrix
The 3 OS x 2 Pi-version matrix re-ran package-metadata checking, typecheck, the deterministic Vitest suites, the static security/parity scripts, the bundle rebuild + staleness gate, the release package contract, and a 100-sample benchmark once per cell — 1,423 runner-seconds on run 29870271440.
New job
ca-pi-checks([CHECK] | [PI ] | Host-independent adapter contract), one cell on ubuntu-latest, owns:python tools/build-host-packages.py --checknpm run typechecknpm test(unfiltered — see Run every Pi adapter suite in required pull-request CI #405)python .github/scripts/test_pi_security.pypython .github/scripts/test_pi_parity.pynpm run build+ the committed-bundle staleness gatepython .github/scripts/test_pi_package.pypython .github/scripts/pi_benchmark.py --samples 100The partition, stated explicitly
The issue's AC is "keep every test whose behavior genuinely depends on the installed Pi version or operating system in the matrix." An earlier revision of this PR got that wrong and moved
activation.test.ts/commands.test.tsout of the matrix; review caught it. The rule now applied — and enforced by the two contracts above, not by a list:test.skipIf(process.platform !== "win32")(...)process.platform === "win32" ? "junction" : "dir"buildChildEnv({ platform: "win32", ... })runtime: { ..., platform: process.platform }Applying it to the committed tree gives 11 host-dependent files.
bridge,package,process-treeandrunner-isolationare already re-run per cell bytest_pi_platform_contract.py; the remaining seven —activation,background-jobs,commands,plan-mode,runtime-resolver,security,windows-supervisor— are executed by a new matrix step.child-env,compaction,statusandtool-guardcarry no live-platform comparison at all and run once onca-pi-checks.ca-pi-toolstherefore keeps: the installed-Pi admission boundary (npm test -- test/package.test.ts), the platform-gated Vitest set, live package RPC activation (test_pi_package.py --rpc-commands), andtest_pi_platform_contract.py --pi-version ${{ matrix.pi-version }}.The savings still hold. The heavy per-cell work #390 measured — six typechecks, six bundle builds, six 100-sample benchmarks, six copies of the static Python scripts, six release-package contracts — is gone. What returns to the matrix is Vitest time on an already-installed toolchain: 7 files, 159 tests, 4.7s locally. The pre-split worst cell (452s, windows-latest, run 30123976665) carried all of the removed work, so this job is a net reduction.
ca-pi-checksresolves its Pi version from the repo's own support policy (pi_promotion.py policy->last_verified) rather than hardcoding it, so a Pi promotion — which rewrites the matrix via.github/pi-promotion-targets.jsonbut would not know about a new hardcoded string — cannot leave this job pinned to a stale runtime.The registration contract (called out as CRITICAL).
ci-passedenforces a job through two independent registrations: theneeds:list (makes the gate wait) and therequired_resultsstring (makes the gate care).ca-pi-checksis in both, andtest_ci_impact.pynow asserts they agree for every job:The only sanctioned divergence is
ca-pi-latest, the advisory WATCH canary.pi_ci_contract_violations()carries the same check independently.Attestation and impact-map registration. Two follow-ons the first revision deferred are closed here, because both weaken something this lane created:
verify_pi_support.py::REQUIRED_HOSTED_CHECKSnow names[CHECK] | [PI ] | Host-independent adapter contractdirectly. Reaching it only throughMerge readinessmeant a future edit to that gate'srequired_resultscould drop the verdict with no Pi-support evidence noticing..github/ci-impact-map.jsongains thepi-checksid and adds it to theplugins/ca-pi/**edge, so a Pi payload edit predicts all three Pi contracts.pi-adapter'sreproduceis corrected at the same time: the matrix no longer runs the whole suite, so the accurate local repro ispython .github/scripts/test_pi_platform_contract.py --pi-version 0.80.10.#399 — explicit job deadlines
How the values were chosen — from the two most recent hosted runs' Pi job durations (
29870271440,30123976665), not from guesses:ci.ymlca-pi-checksci.ymlca-pi-toolsci.ymlca-pi-latestci.ymlca-pi-codeqlci.ymlversion-bump-pigit fetchpi-promotion.ymlresolvepi-promotion.ymlvalidatepi-promotion.ymlopen-prnpm ci+ rebuild +gh pr createEvery value is far below GitHub's 6-hour hosted job ceiling; the contract test asserts each Pi job declares a timeout and that it is
0 < t < 360.#381 — the latest-Pi canary concludes advisory
ca-pi-latestwas declaredcontinue-on-error: trueat job level. That keeps the workflow run from failing but GitHub still reports the check run with conclusionFAILURE— observed on PR #313 and on PR #420, where[WATCH] | [PI ] | Upstream compatibility <runtime: npm latest>was red while main was green.The failing step on PR #420 (and on run 30123976665) is
Report latest version and test installed runtime admission— the probe, not the harness:Now:
continue-on-erroris removed (it does not change the check conclusion, and it would mask real harness breaks);continue-on-error: truewith idsadmissionandplatform-contract;if: always()step publishes an advisory receipt — the resolved latest version and each probe's outcome — to$GITHUB_STEP_SUMMARY, plus a::warningannotation, and exits 0;ca-pi-latestremains out ofrequired_results(AC-3).Would this have made PR #420's case advisory? Yes. Simulated with the exact step outcomes from that run:
Red-test evidence (verbatim, before any implementation)
First revision — the four issues
python -m unittest test_ci_impact.WorkflowContractTestrun from.github/scripts/against unmodifiedmain:test_pi_package.py::pi_ci_contract_violationsalso went red on the restructure and was updated in step, including three mutation assertions (a neutered full-suite run, a host-independent step creeping back into the matrix, andca-pi-checksdropped fromrequired_resultswhile left inneeds:).Second revision — the coverage regression review found
New contracts written first, against the head commit
40f93d6(the ubuntu-only partition):Each contract was then mutation-checked to prove it bites rather than merely passing:
Plus, inside
test_the_platform_gate_contract_binds_to_the_three_os_fan_out: collapsingos: [ubuntu-latest, windows-latest, macos-latest]to one runner must uncover every file, and deleting one filename from the matrix step must uncover exactly that file.Verification
python -m unittest discover -s .github/scripts -p "test_*.py"plugins/ca-pi/tools— full unfilterednpx vitest run(whatca-pi-checksruns)plugins/ca-pi/tools— the new matrix step's 7 platform-gated files, on WindowsThe 159-passed / 0 skipped line is the direct evidence the regression is repaired: on a Windows runner every one of those files' win32 branches executes. The 1 remaining skip in the full suite is
process-tree.test.ts's POSIX-only case, which the matrix covers on its ubuntu and macOS cells.The four
test_pi_benchmarkfailures seen in a bare worktree wereRuntimeError: Pi benchmark requires the installed pinned esbuild— noplugins/ca-pi/tools/node_modules. Afternpm ci --ignore-scriptsthey pass; nothing in this diff touches the benchmark.Also re-run green (always-on CI jobs a workflow edit could affect):
Both workflows parse under
yaml.safe_load;.github/ci-impact-map.jsonparses and validates throughload_map.Payload version
Commits
5ce9b5band40f93d6touchplugins/ca-pi/**(theplan-mode.test.tsportability fix), so the Pi payload does require a bump.ca-piis at 0.1.2 with a matching## [0.1.2]CHANGELOG heading and the release guard confirms payload, version, changelog and root metadata advanced together. (An earlier version of this PR body claimed no bump was required, citing a guard run that predated those two commits — that claim was wrong and is retracted.)Known limits (disclosed, not fixed)
0 < t < 360on all 8 Pi jobs) but no deliberately hanging fixture was run against a hosted runner to observe a job actually terminate at its bound and leave a useful receipt. Proving it costs a burned runner slot up to the declared deadline.bash -eo pipefailwithADMISSION=failure), and the harness steps are demonstrably un-absorbed, but a live red canary-harness run has not been produced. Note also that thealways()receipt step wrapspi --versionin|| trueand defaults tounresolved, so the "receipt publication failure -> red check" arm is effectively unfalsifiable as written.Notes for later lanes rebasing onto this job graph
ci.ymlis nowca-pi-checks->ca-pi-tools->ca-pi-latest->ca-pi-codeql.ca-pi-toolsappears as a new block in the diff even though the job id is unchanged; git renders the split as "rename the old block, add a new one".ci-passed.needsmust also go intorequired_results, ortest_ci_impact.pyfails..github/scriptsworkflow contracts are textual (stdlib-only, no YAML parser) and read the covered file set straight off arun: npm test -- ...line. A folded scalar (run: >-) would silently defeat them.test.skipIf(process.platform ...)to a Pi suite now obliges that file to run inca-pi-tools. Two independent contracts will tell you.https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L