diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66efb25a05c..5e10989d662 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. 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 @@ -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/.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-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/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.', 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", }, ]);