Skip to content

Commit 3ad65b0

Browse files
ci(console-pin): a head that moves the spec's entry layout runs Console Pin Gate and misses its dist cache (#20945)
Fixes #20765 Clause-②: no A head that moves the spec's entry layout now runs `Console Pin Gate` on the PR, and misses the dist cache so the gate judges it. Before this, such a head skipped the job on every push, and the first run was the merge-queue build, where a check outside the required set cannot stop a merge (#20695: `main` red on this check for about 2 h 17 min). ## What changes - **`ci.yml` `filter` job, `console:` list** gains `packages/spec/package.json` and `packages/spec/tsup.config.ts`. The comment above the list now says which entries are build inputs and which are guards, and why the rest of `packages/spec` stays out. - **`ci.yml` `console-pin` job, dist-cache key** (all three spellings: restore, the `CONSOLE_DIST_CACHE_KEY` env, save) now hashes every build input the list names: the pin, `scripts/build-console.sh`, the two probe scripts (`scripts/assert-console-spec-injection.mjs`, `scripts/console-spec-probes.mjs`), and the two spec files. Comments that described the old key are corrected. The restore step name now says what it is keyed on. - **`scripts/check-ci-filter-parity.mjs`** gains a second subject, `judgeConsole`, run by `main()` in both lint.yml legs. It holds five things of the checked-in ci.yml: every `console` entry is a literal path; the key is spelled one way everywhere the job uses it; every hashed path is a `console` entry; every `console` entry is hashed or a declared guard (`CONSOLE_GUARDS`: ci.yml and the two check scripts, which run on a hit and a miss alike); and every declared guard is still listed and not hashed. There is a new self-test battery (8) with 20 cases, and the battery floor goes from 7 to 8. - **`scripts/pm/check-expected-skips.mjs`**: the `Console Pin Gate` row's reason text names the two spec files. Only the prose changes. The row's gate, the job's `if:` and the roster shape are unchanged. - **`scripts/check-console-injection.mjs`** (comments and the `remedy()` fallback text only; seat ruling 5920200169, Q2): the header no longer restates the dist key. It names the two restore steps that each spell one, and says the keys now differ. Its section is renamed "Why the rest of packages/spec is NOT in ci.yml's console filter" and says the spec's entry layout is in both the filter and ci.yml's key. The `remedy()` fallback, used only when `CONSOLE_DIST_CACHE_KEY` is unset, prints `gh cache delete "KEY"` plus a line naming where each workflow spells its key, instead of a stale copy of the old key. - **`.github/workflows/release.yml`** (comment only; no key, step or env change; seat ruling 5920200169, Q1 = B): the comment above the dist restore step says its key hashes the pin and the build script, ci.yml's hashes a superset, the two workflows no longer share dist entries, and the narrower key is deliberate. ## Why the key had to move too (H1, measured) On a cache hit the job skips `Build the Console SPA`, and `assert-console-spec-injection.mjs` runs inside that step. What runs on a hit is `check:console-injection`, which replays the stamp the earlier build wrote. The only thing it reads from the tree is the probe-expiry check over the union of all exports entries, and moving text between entries does not change that union. - **The queue build that went red was a cache MISS.** Run 36645820047, job 109668493655: `Restore vendored Console dist` took 0 s, `Build the Console SPA…` ran 23:37:45 to 23:41:37 and failed, and the only failure annotation is "Process completed with exit code 2" (the assert script's inconclusive exit, "Neither spec appears in the built console"). The job log itself could not be read from this container because the log blob host is refused by the egress policy. - **Fixture replay of the #20695 shape** (scratch fixture: a root entry whose first injected-only description moves to a new `./migrations` entry the console does not import; the assert and probe scripts as they stood at `9c8f113c~1`): - leg 0: the pre-move build stamps the dist. Exit 0; witness "A public export added or removed by one release." - leg 1, **cache hit** on the post-move head: `node scripts/check-console-injection.mjs --dist dist-pre --spec tree-post --require-stamp` prints "✓ @objectstack/spec: the dist carries this tree's copy, and not the published one." with **exit 0**. The hit is green without judging the change. - leg 2, **cache miss** on the same head: the rebuild prints "✗ Neither spec appears in the built console" with **exit 2**. This is the queue's red. - **How often the cache is cold anyway.** On `merge_group`, 32 of the 38 finished `Console Pin Gate` runs from 2026-09-30T13:46Z to 20:23Z rebuilt, and 6 hit. On the hourly `schedule`, 25 of 30 runs from 2026-09-24 to 09-27 rebuilt. So widening the filter alone would have judged this shape most of the time, but not every time. Moving the key makes the rebuild certain for such a head. ## Which spec paths (H2, measured) What the console build and the probe derivation read from `packages/spec`: - `package.json` `exports`: objectui's `OBJECTSTACK_SPEC_DIST` hook derives one alias per exports entry and refuses a missing target (`apps/console/vite.config.ts`, `scripts/vite-objectstack-spec-dist.ts`), and `readSpecBlob` in `scripts/console-spec-probes.mjs` resolves every entry. - `tsup.config.ts`: its `entries` list decides which built file each exports entry points at, and `build-console.sh` builds the spec through it. - `dist/index.mjs` and `json-schema/openapi.json` are build sentinels. They are generated, not authored. Over the first-parent commits on `main` from 2026-08-31 to `9905e61ca2` (3219 commits, window proven by `scripts/pm/git-history.mjs`), 4 commits moved the entry layout: `fbec216e2` (#20695), `c23cfb346`, `9165d5cd4`, `776d64cd3`. Every one of them touched both `package.json` and `tsup.config.ts`. `src/index.ts` changed in 5 other commits, all of them content (a new metadata kind, a cycle fix, schema edits, citation re-anchoring), so it is not in the list. `browser-reachable-entries.json` (touched by #20695) is read only by a lint gate, not by the console build, so it is not in the list either. The triage control holds: no `console` entry reaches `packages/spec/src`. ## Where the pins live (H3) Before this PR, nothing asserted the `console` list. The only readers were `dispatch-gates.mjs` fixtures, and `check-expected-skips.mjs`, which reads outputs and `if:` but never paths. The pins go in `check-ci-filter-parity.mjs`, the gate that already reads and pins the filter job's lists, through the same YAML reader. Its battery (8) pins the following: - A head touching only `packages/spec/package.json` selects the job and moves the key. The same holds for `tsup.config.ts`. - The control: a head touching only `packages/spec/src/ui/view.zod.ts` (a tracked file) does not select the job. - Each way the filter and key can drift turns red on a synthetic tree: a filter entry the key does not hash, a hashed path the filter does not name, a pattern entry, a split key, a stale guard and a hashed guard. There are four refusals, and the report path turns red over the checked-in ci.yml with one hashed path dropped from the filter. ## A bounded in-place fix, declared The two probe scripts were already `console` entries but not in the key, so a head that changed the derivation could also get a green hit without the new derivation ever running. The new rule, applied to the base `ci.yml`, turns red on exactly those two (leg B below). It is the same defect class and the same parameter, a mechanical edit to the same file, and it adds no new verification surface, so it is fixed here. Cost: 1 commit in the 30-day window touched either script. ## Cost - Selection of the job on a pushed head: 37 → 53 of the 3219 commits. The key moves on 13 → 32 of them. - The Version Packages PR bumps `packages/spec/package.json`, so its runs now select the job (93 CI runs on that branch in the same 30 days). The first push of each version rebuilds; later pushes with the same file content hit that PR's own entry. - The queue already runs this job on every `merge_group` build and rebuilds most of the time (above), so the queue's cost barely moves. ## Reverse verification (from the committed state, `1bd002357`) - Leg A, **the hazard itself**: filter widened, key not moved. `node scripts/ablation-replace.mjs --file .github/workflows/ci.yml --anchor NEW_HASHFILES_ARGS --replacement OLD_HASHFILES_ARGS --expect 3 -- node scripts/check-ci-filter-parity.mjs` reported anchor 3 → 0 and blob `24aac400cf2d` → `14c292d64529`. The gate printed "FAIL: ci.yml's `console` filter and the console dist key are out of step", naming the four unhashed entries, and exited 1. The restore brought the blob back to `24aac400cf2d`, equal to HEAD, and `git diff HEAD` was empty. - Leg B, the new rule over the base `ci.yml` (`bee75cebe6`): exit 1, naming exactly `scripts/console-spec-probes.mjs` and `scripts/assert-console-spec-injection.mjs`. That is the in-place fix above. - Direction observed: red, as expected. ## Gates Derived from this diff with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `1bd002357` (57 commands). Exit codes and verdict lines are in the report on #20765. On-point results: - `node scripts/check-ci-filter-parity.mjs`: exit 0, "OK: all 9 `console` entr(ies) are literal paths; 6 are build inputs the console dist key hashes and 3 are declared guards; the key is spelled one way in all 3 place(s)…" - `node scripts/check-ci-filter-parity.mjs --self-test`: exit 0, 68 assertions. - `pnpm check:pm-expected-skips`, `pnpm check:console-injection`, `pnpm check:console-sha`, `pnpm check:workflow-status-functions`, `pnpm check:workflow-step-name-quoting`, `node scripts/check-self-test-workflow-commands.mjs` (both legs) and `pnpm check:nul-bytes`: all exit 0. - NOT MEASURED, prerequisite not met: `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:sourcemap-no-sources-content` need built package `dist/` trees. This diff touches no package source; CI builds before them. - ESLint, narrowed and proven: the population is `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block, which includes both edited `.mjs` files. `--format json` reports 2 files, 0 errors and 0 warnings. The config enables no type-aware linting, so this diff cannot move an untouched file's verdict, and `ci.yml` is not an ESLint input. - `Console Pin Gate` runs on this PR's own head, because `ci.yml` and the key's inputs moved. Its verdict is read from CI. - Patch round at `f57066dd5` (comment and fallback text in `check-console-injection.mjs` and `release.yml`, one `ci.yml` comment): `dispatch-gates.mjs --commands` names the same 57 commands; reconciled with `--ran` (53 run, 4 NOT MEASURED as above). `node scripts/check-console-injection.mjs --self-test` exit 0 (44 assertions); both `remedy()` branches exercised on a scratch dist. ## release.yml's key (seat ruling 5920200169, Q1 = B) `release.yml` keeps its narrower key, and only its comment changes. The two workflows no longer share dist entries. Following ci.yml would rebuild the console on every Version Packages merge, and publishing a cached console whose spec content lags is the existing cache design. ## Acceptance notes - The prose this PR made stale in `scripts/check-console-injection.mjs` and `release.yml` is corrected in the patch round (`f57066dd5`). Neither the header nor the remedy fallback restates the key any more, so `check-ci-filter-parity.mjs`'s hold on ci.yml's spellings is the only copy that matters. - Observation, not filed (a read-only inference with no measured reach): `build-console.sh` copies the tracked `sdui.manifest.json` into the dist, and that file is in neither the key nor the list. `a093ce3e2` changed it without a pin move, so a cached dist would carry the older copy until the pin moves. Carrier: none. - #20764 (the seat protocol watching non-required queue checks) and making this job a required context are out of scope here, as triage ruled. Body edited by the `domain:spec` seat 2 PM (`session_017VaLJnYwhPsanVCe9dMCJU`) after the patch round, from the dev's wording in its report on #20765. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 013f97d commit 3ad65b0

5 files changed

Lines changed: 478 additions & 70 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,38 @@ jobs:
183183
# it matches neither filter above: a pin-only diff skipped `core` and
184184
# `docs` alike, which is how #4288 moved the pin 76 commits with six of
185185
# fourteen checks skipped and nothing anywhere building the new SHA.
186-
# The last two entries are the "a change to the guard runs the guard"
187-
# rule the repo applies to every other filtered gate; they are close to
188-
# free here because an unmoved pin hits the dist cache.
186+
#
187+
# Every entry is one of two kinds, and scripts/check-ci-filter-parity.mjs
188+
# holds each one to its kind (#20765):
189+
# - a BUILD INPUT: something the console build reads to produce the
190+
# cached dist or the injection stamp inside it. Each one is ALSO
191+
# hashed into the `console-pin` job's dist-cache key. A head that
192+
# selects the job by moving a build input and then restores the old
193+
# dist from cache runs the gate without judging the change, because
194+
# the build-time assertion lives inside the build step a hit skips.
195+
# - a GUARD: code every run executes, cache hit or miss (the two check
196+
# scripts, this workflow). That is the "a change to the guard runs the
197+
# guard" rule the repo applies to every other filtered gate, and it is
198+
# close to free here because an unmoved key hits the dist cache.
199+
# The two packages/spec entries are the spec's ENTRY LAYOUT: the exports
200+
# map that objectui's OBJECTSTACK_SPEC_DIST hook and the probe derivation
201+
# both resolve entry by entry, and the tsup entry list that decides which
202+
# built file each entry points at. A head that moves them runs this gate
203+
# before it enqueues. #20695 moved the migration registries to a new
204+
# entry, skipped this job on every push, and went red first in the merge
205+
# queue, where a check outside the required set cannot stop a merge.
206+
# ⛔ Not packages/spec/**: the rest of the spec is bundled CONTENT, and
207+
# its lag behind a cached dist is the ruled cache design (#9667, #9706;
208+
# see scripts/check-console-injection.mjs).
189209
console:
190210
- '.objectui-sha'
191211
- 'scripts/build-console.sh'
192212
- 'scripts/check-console-sha.mjs'
193213
- 'scripts/check-console-injection.mjs'
194214
- 'scripts/console-spec-probes.mjs'
195215
- 'scripts/assert-console-spec-injection.mjs'
216+
- 'packages/spec/package.json'
217+
- 'packages/spec/tsup.config.ts'
196218
- '.github/workflows/ci.yml'
197219
# Test inputs that live OUTSIDE every package (#9829, #10015). Packages
198220
# whose suites read across their own boundary declare that radius in
@@ -2307,10 +2329,12 @@ jobs:
23072329
# already covered in practice; the hole is a hand-edited pin, or that single
23082330
# line cherry-picked out of another branch.
23092331
#
2310-
# Cost is real but narrowly aimed. A moved pin is the only trigger that pays
2311-
# the full clone + vite build, and #4288's 76 commits of staleness are the
2312-
# measure of how rare that is. The other triggers (this file, the drift guard)
2313-
# leave the pin untouched, so they hit the dist cache and cost about a minute.
2332+
# Cost is real but narrowly aimed. A moved BUILD INPUT (the pin, the build and
2333+
# probe scripts, the spec's entry layout; see the `console` filter's comment)
2334+
# is the only trigger that always pays the full clone + vite build: 32 of the
2335+
# 3219 first-parent commits on main from 2026-08-31 to 9905e61ca2 moved one.
2336+
# The other triggers (this file, the check scripts) leave the key untouched,
2337+
# so they cost about a minute whenever the entry is still in the cache.
23142338
#
23152339
# Scope, stated plainly: this proves the PINNED SHA builds. It does NOT cover a
23162340
# packages/client change breaking the injected-client bundle — that input lives
@@ -2402,10 +2426,20 @@ jobs:
24022426
- name: Build @objectstack/client and its dependencies
24032427
run: pnpm exec turbo run build --filter=@objectstack/client... --concurrency=4
24042428

2405-
# Same key as release.yml's "Cache vendored Console dist" step. Actions
2406-
# caches are repo-scoped, so a PR reads whatever main already built for this
2407-
# pin — that is what makes the non-pin triggers cheap. Only a pin the repo
2408-
# has never built misses, and a miss is exactly when this gate has work to do.
2429+
# Keyed on every BUILD INPUT the `filter` job's `console` list names (the
2430+
# pin, the build script, the two scripts that derive and stamp the injection
2431+
# probes, and the spec's entry layout) and on nothing else.
2432+
# scripts/check-ci-filter-parity.mjs holds this key and that list in step
2433+
# (#20765). A head that moves a build input MISSES, so the build and the
2434+
# assertion inside it judge the change on the PR head, not first in the
2435+
# merge queue. A head that moves only a guard hits, which is what keeps
2436+
# those triggers cheap. A PR reads what main, or its own earlier pushes,
2437+
# saved under the same key.
2438+
#
2439+
# ⚠️ release.yml's restore step still keys on the pin and the build script
2440+
# alone, so the two workflows no longer share dist entries: each builds its
2441+
# own on a miss. The narrower release key is deliberate, and that file's
2442+
# comment says why (#20765).
24092443
#
24102444
# Split restore/save where release.yml uses the combined action, because the
24112445
# combined form's post-step saves even when the job FAILED. build-console.sh
@@ -2414,12 +2448,12 @@ jobs:
24142448
# run restores it, skips the build, and sails through the stamp check. Saving
24152449
# only once every assertion below is green is what lets a restored dist carry
24162450
# the same guarantees as a freshly built one.
2417-
- name: Restore vendored Console dist (keyed on the objectui pin)
2451+
- name: Restore vendored Console dist (keyed on the pin and its other build inputs)
24182452
id: console-dist
24192453
uses: actions/cache/restore@v6
24202454
with:
24212455
path: packages/console/dist
2422-
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
2456+
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh', 'scripts/assert-console-spec-injection.mjs', 'scripts/console-spec-probes.mjs', 'packages/spec/package.json', 'packages/spec/tsup.config.ts') }}
24232457

24242458
# The gate. Shallow-clones objectui at the pinned SHA, builds
24252459
# @object-ui/console against this tree's client, copies the dist into
@@ -2458,10 +2492,11 @@ jobs:
24582492
# The spec-injection half of the same question, and the reason it is a
24592493
# SEPARATE step from build-console.sh (#9667).
24602494
#
2461-
# The dist cache key is hashFiles('.objectui-sha', 'scripts/build-console.sh')
2462-
# — packages/spec is deliberately NOT in it, because adding it would bust the
2463-
# key on every spec change and force a ~20 min cold console rebuild on a repo
2464-
# doing ~18 merges a day. The cost model stays; what does not stay is the
2495+
# The dist cache key carries only the spec's ENTRY LAYOUT (package.json and
2496+
# tsup.config.ts, see the restore step). The rest of packages/spec is
2497+
# deliberately NOT in it, because adding it would bust the key on every spec
2498+
# change and force a ~20 min cold console rebuild on a repo doing ~18 merges
2499+
# a day. The cost model stays; what does not stay is the
24652500
# silence. scripts/assert-console-spec-injection.mjs runs INSIDE
24662501
# build-console.sh, so the `if: cache-hit != 'true'` above skips it exactly
24672502
# when the dist was NOT built here — which is the run that most needs asking.
@@ -2482,7 +2517,7 @@ jobs:
24822517
# a poisoned entry is reachable from here and must not pass quietly.
24832518
- name: Verify the restored Console dist bundles this tree's spec
24842519
env:
2485-
CONSOLE_DIST_CACHE_KEY: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
2520+
CONSOLE_DIST_CACHE_KEY: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh', 'scripts/assert-console-spec-injection.mjs', 'scripts/console-spec-probes.mjs', 'packages/spec/package.json', 'packages/spec/tsup.config.ts') }}
24862521
run: pnpm check:console-injection --require-stamp
24872522

24882523
# Reached only with every assertion above green (see the split-restore note).
@@ -2495,4 +2530,4 @@ jobs:
24952530
uses: actions/cache/save@v6
24962531
with:
24972532
path: packages/console/dist
2498-
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
2533+
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh', 'scripts/assert-console-spec-injection.mjs', 'scripts/console-spec-probes.mjs', 'packages/spec/package.json', 'packages/spec/tsup.config.ts') }}

‎.github/workflows/release.yml‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1378,9 +1378,13 @@ jobs:
13781378
- name: Build
13791379
run: pnpm run build
13801380

1381-
# ci.yml's Console Pin Gate (#4290) uses this exact key, so the pin bump's
1382-
# PR run and this job share one build — keep the two in step if either
1383-
# input set changes.
1381+
# This key hashes the pin and the build script. ci.yml's Console Pin Gate
1382+
# (#4290) hashes a SUPERSET: those two, the probe scripts and the spec's
1383+
# entry layout (#20765). So the two workflows no longer share dist entries,
1384+
# and each one builds its own on a miss. Keeping this key narrower is
1385+
# deliberate. Following ci.yml would rebuild the console on every Version
1386+
# Packages merge, and publishing a cached console whose spec content lags
1387+
# is the existing cache design.
13841388
#
13851389
# Split restore/save, matching ci.yml, and not the combined actions/cache
13861390
# action: the combined form's post-step saves even when the job FAILED,

0 commit comments

Comments
 (0)