From 1bd002357e1e96cff242821190c8595067186ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:03:40 +0000 Subject: [PATCH 1/2] ci(console-pin): a head that moves the spec's entry layout runs Console Pin Gate and misses its dist cache (#20765) The `console` filter gains packages/spec/package.json (the exports map the OBJECTSTACK_SPEC_DIST hook and the probe derivation resolve) and packages/spec/tsup.config.ts (the entry list), and the console-pin dist-cache key now hashes every build input the filter names: those two, plus the two scripts that derive and stamp the injection probes. A selection the key does not follow restores the pre-change dist and skips the build step the assertion lives in, so the run is green without judging the change. check-ci-filter-parity gains a second subject that holds the filter and the key in step: literal entries only, one key spelling, every hashed path selected, every selected path hashed or a declared guard. Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- .github/workflows/ci.yml | 73 +++-- scripts/check-ci-filter-parity.mjs | 399 ++++++++++++++++++++++++++-- scripts/pm/check-expected-skips.mjs | 2 +- 3 files changed, 429 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66efb25a05c..24aac400cf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,9 +183,29 @@ jobs: # it matches neither filter above: a pin-only diff skipped `core` and # `docs` alike, which is how #4288 moved the pin 76 commits with six of # fourteen checks skipped and nothing anywhere building the new SHA. - # The last two entries are the "a change to the guard runs the guard" - # rule the repo applies to every other filtered gate; they are close to - # free here because an unmoved pin hits the dist cache. + # + # Every entry is one of two kinds, and scripts/check-ci-filter-parity.mjs + # holds each one to its kind (#20765): + # - a BUILD INPUT: something the console build reads to produce the + # cached dist or the injection stamp inside it. Each one is ALSO + # hashed into the `console-pin` job's dist-cache key. A head that + # selects the job by moving a build input and then restores the old + # dist from cache runs the gate without judging the change, because + # the build-time assertion lives inside the build step a hit skips. + # - a GUARD: code every run executes, cache hit or miss (the two check + # scripts, this workflow). That is the "a change to the guard runs the + # guard" rule the repo applies to every other filtered gate, and it is + # close to free here because an unmoved key hits the dist cache. + # The two packages/spec entries are the spec's ENTRY LAYOUT: the exports + # map that objectui's OBJECTSTACK_SPEC_DIST hook and the probe derivation + # both resolve entry by entry, and the tsup entry list that decides which + # built file each entry points at. A head that moves them runs this gate + # before it enqueues. #20695 moved the migration registries to a new + # entry, skipped this job on every push, and went red first in the merge + # queue, where a check outside the required set cannot stop a merge. + # ⛔ Not packages/spec/**: the rest of the spec is bundled CONTENT, and + # its lag behind a cached dist is the ruled cache design (#9667, #9706; + # see scripts/check-console-injection.mjs). console: - '.objectui-sha' - 'scripts/build-console.sh' @@ -193,6 +213,8 @@ jobs: - 'scripts/check-console-injection.mjs' - 'scripts/console-spec-probes.mjs' - 'scripts/assert-console-spec-injection.mjs' + - 'packages/spec/package.json' + - 'packages/spec/tsup.config.ts' - '.github/workflows/ci.yml' # Test inputs that live OUTSIDE every package (#9829, #10015). Packages # whose suites read across their own boundary declare that radius in @@ -2307,10 +2329,12 @@ jobs: # already covered in practice; the hole is a hand-edited pin, or that single # line cherry-picked out of another branch. # - # Cost is real but narrowly aimed. A moved pin is the only trigger that pays - # the full clone + vite build, and #4288's 76 commits of staleness are the - # measure of how rare that is. The other triggers (this file, the drift guard) - # leave the pin untouched, so they hit the dist cache and cost about a minute. + # Cost is real but narrowly aimed. A moved BUILD INPUT (the pin, the build and + # probe scripts, the spec's entry layout; see the `console` filter's comment) + # is the only trigger that always pays the full clone + vite build: 32 of the + # 3219 first-parent commits on main from 2026-08-31 to 9905e61ca2 moved one. + # The other triggers (this file, the check scripts) leave the key untouched, + # so they cost about a minute whenever the entry is still in the cache. # # Scope, stated plainly: this proves the PINNED SHA builds. It does NOT cover a # packages/client change breaking the injected-client bundle — that input lives @@ -2402,10 +2426,20 @@ jobs: - name: Build @objectstack/client and its dependencies run: pnpm exec turbo run build --filter=@objectstack/client... --concurrency=4 - # Same key as release.yml's "Cache vendored Console dist" step. Actions - # caches are repo-scoped, so a PR reads whatever main already built for this - # pin — that is what makes the non-pin triggers cheap. Only a pin the repo - # has never built misses, and a miss is exactly when this gate has work to do. + # Keyed on every BUILD INPUT the `filter` job's `console` list names (the + # pin, the build script, the two scripts that derive and stamp the injection + # probes, and the spec's entry layout) and on nothing else. + # scripts/check-ci-filter-parity.mjs holds this key and that list in step + # (#20765). A head that moves a build input MISSES, so the build and the + # assertion inside it judge the change on the PR head, not first in the + # merge queue. A head that moves only a guard hits, which is what keeps + # those triggers cheap. A PR reads what main, or its own earlier pushes, + # saved under the same key. + # + # ⚠️ release.yml's restore step still keys on the pin and the build script + # alone, so the two workflows no longer share dist entries: each builds its + # own on a miss. That file's comment asks for the two to stay in step; which + # way to reconcile them is recorded on #20765. # # Split restore/save where release.yml uses the combined action, because the # combined form's post-step saves even when the job FAILED. build-console.sh @@ -2414,12 +2448,12 @@ jobs: # run restores it, skips the build, and sails through the stamp check. Saving # only once every assertion below is green is what lets a restored dist carry # the same guarantees as a freshly built one. - - name: Restore vendored Console dist (keyed on the objectui pin) + - name: Restore vendored Console dist (keyed on the pin and its other build inputs) id: console-dist uses: actions/cache/restore@v6 with: path: packages/console/dist - key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }} + 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') }} # The gate. Shallow-clones objectui at the pinned SHA, builds # @object-ui/console against this tree's client, copies the dist into @@ -2458,10 +2492,11 @@ jobs: # The spec-injection half of the same question, and the reason it is a # SEPARATE step from build-console.sh (#9667). # - # The dist cache key is hashFiles('.objectui-sha', 'scripts/build-console.sh') - # — packages/spec is deliberately NOT in it, because adding it would bust the - # key on every spec change and force a ~20 min cold console rebuild on a repo - # doing ~18 merges a day. The cost model stays; what does not stay is the + # The dist cache key carries only the spec's ENTRY LAYOUT (package.json and + # tsup.config.ts, see the restore step). The rest of packages/spec is + # deliberately NOT in it, because adding it would bust the key on every spec + # change and force a ~20 min cold console rebuild on a repo doing ~18 merges + # a day. The cost model stays; what does not stay is the # silence. scripts/assert-console-spec-injection.mjs runs INSIDE # build-console.sh, so the `if: cache-hit != 'true'` above skips it exactly # when the dist was NOT built here — which is the run that most needs asking. @@ -2482,7 +2517,7 @@ jobs: # a poisoned entry is reachable from here and must not pass quietly. - name: Verify the restored Console dist bundles this tree's spec env: - CONSOLE_DIST_CACHE_KEY: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }} + 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') }} run: pnpm check:console-injection --require-stamp # Reached only with every assertion above green (see the split-restore note). @@ -2495,4 +2530,4 @@ jobs: uses: actions/cache/save@v6 with: path: packages/console/dist - key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }} + 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') }} diff --git a/scripts/check-ci-filter-parity.mjs b/scripts/check-ci-filter-parity.mjs index cb3d475ff1f..d948b2eb4db 100644 --- a/scripts/check-ci-filter-parity.mjs +++ b/scripts/check-ci-filter-parity.mjs @@ -112,6 +112,39 @@ * NOTHING the table declares, though the table is precisely this gate's * population. Measured on 589758d22: 1 (gate, file) pair before, 3253 after. * + * ## The second subject: the `console` selection and the dist key it must move (#20765) + * + * The same filter job schedules `Console Pin Gate` from a second hand-kept list, + * `console:`, and that job restores its console dist from a cache keyed on + * `hashFiles(...)`. The two lists answer different questions and each has a hole + * the other one cannot see: + * + * - A path the KEY hashes but the FILTER does not name moves the key without + * starting the job, so the first build under the new key is the merge + * queue's, where a check outside the required set cannot stop a merge. + * - A path the FILTER names but the KEY does not hash starts the job, and the + * job then restores the dist built before the change and skips the build + * step, which is where the build-time assertion + * (`scripts/assert-console-spec-injection.mjs`) lives. The run is green + * without judging the change. That is only right for a GUARD: code every run + * executes, hit or miss. `CONSOLE_GUARDS` declares those, each with the step + * that runs it. + * + * Measured on the #20695 shape (the migration registries moved to a new spec + * entry): its queue build missed the cache, rebuilt, and went red with "Neither + * spec appears in the built console"; a fixture replay of the same head on the + * cache-hit path (the restored pre-move dist, its stamp, the post-move tree) + * exits 0. So the job judges an entry-layout change only when the key moves too. + * + * So `judgeConsole` holds five things of the checked-in ci.yml: every `console` + * entry is a literal path (with no pattern in the list, a path selects the job + * exactly when the list names it, which is what lets the self-test pin the + * selection, and a pattern under `packages/spec` would widen the job to every + * spec change); every key spelling in the job is one string; every hashed path + * is a `console` entry; every `console` entry is hashed or a declared guard; and + * every declared guard is still in the list and not hashed. Pure string again, + * for the reason above: no matcher, so no third recognizer. + * * ## Wiring * * Invoked from `.github/workflows/lint.yml` as `node scripts/...` directly, both @@ -124,7 +157,8 @@ * exists and is not scheduled is the same dormant shape from the other side. */ -import { readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; @@ -159,11 +193,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ '(5) refusals: never a clean zero over a subject that was not read': 10, '(6) the real tree': 10, '(7) WIRING: the gate and its self-test really run in CI': 2, + '(8) the `console` selection and the dist key it must move': 20, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 7; +const SELF_TEST_BATTERY_FLOOR = 8; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -243,6 +278,38 @@ export function declarationsOf(table) { * does them. */ export function readSchedulingFilters(source) { + const read = readFilterLists(source); + if (read.refusal) return read; + const { jobs, filters } = read; + for (const name of SCHEDULING_FILTERS) { + if (!(name in filters)) { + return { refusal: `${CI_WORKFLOW}'s \`filters:\` input declares no \`${name}:\` filter.` }; + } + } + + const job = jobs[SCHEDULED_JOB]; + if (!job) return { refusal: `${CI_WORKFLOW} has no \`${SCHEDULED_JOB}\` job -- Layer A's \`--union-into\` step has moved.` }; + const condition = typeof job.if === 'string' ? job.if : ''; + const absent = SCHEDULING_FILTERS.filter((n) => !condition.includes(`needs.filter.outputs.${n}`)); + if (absent.length > 0) { + return { + refusal: + `${CI_WORKFLOW}'s \`${SCHEDULED_JOB}\` job no longer names ${absent.map((n) => `\`${n}\``).join(', ')} in its \`if:\`, ` + + `so that filter does not schedule it any more and parity against it means nothing.\n` + + ` if: ${condition || '(absent)'}`, + }; + } + + return { filters, condition, entries: SCHEDULING_FILTERS.flatMap((n) => filters[n]) }; +} + +/** + * The shared half of both readers: ci.yml's `jobs` map and the `filter` job's + * path lists, or `{ refusal }` naming what could not be read. Both subjects of + * this gate read the lists through here, so they cannot disagree about what + * the lists are. + */ +function readFilterLists(source) { let doc; try { doc = parse(source); @@ -282,26 +349,7 @@ export function readSchedulingFilters(source) { return { refusal: `${CI_WORKFLOW}'s \`${name}:\` filter is not a list of path strings.` }; } } - for (const name of SCHEDULING_FILTERS) { - if (!(name in filters)) { - return { refusal: `${CI_WORKFLOW}'s \`filters:\` input declares no \`${name}:\` filter.` }; - } - } - - const job = jobs[SCHEDULED_JOB]; - if (!job) return { refusal: `${CI_WORKFLOW} has no \`${SCHEDULED_JOB}\` job -- Layer A's \`--union-into\` step has moved.` }; - const condition = typeof job.if === 'string' ? job.if : ''; - const absent = SCHEDULING_FILTERS.filter((n) => !condition.includes(`needs.filter.outputs.${n}`)); - if (absent.length > 0) { - return { - refusal: - `${CI_WORKFLOW}'s \`${SCHEDULED_JOB}\` job no longer names ${absent.map((n) => `\`${n}\``).join(', ')} in its \`if:\`, ` + - `so that filter does not schedule it any more and parity against it means nothing.\n` + - ` if: ${condition || '(absent)'}`, - }; - } - - return { filters, condition, entries: SCHEDULING_FILTERS.flatMap((n) => filters[n]) }; + return { jobs, filters }; } /** @@ -335,6 +383,172 @@ export function judge(source, table) { return { filters: read.filters, condition: read.condition, declarations, covered, uncovered, stale }; } +// ── The second subject: the `console` selection and its dist key (#20765) ──── + +/** The filter list that schedules the console job, and that job, by id. */ +export const CONSOLE_FILTER = 'console'; +export const CONSOLE_JOB = 'console-pin'; +/** Every spelling of the console dist-cache key carries this. */ +const CONSOLE_KEY_MARKER = '-console-dist-'; + +/** + * The `console` entries that are GUARDS rather than build inputs: code every + * run of the job executes, cache hit or miss, so a change to one is judged even + * when the dist is restored. Every other entry must move the dist key. A new + * entry lands in neither place by default: it reds as unclassified until + * someone decides which it is. + */ +export const CONSOLE_GUARDS = Object.freeze({ + '.github/workflows/ci.yml': 'the job definition itself: its steps run on a hit and on a miss', + 'scripts/check-console-sha.mjs': 'run by `pnpm check:console-sha` on every run, hit or miss', + 'scripts/check-console-injection.mjs': 'run by `pnpm check:console-injection --require-stamp` on every run, hit or miss', +}); + +/** The quoted arguments of the ONE `hashFiles(...)` a key spells, or null. */ +export function hashFilesInputs(key) { + const calls = [...String(key).matchAll(/hashFiles\(([^)]*)\)/g)]; + if (calls.length !== 1) return null; + const args = calls[0][1].split(',').map((a) => a.trim()); + if (args.length === 0 || args.some((a) => !/^'[^']+'$/.test(a))) return null; + return args.map((a) => a.slice(1, -1)); +} + +/** + * Does a path select the console job? Only answerable when every entry is a + * literal path, which `judgeConsole` requires; `null` otherwise, so a caller + * can never read a pattern list as a membership test. + */ +export function consoleSelects(path, entries) { + if (entries.some((e) => WILDCARD.test(e))) return null; + return entries.includes(path); +} + +/** + * The verdict on the console selection and the key it must move. `{ refusal }` + * for every state in which the subject was not read; otherwise the findings, + * each list empty on a clean tree. + */ +export function judgeConsole(source) { + const read = readFilterLists(source); + if (read.refusal) return read; + const { jobs, filters } = read; + + const entries = filters[CONSOLE_FILTER]; + if (!entries) return { refusal: `${CI_WORKFLOW}'s \`filters:\` input declares no \`${CONSOLE_FILTER}:\` filter.` }; + if (entries.length === 0) return { refusal: `${CI_WORKFLOW}'s \`${CONSOLE_FILTER}:\` filter is empty.` }; + + const job = jobs[CONSOLE_JOB]; + if (!job) return { refusal: `${CI_WORKFLOW} has no \`${CONSOLE_JOB}\` job -- the console gate has moved.` }; + const condition = typeof job.if === 'string' ? job.if : ''; + if (!condition.includes(`needs.filter.outputs.${CONSOLE_FILTER}`)) { + return { + refusal: + `${CI_WORKFLOW}'s \`${CONSOLE_JOB}\` job no longer names \`${CONSOLE_FILTER}\` in its \`if:\`, so that ` + + `filter does not schedule it any more and parity against it means nothing.\n if: ${condition || '(absent)'}`, + }; + } + + // Every place the job spells the key: the restore and save steps' `with.key`, + // and any env value that carries it (the remedy text the injection check prints). + const steps = Array.isArray(job.steps) ? job.steps : []; + const spellings = []; + for (const step of steps) { + const uses = String(step?.uses ?? ''); + const key = step?.with?.key; + if (typeof key === 'string' && key.includes(CONSOLE_KEY_MARKER)) spellings.push({ where: `${uses || step?.name} key`, key, uses }); + for (const [name, value] of Object.entries(step?.env ?? {})) { + if (typeof value === 'string' && value.includes(CONSOLE_KEY_MARKER)) spellings.push({ where: `env ${name}`, key: value, uses: '' }); + } + } + const restores = spellings.filter((s) => s.uses.startsWith('actions/cache/restore')); + const saves = spellings.filter((s) => s.uses.startsWith('actions/cache/save')); + if (restores.length !== 1 || saves.length !== 1) { + return { + refusal: + `${CI_WORKFLOW}'s \`${CONSOLE_JOB}\` job spells the console dist key in ${restores.length} restore and ` + + `${saves.length} save step(s); this gate reads exactly one of each. The cache has moved or been split.`, + }; + } + const keyInputs = hashFilesInputs(restores[0].key); + if (!keyInputs) { + return { + refusal: + `${CI_WORKFLOW}'s console dist key does not spell exactly one \`hashFiles(...)\` of quoted paths:\n ${restores[0].key}`, + }; + } + + const mismatched = spellings.filter((s) => s.key !== restores[0].key).map((s) => s.where); + const patterns = entries.filter((e) => WILDCARD.test(e)); + const unselected = keyInputs.filter((p) => !entries.includes(p)); + const unclassified = entries.filter((e) => !keyInputs.includes(e) && !Object.hasOwn(CONSOLE_GUARDS, e)); + const staleGuards = Object.keys(CONSOLE_GUARDS).filter((g) => !entries.includes(g)); + const hashedGuards = Object.keys(CONSOLE_GUARDS).filter((g) => keyInputs.includes(g)); + + return { entries, keyInputs, spellings: spellings.length, mismatched, patterns, unselected, unclassified, staleGuards, hashedGuards }; +} + +function reportConsole(verdict) { + if (verdict.refusal) { + console.error(`FAIL: check-ci-filter-parity could not judge the console selection.\n\n - ${verdict.refusal}\n`); + return 1; + } + const problems = []; + const lines = (xs) => xs.map((x) => ` ${x}`).join('\n'); + if (verdict.patterns.length > 0) { + problems.push( + `the \`${CONSOLE_FILTER}:\` filter carries pattern entr(ies):\n${lines(verdict.patterns)}\n` + + ' Name files. A pattern under packages/spec widens the console gate to every spec change, and a\n' + + ' list of literal paths is what lets this gate say which paths select the job.', + ); + } + if (verdict.mismatched.length > 0) { + problems.push( + `the \`${CONSOLE_JOB}\` job spells the console dist key differently at: ${verdict.mismatched.join(', ')}\n` + + ' The restore, the save and the remedy text must name ONE key, or a run saves an entry no run restores.', + ); + } + if (verdict.unselected.length > 0) { + problems.push( + `the console dist key hashes path(s) the \`${CONSOLE_FILTER}:\` filter does not name:\n${lines(verdict.unselected)}\n` + + ' A change to one moves the key without starting the job, so its first build is the merge queue\'s,\n' + + ` where a check outside the required set cannot stop a merge. Add each to \`${CONSOLE_FILTER}:\`.`, + ); + } + if (verdict.unclassified.length > 0) { + problems.push( + `the \`${CONSOLE_FILTER}:\` filter names path(s) the dist key does not hash and CONSOLE_GUARDS does not declare:\n` + + `${lines(verdict.unclassified)}\n` + + ' A head that moves one starts the job, restores the dist built before the change and skips the build\n' + + ' step, so the run is green without judging it. A BUILD INPUT goes into the key\'s hashFiles (all of its\n' + + ' spellings); a GUARD that every run executes goes into CONSOLE_GUARDS in this file, with the step.', + ); + } + if (verdict.staleGuards.length > 0) { + problems.push( + `CONSOLE_GUARDS declares path(s) the \`${CONSOLE_FILTER}:\` filter no longer names:\n${lines(verdict.staleGuards)}\n` + + ' Delete the declaration, or restore the entry if the guard still runs.', + ); + } + if (verdict.hashedGuards.length > 0) { + problems.push( + `CONSOLE_GUARDS declares path(s) the dist key also hashes:\n${lines(verdict.hashedGuards)}\n` + + ' A guard runs on a cache hit, so hashing it only buys a rebuild per change. Pick one kind.', + ); + } + if (problems.length > 0) { + console.error('FAIL: ci.yml\'s `console` filter and the console dist key are out of step.\n'); + for (const p of problems) console.error(` - ${p}\n`); + return 1; + } + console.log( + `OK: all ${verdict.entries.length} \`${CONSOLE_FILTER}\` entr(ies) are literal paths; ` + + `${verdict.keyInputs.length} are build inputs the console dist key hashes and ` + + `${verdict.entries.length - verdict.keyInputs.length} are declared guards; the key is spelled one way ` + + `in all ${verdict.spellings} place(s) the \`${CONSOLE_JOB}\` job uses it.`, + ); + return 0; +} + function report(verdict) { if (verdict.refusal) { console.error(`FAIL: check-ci-filter-parity could not judge the scheduling filters.\n\n - ${verdict.refusal}\n`); @@ -397,7 +611,10 @@ export function main(root = REPO_ROOT, table = CROSS_PACKAGE_TEST_INPUTS) { console.error(`FAIL: cannot read ${CI_WORKFLOW}: ${err?.code ?? err?.message ?? err}`); return 1; } - return report(judge(source, table)); + // Both subjects are judged and both report, so one red never hides the other. + const crosspkg = report(judge(source, table)); + const consoleCode = reportConsole(judgeConsole(source)); + return crosspkg === 0 && consoleCode === 0 ? 0 : 1; } function list(root = REPO_ROOT, table = CROSS_PACKAGE_TEST_INPUTS) { @@ -415,7 +632,18 @@ function list(root = REPO_ROOT, table = CROSS_PACKAGE_TEST_INPUTS) { console.log(`${row.covered ? 'ok ' : 'FAIL'} ${row.glob}${row.covered ? ` via ${row.kind} ${row.via}` : ''}`); } console.log(`\n${seen.size} unique glob(s), ${verdict.uncovered.length} uncovered declaration(s).`); - return verdict.uncovered.length > 0 ? 1 : 0; + + const con = judgeConsole(readFileSync(join(root, CI_WORKFLOW), 'utf8')); + if (con.refusal) { + console.error(`FAIL: ${con.refusal}`); + return 1; + } + console.log(`\n${CONSOLE_FILTER}: ${JSON.stringify(con.entries)}`); + for (const entry of con.entries) { + const kind = con.keyInputs.includes(entry) ? 'build input (hashed into the dist key)' : Object.hasOwn(CONSOLE_GUARDS, entry) ? `guard: ${CONSOLE_GUARDS[entry]}` : 'UNCLASSIFIED'; + console.log(`${kind === 'UNCLASSIFIED' ? 'FAIL' : 'ok '} ${entry} ${kind}`); + } + return verdict.uncovered.length > 0 || con.unclassified.length > 0 ? 1 : 0; } // ── self-test ──────────────────────────────────────────────────────────────── @@ -766,6 +994,125 @@ export async function selfTest() { assert(lint.includes(`node ${SELF} --self-test`), 'wiring: lint.yml runs the --self-test leg too'); } + // ── (8) the `console` selection and the dist key it must move (#20765) ─── + battery('(8) the `console` selection and the dist key it must move'); + const realSource = readFileSync(join(REPO_ROOT, CI_WORKFLOW), 'utf8'); + const realConsole = judgeConsole(realSource); + const findingsOf = (v) => + ['mismatched', 'patterns', 'unselected', 'unclassified', 'staleGuards', 'hashedGuards'].flatMap((k) => v[k] ?? ['(no verdict)']); + assert(!realConsole.refusal, `the checked-in ci.yml's console selection is readable -- ${realConsole.refusal ?? ''}`); + assert(findingsOf(realConsole).length === 0, `the checked-in console filter and dist key are in step -- ${findingsOf(realConsole).join(', ')}`); + // Triage's pins, over the real tree: the spec's entry layout selects the job + // AND moves the key, so the head judges the change instead of replaying a dist + // built before it; an ordinary spec source file does neither. + for (const layout of ['packages/spec/package.json', 'packages/spec/tsup.config.ts']) { + assert( + consoleSelects(layout, realConsole.entries ?? []) === true && (realConsole.keyInputs ?? []).includes(layout), + `a head touching only ${layout} selects Console Pin Gate and moves its dist key`, + ); + } + const control = 'packages/spec/src/ui/view.zod.ts'; + assert( + existsSync(join(REPO_ROOT, control)) && consoleSelects(control, realConsole.entries ?? []) === false, + `the control: a head touching only ${control} (a tracked spec source file) does NOT select Console Pin Gate`, + ); + assert( + !(realConsole.entries ?? []).some((e) => e.startsWith('packages/spec/src/')), + 'no console entry reaches into packages/spec/src -- the entry layout selects the job, the spec content does not', + ); + + // Synthetic trees. The default is clean, so every red below is the named drift. + const guardEntries = Object.keys(CONSOLE_GUARDS); + const keyOf = (paths) => `\${{ runner.os }}-console-dist-\${{ hashFiles(${paths.map((p) => `'${p}'`).join(', ')}) }}`; + const consoleFixture = ({ entries, key, saveKey, envKey, condition, withSave = true } = {}) => { + const k = key ?? keyOf(['.objectui-sha', 'scripts/build-console.sh']); + const list = (xs) => xs.map((e) => ` - '${e}'`).join('\n'); + return [ + 'name: CI', + 'jobs:', + ' filter:', + ' steps:', + ' - uses: dorny/paths-filter@v4', + ' id: changes', + ' with:', + ' filters: |', + ' core:', + list(['packages/**']), + ' console:', + list(entries ?? ['.objectui-sha', 'scripts/build-console.sh', ...guardEntries]), + ' console-pin:', + ` if: ${JSON.stringify(condition ?? "${{ !cancelled() && needs.filter.outputs.console != 'false' }}")}`, + ' steps:', + ' - uses: actions/cache/restore@v6', + ' with:', + ' path: packages/console/dist', + ` key: ${k}`, + ' - run: pnpm check:console-injection --require-stamp', + ' env:', + ` CONSOLE_DIST_CACHE_KEY: ${envKey ?? k}`, + ...(withSave + ? [' - uses: actions/cache/save@v6', ' with:', ' path: packages/console/dist', ` key: ${saveKey ?? k}`] + : []), + ].join('\n'); + }; + const clean = judgeConsole(consoleFixture()); + assert(!clean.refusal && findingsOf(clean).length === 0, `positive control: the default synthetic tree is clean -- ${clean.refusal ?? findingsOf(clean).join(', ')}`); + + const specInFilterOnly = judgeConsole( + consoleFixture({ entries: ['.objectui-sha', 'scripts/build-console.sh', 'packages/spec/package.json', ...guardEntries] }), + ); + assert( + (specInFilterOnly.unclassified ?? []).join(',') === 'packages/spec/package.json', + 'THE HOLE: a spec path the filter names but the key does not hash is reported -- the head would replay a pre-change dist', + ); + const keyOnly = judgeConsole(consoleFixture({ key: keyOf(['.objectui-sha', 'scripts/build-console.sh', 'packages/spec/package.json']) })); + assert( + (keyOnly.unselected ?? []).join(',') === 'packages/spec/package.json', + 'the other hole: a path the key hashes but the filter does not name is reported -- its first build would be the queue\'s', + ); + const subtree = judgeConsole(consoleFixture({ entries: ['.objectui-sha', 'scripts/build-console.sh', 'packages/spec/**', ...guardEntries] })); + assert((subtree.patterns ?? []).join(',') === 'packages/spec/**', 'a pattern entry is reported -- the filter must not become all of spec'); + assert(consoleSelects('packages/spec/src/x.zod.ts', ['packages/spec/**']) === null, '-- and a pattern list is never read as a membership test'); + const splitKey = judgeConsole(consoleFixture({ saveKey: keyOf(['.objectui-sha']) })); + assert( + (splitKey.mismatched ?? []).length === 1 && /cache\/save/.test(splitKey.mismatched[0]), + 'a save step spelling a different key than the restore is reported, naming the save', + ); + const staleGuard = judgeConsole( + consoleFixture({ entries: ['.objectui-sha', 'scripts/build-console.sh', ...guardEntries.filter((g) => g !== CI_WORKFLOW)] }), + ); + assert((staleGuard.staleGuards ?? []).join(',') === CI_WORKFLOW, 'a declared guard the filter no longer names is reported stale'); + const hashedGuard = judgeConsole(consoleFixture({ key: keyOf(['.objectui-sha', 'scripts/build-console.sh', CI_WORKFLOW]) })); + assert((hashedGuard.hashedGuards ?? []).join(',') === CI_WORKFLOW, 'a declared guard the key also hashes is reported'); + + assert( + /declares no \`console:\` filter/.test(judgeConsole(consoleFixture().replace(/ console:\n( - '[^']*'\n?)+/, '')).refusal ?? ''), + 'a filters input with no `console:` list => REFUSAL, not a clean zero', + ); + assert( + /no longer names \`console\`/.test(judgeConsole(consoleFixture({ condition: "${{ !cancelled() }}" })).refusal ?? ''), + 'the console job dropping the filter from its `if:` => REFUSAL', + ); + assert(/exactly one of each/.test(judgeConsole(consoleFixture({ withSave: false })).refusal ?? ''), 'a job with no save step => REFUSAL'); + assert( + hashFilesInputs("${{ runner.os }}-x-${{ hashFiles(env.PATHS) }}") === null && + /quoted paths/.test(judgeConsole(consoleFixture({ key: "${{ runner.os }}-console-dist-${{ hashFiles(env.PATHS) }}" })).refusal ?? ''), + 'a key whose hashFiles arguments are not quoted paths => REFUSAL: the gate cannot say what it hashes', + ); + + // The report path, over the real ci.yml with one drift injected: the spec's + // tsup entry list dropped from the filter while the key still hashes it. + const drifted = realSource.replace(" - 'packages/spec/tsup.config.ts'\n", ''); + assert(drifted !== realSource, 'the report-path fixture found its anchor in the checked-in ci.yml'); + const scratch = mkdtempSync(join(tmpdir(), 'ci-filter-parity-')); + try { + mkdirSync(join(scratch, '.github', 'workflows'), { recursive: true }); + writeFileSync(join(scratch, CI_WORKFLOW), drifted); + assert(quietly(() => main(scratch)) === 1, 'main() returns 1 over a ci.yml whose console filter lost a hashed path -- the report path, not only `judgeConsole`'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -823,7 +1170,9 @@ export async function selfTest() { `\`core\`, one covered only by \`crosspkg\` and one covered by neither judged separately in one table, the ` + `stale-entry direction, seven refusals over subjects that could not be read, the checked-in ci.yml, the ` + `pre-#10015 rollback uncovering the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one plus #14561's one plus #14824's three plus #15818's two plus #18650's one, ` + - `and the CI wiring read out of lint.yml.`, + `the CI wiring read out of lint.yml, and the \`console\` selection: the spec's entry layout selecting Console Pin ` + + `Gate and moving its dist key while a spec source file does neither, each way the filter and the key can drift ` + + `observed red, and the report path red over the checked-in ci.yml with one hashed path dropped from the filter.`, ); selfTestReachedVerdict = true; return 0; diff --git a/scripts/pm/check-expected-skips.mjs b/scripts/pm/check-expected-skips.mjs index a999480bc62..8a58a8c0e1e 100644 --- a/scripts/pm/check-expected-skips.mjs +++ b/scripts/pm/check-expected-skips.mjs @@ -313,7 +313,7 @@ export const EXPECTED_SKIPS = Object.freeze([ workflow: 'ci.yml', job: 'console-pin', gate: { kind: 'filter-output', outputs: ['console'] }, - reason: "gated on ci.yml's `filter` job `console` output (the `.objectui-sha` pin and the console build/probe scripts); a diff that moves none of them skips the pinned-console build", + reason: "gated on ci.yml's `filter` job `console` output (the `.objectui-sha` pin, the console build/probe scripts, and the spec's entry layout: `packages/spec/package.json` and `packages/spec/tsup.config.ts`); a diff that moves none of them skips the pinned-console build", }, ]); From f57066dd5e5ba9c4b0423b44784af32d5aaa591f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:42:07 +0000 Subject: [PATCH 2/2] docs(console-pin): the injection gate's header and remedy, and release.yml's key comment, say the two dist keys now differ (#20765) ci.yml's console dist key hashes a superset of release.yml's since the previous commit, so three pieces of prose went false: the header of scripts/check-console-injection.mjs restated the old key and said packages/spec was out of the console filter, its remedy() fallback printed that key as the one to delete, and release.yml asked the two keys to stay in step. The header and the fallback now point at each workflow's restore step instead of carrying a third copy of the key, and release.yml's comment says its narrower key is deliberate. No key, step or env changes. Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 10 +++-- scripts/check-console-injection.mjs | 64 +++++++++++++++++++---------- 3 files changed, 51 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24aac400cf2..5e10989d662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2438,8 +2438,8 @@ jobs: # # ⚠️ release.yml's restore step still keys on the pin and the build script # alone, so the two workflows no longer share dist entries: each builds its - # own on a miss. That file's comment asks for the two to stay in step; which - # way to reconcile them is recorded on #20765. + # own on a miss. The narrower release key is deliberate, and that file's + # comment says why (#20765). # # Split restore/save where release.yml uses the combined action, because the # combined form's post-step saves even when the job FAILED. build-console.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f00089fde0a..230f1a6e58a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1378,9 +1378,13 @@ jobs: - name: Build run: pnpm run build - # ci.yml's Console Pin Gate (#4290) uses this exact key, so the pin bump's - # PR run and this job share one build — keep the two in step if either - # input set changes. + # This key hashes the pin and the build script. ci.yml's Console Pin Gate + # (#4290) hashes a SUPERSET: those two, the probe scripts and the spec's + # entry layout (#20765). So the two workflows no longer share dist entries, + # and each one builds its own on a miss. Keeping this key narrower is + # deliberate. 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. # # Split restore/save, matching ci.yml, and not the combined actions/cache # action: the combined form's post-step saves even when the job FAILED, diff --git a/scripts/check-console-injection.mjs b/scripts/check-console-injection.mjs index bd02c3b87c0..6e91a163863 100644 --- a/scripts/check-console-injection.mjs +++ b/scripts/check-console-injection.mjs @@ -7,20 +7,23 @@ * * ## The hole this closes (objectstack#9667) * - * The vendored Console SPA is cached under - * - * ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }} - * - * spelled identically in ci.yml (twice: restore + save) and release.yml. The key - * does NOT include packages/spec, so a dist built while spec was at state X is - * restored and reused after spec moves on — and because + * The vendored Console SPA is cached under a key each workflow spells in its + * own restore step: ci.yml's `console-pin` job and release.yml's publish job. + * The key is deliberately not restated here, because a copy in a comment is one + * that nothing holds in step (scripts/check-ci-filter-parity.mjs holds ci.yml's + * spellings to its `console` filter). The two keys no longer match + * (objectstack#20765): release.yml hashes the pin and the build script, and + * ci.yml hashes those plus the probe scripts and the spec's ENTRY LAYOUT + * (package.json, tsup.config.ts). Neither includes the spec's CONTENT, so a dist + * built while spec was at state X is restored and reused after spec moves on, + * and because * scripts/assert-console-spec-injection.mjs runs INSIDE build-console.sh, a * cache hit skips the entire build step and therefore skips the assertion too. * The injection fixed resolution; the cache could still serve a console whose * bundled spec is not the one this build proved. * - * Adding packages/spec to the cache key was considered and REJECTED: it busts - * the key on every spec change and forces a full cold console rebuild (~20 min, + * Adding the whole of packages/spec to the cache key was considered and + * REJECTED: it busts the key on every spec change and forces a full cold console rebuild (~20 min, * measured) on a repo doing ~18 merges a day. The cache's economics — including * ci.yml's deliberate split restore/save, which exists so a failed build never * poisons the entry — are kept exactly as they are. Only the silent half is @@ -48,11 +51,17 @@ * the stamped detector is STILL ABSENT from this tree's spec. Once the published * spec catches up, the stamp is expired and says so instead of passing. * - * ## Why packages/spec is NOT in ci.yml's console filter (objectstack#9710) + * ## Why the rest of packages/spec is NOT in ci.yml's console filter (objectstack#9710) * - * That filter lists the pin, the build script and this gate's own sources — not - * packages/spec — so a spec-only PR never schedules Console Pin Gate and never - * reaches this check. Adding it is the obvious next thought; it was measured and + * That filter lists the pin, the build and probe scripts and this gate's own + * sources, and since objectstack#20765 also the spec's ENTRY LAYOUT + * (package.json's exports map and tsup.config.ts). ci.yml's dist key hashes + * each of those build inputs too, so a head that moves the entry layout MISSES + * the cache, rebuilds, and assert-console-spec-injection.mjs judges it; a + * cache hit could not have, as the paragraphs below explain. The rest of + * packages/spec is its CONTENT, which is in neither the filter nor the key, so + * a content-only spec PR never schedules Console Pin Gate and never reaches + * this check. Adding the content is the obvious next thought; it was measured and * DECLINED, and the reason is not cost, which is why it is recorded here rather * than left on a card: the job it would schedule is vacuous, not expensive. * @@ -60,9 +69,9 @@ * functions of the RESTORED DIST and its stamp — a missing dist, unreadable * assets or a malformed stamp, a missing stamp, the published-only detector * present in the bundle, the stamp's own fresh witness missing from it. A - * spec-only diff cannot move any of those: the cache key is the one spelled at - * the top of this header — the pin and the build script, nothing else — and - * entries under it are IMMUTABLE, so all five replay what the last + * content-only spec diff cannot move any of those: it does not move the cache + * key (see the top of this header for what each key hashes), and entries under + * a key are IMMUTABLE, so all five replay what the last * console-filtered run already saw. Exactly ONE verdict reads this tree, the * expiry re-check, and it needs packages/spec/dist because readSpecBlob resolves * the package's exports map. So the restore-only job proposed there — no @@ -214,13 +223,17 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); * How an operator clears a cached dist this gate refused. * * ⛔ The `gh cache delete` line is MAINTAINER-ONLY — it needs repo write scope, - * and it deletes an entry shared by ci.yml and release.yml. It is named anyway - * because ruling out a remedy an operator cannot discover is how a red becomes - * noise; a contributor who cannot run it should hand this message to a - * maintainer rather than guess. + * and it deletes an entry every later run of that workflow under the same key + * would restore. It is named anyway because ruling out a remedy an operator + * cannot discover is how a red becomes noise; a contributor who cannot run it + * should hand this message to a maintainer rather than guess. + * + * The key comes from the CI step (`CONSOLE_DIST_CACHE_KEY`). Without it (a local + * run) the line names WHERE each workflow spells its key instead of restating + * one: ci.yml's and release.yml's keys differ (objectstack#20765), and a copy + * here would be a third spelling nothing holds in step. */ function remedy(cacheKey) { - const key = cacheKey || '${{ runner.os }}-console-dist-${{ hashFiles(\'.objectui-sha\', \'scripts/build-console.sh\') }}'; return [ ' How to clear this:', '', @@ -231,7 +244,14 @@ function remedy(cacheKey) { ' • In CI — the restored artifact is a CACHE ENTRY, not anything in this PR.', " Nothing in the diff can fix it; the entry has to go. ⛔ MAINTAINER-ONLY:", '', - ` gh cache delete "${key}"`, + ` gh cache delete "${cacheKey || 'KEY'}"`, + ...(cacheKey + ? [] + : [ + '', + " KEY is the key the failing job's restore step printed. ci.yml's", + " `console-pin` job and release.yml's publish job each spell their own.", + ]), '', ' then re-run the Console Pin Gate job. The next run misses, rebuilds,', ' and re-stamps.',