Uh oh!
There was an error while loading. Please reload this page.
fix(ci): verify the RESTORED console dist bundles this tree's spec, not only the built one - #9706
Conversation
…ot only the built one
The console dist cache key is hashFiles('.objectui-sha', 'scripts/build-console.sh')
— spelled identically in ci.yml (restore + save) and release.yml. packages/spec is
not in it, and scripts/assert-console-spec-injection.mjs runs INSIDE
build-console.sh, whose step is skipped on a cache hit. So the run that did not
build the dist is exactly the run that never asks whether the dist is right.
Adding packages/spec to the key was rejected on cost (~20 min cold rebuild per
spec change, ~18 merges/day). This keeps the key and the deliberate split
restore/save untouched and removes only the silence:
- scripts/console-spec-probes.mjs — probe derivation, extracted so the build-time
assertion and the restore-time gate cannot drift apart.
- assert-console-spec-injection.mjs — same behaviour and exit codes; now stamps
the probes it chose into dist/.objectstack-injection.json, written only after
every assertion is green.
- scripts/check-console-injection.mjs — replays the stamped probes against
whatever dist is on disk, cache hit or miss, and re-checks that the stamped
probe still discriminates instead of trusting it forever.
Fails rather than rebuilds: Actions cache keys are immutable, so a gate that
responded by rebuilding could not evict the entry and would rebuild on every run
until the pin moved. Every failure names its remedy, including the exact
`gh cache delete` line for the key that produced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja✅ PM ACCEPT — #9667 / PR #9706 · three rulings belowVerified independently: 5 files ⭐ The H1 refinement is a better finding than the card
I verified the filter myself ( Lifting the assertion makes every run that consumes a restored dist verify it, which is real and is what B was for. It does not put a spec-only PR in front of the gate. Reporting that distinction instead of quietly shipping a fix that does not cover the card's headline scenario is the whole value of this report. Ruling on Q1: A, as implemented. Take the transient red.H2's answer is decisive and I accept it: the assertion does not lift cleanly — B is forbidden by ruling 3 (network round-trip per PR). C is measured useless — a fresh-witness-only check "passes with no injection at all, because the bundle already holds a transitive copy of this tree's spec via the injected client." That is a one-sided canary, and #9660 already established that a one-sided canary is exactly what let this defect survive. On the transient red — pay it. Two facts make it cheap, and you should know both:
⛔ I am specifically not asking you to touch Ruling on Q2 ( |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9667
Ruled shape B: keep the cache key, and run the injection assertion against the restored dist rather than only inside the build.
H1 — the gap does reopen, and there is a second lock the card did not know about
Confirmed on
origin/main. The key is spelled in exactly three places, no fourth copy anywhere in the repo (swept all files, not just workflows):ci.yml:1439(restore),ci.yml:1485(save),release.yml:491.A spec-only change moves neither
.objectui-shanorscripts/build-console.sh, so the key is unchanged, the restore hits, andif: steps.console-dist.outputs.cache-hit != 'true'skips the build step — and with it the assertion, which lives insidebuild-console.sh. Nothing on the restore path re-validates: the two steps that do run on a hit are the dist-presence assert andcheck:console-sha, and neither looks at spec. PR #9660 busts the key exactly once, so the next spec-only change reopens it.But the cache key is the second lock, not the first.
ci.yml's paths filter defines theconsolefilter as.objectui-sha,scripts/build-console.sh,scripts/check-console-sha.mjs,.github/workflows/ci.yml.packages/spec/**is not in it, and the job isif: needs.filter.outputs.console != 'false'. So on a spec-only PR the Console Pin Gate job does not run at all — the cache never even gets consulted. Lifting the assertion makes every run that consumes a restored dist verify it; it does not by itself put a spec-only PR in front of the gate. Widening theconsolefilter topackages/spec/**is the change that would, and it is a cost decision (it adds the job, including its client-closure turbo build, to every spec PR) of exactly the kind that got option A rejected — so it is reported, not taken.H2 — the assertion does not lift cleanly, and this is what shaped the design
assert-console-spec-injection.mjstakes--injected,--vendored,--assets. Two of those survive a cache hit; the third does not:--injected— this tree'spackages/spec. Present (the job already builds the client closure, which contains spec).--assets— the restoredpackages/console/dist/assets. Present.--vendored—${BUILD_ROOT}/node_modules/@objectstack/spec, whereBUILD_ROOTis the objectui shallow clone under.cache/objectui-*. On a cache hit that tree does not exist, because the step that creates it is the one being skipped.That is the side that matters. The stale detector — text only the published spec carries, which must be absent — is the probe that actually catches this defect; the fresh witness alone cannot tell "injection worked" from "some other copy" (the bundle holds a second, transitive copy of this tree's spec via the injected client). So re-running the script on the restore path is not merely awkward, it is impossible: it would exit 2 for want of a package.json. Fetching the published spec instead would put a network round-trip on every PR, which ruling 3 forbids.
Design: the build stamps the probes it derived into
packages/console/dist/.objectstack-injection.json, and a new gate replays them against whatever dist is on disk. Cost is one node process over files already present — no network, no build.scripts/build-console.shis not touched (#9659 owns it). The stamp is written byassert-console-spec-injection.mjs, whichbuild-console.shalready calls, so the existing call site picks it up unchanged and localpnpm objectui:buildstamps too.Fail vs. invalidate-and-rebuild (ruling 2)
It fails. Reasoning, because the other option looks more attractive than it is:
Actions cache keys are immutable. A gate that responded to a bad restore by rebuilding could not evict the offending entry — the next run would restore the same bad entry and rebuild again, a ~20 min rebuild on every run until the pin moved. That is strictly worse than the one-off cost that got option A rejected. "Invalidate and rebuild" is not actually available here; only "rebuild forever" is.
So it fails once, and every failure names its remedy, including the exact eviction command for the key that produced it — the workflow passes the resolved key in
CONSOLE_DIST_CACHE_KEY, so the message readsgh cache delete "Linux-console-dist-HASH"with the real hash, not a template. That path is marked ⛔ MAINTAINER-ONLY (it needs write scope and the entry is shared withrelease.yml), withpnpm objectui:buildas the local remedy. The message also states plainly that the restored artifact is a cache entry and nothing in the PR's diff can fix it, so a contributor who did nothing wrong is not left hunting their own changes.Which reds are even reachable matters here, and none of them fire on an innocent spec change:
--require-stamp(CI), advisory locallyThat last row is the point: the probes are stamped beside the dist and describe that build, so they are assertions about the artifact, not about the tree. A spec change does not move them.
H3 — the probe cannot go silently useless
The stamped stale detector is only evidence while it still tells the two specs apart. If the published spec later catches up, a frozen probe would pass forever while proving nothing — the exact failure #8134 exists to end. So the gate re-checks the stamped detector against this tree's spec on every run and reports expiry instead of passing. Probe derivation is substring-based, not set-difference, because rewording a description by appending a clause makes the old text a prefix of the new one; that property is asserted directly in the self-test.
Both consumers import one derivation module (
scripts/console-spec-probes.mjs) rather than keeping a copy each — the second script replays strings the first one chose, so two implementations that drifted would silently stop agreeing on what a probe is.H4 — sweep of every
actions/cachein the repoFour distinct cached paths exist across all 25 workflows:
${{ env.STORE_PATH }}(pnpm store).turbo/cacheapps/docs/.next/cachepackages/console/distThe docs cache is worth recording: its key is lockfile plus
apps/docs/**.[jt]s(x)pluscontent/**/*.mdx, andapps/docsdepends on@objectstack/specatworkspace:*— so the key genuinely omits an input the build consumes. It is still not this defect, because the cached path is Next's incremental compiler cache (internally content-hashed, and self-invalidating), not a build product, and no later step asserts anything about it.That distinction is the generalisable finding, and it is sharper than "the key omits an input" — turbo and Next keys omit inputs too and are fine. The property that makes the console dist different is: a cache hit skips the step that produces the proof, and nothing on the restore path re-establishes it. By that definition the console dist is the only instance, so this is a one-off gate rather than a shared convention. Nothing else was changed.
Not touched, deliberately
.github/workflows/release.yml— fenced by epic Migrate the release toolchain to @changesets/cli v3 — one atomic PR carrying the bump, the pre-mode restructure, and the gates that model v2's semantics #9465. It carries the same key and, perci.yml's own comment, the combined cache action whose post-step saves even when the job failed, so a poisoned entry is reachable fromci.ymlunder the shared repo-scoped key. This PR's--require-stamprefuses such an entry on theci.ymlside. Givingrelease.ymlthe same step is the natural completion and belongs to whoever holds that fence — flagging, not routing around it.#9465 remains open.scripts/build-console.sh— parallel card Four more@objectstack/*packages still reach the Console bundle from objectui's lockfile — the same publish-ordering trap #8134 closes forspec#9659. Zero lines changed.ci.ymlalone would split it fromrelease.ymland cold-build both.Verification
Union re-run on the final commit
3497032ac, all green:check:nul-bytes,check:required-contexts,check:workflow-status-functions,check:node-version,check:shard-attestation,check:cross-package-test-inputs,check:ratchet-remedy-authority,check:console-injection. Gate list derived withnode scripts/pm/dispatch-gates.mjsover the five changed paths.The new gate ships a
--self-test(repo convention), 21 assertions over real fixture trees driving the realevaluate()path, including a round trip that runs the actual assert script and feeds its stamp back in — the drift the shared module exists to prevent.Reverse verification, three mutations, each reddening as predicted:
expected 1, got 0. First attempt at this passed, which exposed a genuine vacuity in the fixture: the bundle held only the published string, so the missing-fresh-witness branch returned 1 for the wrong reason and both branches print the same remedy block. Fixed by making the fixture carry both strings (also the realistic shape) and keying assertions on branch-unique wording.pickProbeswapped to set-difference ⇒ caught, naming the reworded prefix it wrongly called unique.The refactored assert script was exercised across all five of its exit paths (proven, no-skew, published-in-bundle, neither-present, vendored-missing) with identical exit codes and first-line messages to before, and the stamp is written on the two success paths only, never on a failure.
Positive control against the real package rather than fixtures only:
readSpecBlobresolves all 18 export subpaths ofpackages/specinto a 12.5 MB blob yielding 2995 usable probe candidates, andevaluate()returns the right verdict for a healthy dist, one carrying the published spec, and an expired probe — confirming the scheme is not silently inert on the real thing.Not run locally: a full
build-console.sh(clones objectui, ~20 min). The build-side path is covered by the round-trip case and the exit-path matrix.Notes
package.jsonscript entry. Nothing publishes.skip-changesetapplied.pnpm check:console-injection --require-stampworks because pnpm appends forwarded args to the tail command of the&&chain, so the self-test still runs first. Verified from the echoed command line.Generated by Claude Code