From fb32a5e1cf0c049e949ed33c56f7350a86537423 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 04:25:13 +0000 Subject: [PATCH 1/6] ci(console-pin): conclude on every run and re-enrol Console Pin Gate in REQUIRED_CONTEXTS The console-pin job no longer carries the console filter term in its job-level if: a first step reads the filter verdict once, every step that costs anything is step-gated on it, and an unaffected run concludes success with a NOT BUILT notice and step summary instead of skipped. The filter contract is kept one level down (anything but an explicit 'false' builds; merge_group widens to 'true'). The registry row returns ahead of its Settings half, with an authorized field that says approved-not-applied, and the tombstone is rewritten. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .github/workflows/ci.yml | 95 ++++++++++++++++++++++++----- scripts/check-required-contexts.mjs | 85 +++++++++++++++++++------- 2 files changed, 145 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ee515119a4..bb31ff916ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2310,16 +2310,34 @@ jobs: # under `packages/**`, and watching it here would rebuild the console on a large # fraction of every PR. release.yml stays the only check for that direction. # - # ⚠️ This gate is ADVISORY, and its name is no longer pinned. It was enrolled - # in `check:required-contexts` in #6865 on the strength of the 2026-08-09 - # ruling approving it for the required set; the live ruleset read of - # 2026-08-18 (#9533) found it had never landed there, and the row was dropped - # rather than left claiming otherwise — see that file's TOMBSTONE for the full - # provenance. So a red here does NOT block a merge, and renaming this job no - # longer turns anything red. The name is still load-bearing PROSE that renaming - # this job silently falsifies — docs/releases-maintenance.md and - # packages/console/README.md both describe the pin flow by this name (#9793 - # tracks the missing pin for job names that are not required contexts). + # ⭐ This job CONCLUDES ON EVERY RUN, and its name is pinned again (#17673, + # ruling #18900 item ⑤: back to required, in two steps). Step one is this + # file plus the REQUIRED_CONTEXTS row in scripts/check-required-contexts.mjs, + # so renaming this job reddens the required `Lint & Repo Gates` context. + # Step two — adding `Console Pin Gate` to Settings → Rulesets — is the + # maintainer's click AFTER step one lands. Until that click a red here still + # does NOT block a merge, and `--verify-required-set` reports the row as + # direction A (registered, not yet live); the row's `authorized` field and + # that file's TOMBSTONE carry the provenance, including the #9533 drop this + # reverses. + # + # Why the job carries NO filter term in its `if:` any more. A job-level `if:` + # that evaluates false concludes `skipped`: the question this gate asks was + # never asked, yet the check-run reads like a pass. Ruling ⑤ asks for a + # conclusion on every PR — a `success` on an unaffected one — so the filter + # verdict moved from the job's `if:` into the first step (`scope`), and every + # step that costs anything is step-gated on it. THE FILTER CONTRACT (#4928) + # is kept in both halves, only one level down: the `scope` step treats the + # `console` output as "affected" unless it is EXACTLY 'false', so a dead + # `filter` job (output '' or the `|| 'true'` fallback) still builds, and + # `!cancelled()` still displaces the implicit success() at the job level. On + # merge_group / schedule / workflow_dispatch the output is widened to 'true' + # and the full gate runs, exactly as before. + # + # An unaffected run MUST say so: the `scope` step writes a notice and a step + # summary stating that nothing was built. That `success` is the filter's + # verdict, never a measured build — read it as NOT BUILT, not as a pass of the + # pinned SHA. # # ⚠️ It used to need telling apart from a same-shaped sibling, `Console Pin # Freshness`, which asked whether the pin was CURRENT. That gate is gone @@ -2333,31 +2351,71 @@ jobs: name: Console Pin Gate needs: filter # See THE FILTER CONTRACT on the filter job's outputs (#4928). `console`, - # not `core` — each job keeps its own filter output. - if: ${{ !cancelled() && needs.filter.outputs.console != 'false' }} + # not `core` — each job keeps its own filter output, read by the `scope` + # step below rather than here (see the header: a job-level filter term + # concludes `skipped`, and this job must conclude on every run). + if: ${{ !cancelled() }} runs-on: ubuntu-latest timeout-minutes: 45 permissions: contents: read steps: + # The filter verdict, read ONCE. Every later step is gated on + # `steps.scope.outputs.affected != 'false'` — "skip only when EXPLICITLY + # false", the contract's own spelling — so a step that forgets its guard + # costs runner time, never a false green: this step fails loudly rather + # than writing anything but 'true' or 'false'. + - name: Read the console filter verdict + id: scope + env: + CONSOLE_OUTPUT: ${{ needs.filter.outputs.console }} + FILTER_RESULT: ${{ needs.filter.result }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + echo "filter job result: ${FILTER_RESULT:-}; console output: '${CONSOLE_OUTPUT}'; event: ${EVENT_NAME}" + if [ "$CONSOLE_OUTPUT" = "false" ]; then + echo "affected=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Console Pin Gate: NOT BUILT::The console filter said false for this diff, so nothing was built or checked here. This success is the filter's verdict, not a measured build of the pinned objectui SHA. The merge-queue build widens the filter and runs the full gate." + { + echo "### Console Pin Gate — NOT BUILT on this run" + echo + echo "The \`filter\` job's \`console\` output was \`false\` (event: \`${EVENT_NAME}\`): this diff moves none of the console build inputs listed in ci.yml's \`console\` filter." + echo + echo "- ⛔ No objectui checkout, no console build, no dist restore, no \`check:console-sha\` / \`check:console-injection\` ran." + echo "- This \`success\` is the **filter's verdict**, not a measured pass of the pinned SHA — read it as NOT BUILT." + echo "- On \`merge_group\` the filter is widened to \`'true'\` and this job runs the full gate on the merged tree." + } >> "$GITHUB_STEP_SUMMARY" + else + # '' (a dead filter job) and 'true' both mean RUN — THE FILTER + # CONTRACT's half 1, restated at step level. + echo "affected=true" >> "$GITHUB_OUTPUT" + echo "Console inputs affected (or the filter could not say otherwise): running the full gate." + fi + - name: Checkout repository + if: steps.scope.outputs.affected != 'false' uses: actions/checkout@v7 - name: Setup Node.js + if: steps.scope.outputs.affected != 'false' uses: actions/setup-node@v7 with: node-version: '22' - name: Setup pnpm + if: steps.scope.outputs.affected != 'false' uses: ./.github/actions/setup-pnpm - name: Get pnpm store directory + if: steps.scope.outputs.affected != 'false' shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache + if: steps.scope.outputs.affected != 'false' uses: actions/cache@v6 with: path: ${{ env.STORE_PATH }} @@ -2371,6 +2429,7 @@ jobs: # builds a superset of the client closure needed here and is seeded from # main (same reasoning as the Temporal Conformance job's fallback). - name: Restore Turbo cache + if: steps.scope.outputs.affected != 'false' uses: actions/cache/restore@v6 with: path: .turbo/cache @@ -2382,6 +2441,7 @@ jobs: ${{ runner.os }}-turbo-build-core- - name: Install dependencies + if: steps.scope.outputs.affected != 'false' run: pnpm install --frozen-lockfile # build-console.sh bundles THIS tree's @objectstack/client into the console @@ -2393,6 +2453,7 @@ jobs: # script's existing callers (release.yml, showcase-smoke.yml) pre-build for # the same reason. - name: Build @objectstack/client and its dependencies + if: steps.scope.outputs.affected != 'false' run: pnpm exec turbo run build --filter=@objectstack/client... --concurrency=4 # Same key as release.yml's "Cache vendored Console dist" step. Actions @@ -2409,6 +2470,7 @@ jobs: # the same guarantees as a freshly built one. - name: Restore vendored Console dist (keyed on the objectui pin) id: console-dist + if: steps.scope.outputs.affected != 'false' uses: actions/cache/restore@v6 with: path: packages/console/dist @@ -2419,7 +2481,9 @@ jobs: # packages/console/, and asserts the bundle canary — every failure mode a # bad pin can carry, surfaced on the PR instead of at publish time. - name: Build the Console SPA at the pinned objectui SHA - if: steps.console-dist.outputs.cache-hit != 'true' + # Both terms: on an unaffected run the restore above is skipped, its + # cache-hit output is '' and `'' != 'true'` alone would BUILD. + if: steps.scope.outputs.affected != 'false' && steps.console-dist.outputs.cache-hit != 'true' run: bash scripts/build-console.sh # check:console-sha exits 0 — deliberately — when there is no dist at all @@ -2430,6 +2494,7 @@ jobs: # check with nothing to check and report success. Prove one is really in # place before letting that check speak for the job. - name: Assert a real Console dist is in place + if: steps.scope.outputs.affected != 'false' env: CACHE_HIT: ${{ steps.console-dist.outputs.cache-hit }} run: | @@ -2446,6 +2511,7 @@ jobs: echo "✓ Console dist present ($(du -sk "$dist" | awk '{print $1}') KB, via $origin) from objectui@$(cut -c1-12 "$dist/.objectui-sha")" - name: Verify the Console dist stamp matches the pin + if: steps.scope.outputs.affected != 'false' run: pnpm check:console-sha # The spec-injection half of the same question, and the reason it is a @@ -2474,6 +2540,7 @@ jobs: # whose post-step saves even on failure, under this same repo-scoped key — so # a poisoned entry is reachable from here and must not pass quietly. - name: Verify the restored Console dist bundles this tree's spec + if: steps.scope.outputs.affected != 'false' env: CONSOLE_DIST_CACHE_KEY: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }} run: pnpm check:console-injection --require-stamp @@ -2484,7 +2551,7 @@ jobs: # key moves only when the pin does. A pin-bump PR writes one entry, and the # rest of that PR's pushes reuse it instead of rebuilding the console again. - name: Save vendored Console dist - if: steps.console-dist.outputs.cache-hit != 'true' + if: steps.scope.outputs.affected != 'false' && steps.console-dist.outputs.cache-hit != 'true' uses: actions/cache/save@v6 with: path: packages/console/dist diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 90f399376ab..78fea5b66bc 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -145,11 +145,13 @@ * only a maintainer can change the settings. What #9642 changed is that a * disagreement is now MEASURED instead of invisible — and the first run found * one: `Build Docs` and `Console Pin Gate` were registered here and were NOT - * in the live required set. That first finding is closed (#9533, 2026-08-18 - * ruling — the two rows dropped; the tombstone below carries the provenance), - * so the two lists agree in both directions today and the sweep prints its - * ✅ line. The mechanism stays for the next disagreement; it reports and - * decides nothing. + * in the live required set. That first finding was closed by #9533 (2026-08-18 + * ruling — the two rows dropped; the tombstone below carries the provenance). + * `Console Pin Gate` has since been re-enrolled ahead of its Settings half + * (#17673, ruling #18900 ⑤: code first, the maintainer's click after), so + * until that click the sweep reports it as direction A — the designed reading + * of an enrolment whose settings half has not happened yet, not a new finding. + * The mechanism reports and decides nothing. * * ## Why `if:` is deliberately NOT asserted * @@ -157,13 +159,17 @@ * right for lint.yml's two and WRONG for the four jobs the 2026-08-09 ruling * approved (approved — how much of that batch reached the settings is #9533's * finding below): `build-core`, `build-docs`, `console-pin` and - * `temporal-conformance` each carry `if: ${{ !cancelled() && needs.filter…}}` + * `temporal-conformance` each carried `if: ${{ !cancelled() && needs.filter…}}` * BY DESIGN — THE FILTER CONTRACT (#4928). A job-level `if:` that skips still * publishes a check run (conclusion `skipped`, which branch protection counts * as passing); it is the workflow-level `paths:` filter that publishes nothing, * which is why assertion 7 above judges the trigger and not the job. Asserting * "no `if:`" uniformly would have made this gate red on `main` the day it - * landed, against four jobs that are correct. + * landed, against four jobs that are correct. (`console-pin` has since moved + * its filter term from the job's `if:` into a first step, so it concludes on + * every run rather than reading `skipped` — #17673, ruling #18900 ⑤; see its + * registry row. That is a property of that one job, not a rule this pin + * asserts of the others.) * * The two aggregate gates are a further special case — they need `if: always()` * for a reason of their own (#3622: a gate that skips because a dependency @@ -290,11 +296,19 @@ const UNATTRIBUTED_BATTERY = '(no battery open)'; * the registry-follows half of the header's two-step; the Settings half is * the maintainer attestation above. * - * ⛔ TOMBSTONE — `Build Docs` (ci.yml:build-docs) and `Console Pin Gate` - * (ci.yml:console-pin), dropped 2026-08-18 by the #9533 ruling. Recorded at - * length because the shape here is NOT the one the ruling's wording assumed, - * and a future reader who re-derives it from git will otherwise reach the - * same wrong conclusion twice: + * ⛔ TOMBSTONE — `Build Docs` (ci.yml:build-docs), dropped 2026-08-18 by the + * #9533 ruling together with `Console Pin Gate` (ci.yml:console-pin). ⭐ The + * console half is REVERSED: ruling #18900 ⑤ put `Console Pin Gate` back, in + * two steps — first the job reports a conclusion on every PR and its row + * returns here (#17673, the row below), then the maintainer adds it in + * Settings → Rulesets. Until that click the row is registered-not-live and + * `--verify-required-set` prints it as direction A; that is the two-step's + * designed intermediate state, which the row's `authorized` field says in + * words so it is never read as an attested application (the exact mistake + * the first bullet below records). `Build Docs` stays dropped. The #9533 + * provenance is kept for both, because the shape here is NOT the one that + * ruling's wording assumed, and a future reader who re-derives it from git + * will otherwise reach the same wrong conclusion twice: * * • What the rows claimed. Both carried `authorized: '#5617 closing ruling * 2026-08-09, second batch'` — and that ruling reads «Second batch @@ -323,9 +337,10 @@ const UNATTRIBUTED_BATTERY = '(no battery open)'; * ⛔ And why this tombstone is PROSE and not a RETIRED_CONTEXT_NAMES row, * against the #9523 precedent the ruling cited. That ledger bans a name from * the instruction surfaces because the name is DEAD — no check-run reports it - * any more. Neither of these names is dead: both jobs still exist, still carry - * these `name:` literals, and still publish these check-runs on every PR that - * trips their filter. They lost REQUIRED status, which is a different fact. + * any more. Neither of these names was dead: both jobs still existed, still + * carried these `name:` literals, and still published these check-runs. They + * lost REQUIRED status, which is a different fact — and for `Console Pin + * Gate` a reversible one, as ⑤ has now shown. * Ledgering them anyway was measured before it was rejected (2026-08-18): * `docs/releases-maintenance.md` names `Console Pin Gate` in correct, current * prose describing what still checks the pin, so @@ -334,18 +349,20 @@ const UNATTRIBUTED_BATTERY = '(no battery open)'; * is right. Budgeting around it would only arm the trap for the next author * who legitimately names the live job. * - * ⚠️ The cost of the drop, stated rather than discovered later: these two - * `name:` literals are now pinned by NOTHING, while prose elsewhere still refers - * to the jobs by name — `docs/releases-maintenance.md`, + * ⚠️ The cost of the drop, stated rather than discovered later: the `Build + * Docs` literal is pinned by NOTHING (`Console Pin Gate` is pinned again by its + * row below), while prose elsewhere still refers to the jobs by name — + * `docs/releases-maintenance.md`, * `packages/console/README.md` and lint.yml's cross-reference among them. This * list read FIVE until #10134 deleted `scripts/check-objectui-pin-fresh.mjs` and * `.github/workflows/objectui-pin-freshness.yml`, both of which named * `Console Pin Gate` only to tell it apart from `Console Pin Freshness`; the * recorded debt shrank by two and did not close. ⚠️ It was never asserted by * anything either way, so read it as examples and never as a census — the same - * trap the `carries` note below is about. Renaming either job no longer detaches a - * required gate — that is the whole point — but it does silently falsify that - * prose. Filed as its own card rather than solved here, since a pin for + * trap the `carries` note below is about. Renaming `build-docs` no longer + * detaches a required gate — that is the whole point — but it does silently + * falsify that prose (renaming `console-pin` reds this pin again, since #17673). + * Filed as its own card rather than solved here, since a pin for * "contract job names that are not required contexts" is a new mechanism and * this card is ledger hygiene. Filed as #9793. * @@ -439,6 +456,23 @@ export const REQUIRED_CONTEXTS = [ 'read back live in the ruleset sweep of 2026-09-10 that carries the director ruling enrolling this row (#15233)', carries: 'the governed-surface refusal — the `merge_group` leg that refuses a governed diff carrying no ruled approval', }, + { + workflow: 'ci.yml', + job: 'console-pin', + // Re-enrolled AHEAD of its Settings half, on purpose (ruling #18900 ⑤: + // code first, the maintainer's click after). The job concludes on every + // run — `success` with a NOT BUILT notice when the `console` filter says + // false, the real build and dist checks otherwise — so the tombstone's + // `skipped` reading is gone. Until the click, `--verify-required-set` + // reports this row as direction A; `authorized` below says APPROVED, not + // APPLIED, so it is never mistaken for an attested application (the + // #9533 lesson recorded in the tombstone above). + context: 'Console Pin Gate', + authorized: + 'maintainer ruling on #18900 item ⑤ (comment 5727134555, 2026-09-18): back to required in two steps — this row is step one (#17673); ' + + 'the Settings → Rulesets entry is the maintainer\'s click AFTER it lands, and NOT attested applied until then', + carries: 'the pinned-objectui console build and its dist checks (check:console-sha, check:console-injection)', + }, ]; /** @@ -563,6 +597,12 @@ export const INSTRUCTION_SURFACES = [ 'Build Core', 'Temporal Conformance (live PG + MySQL)', 'Governed Surface Queue Guard', + // The eighth, re-enrolled ahead of its Settings half by #17673 (ruling + // #18900 ⑤). ⚠️ This file already named it in its Post-Task Checklist + // item 4, so the literal scan is satisfied by that mention whether or + // not the merge-queue sentence above follows — see the PR for that + // residual. + 'Console Pin Gate', ], }, { @@ -593,6 +633,9 @@ export const INSTRUCTION_SURFACES = [ // array — held equal to the registry by the exclusivity rule in // judgeInstructionSurfaces — that makes the seat's copy non-optional. 'Governed Surface Queue Guard', + // The eighth, re-enrolled ahead of its Settings half by #17673 (ruling + // #18900 ⑤). + 'Console Pin Gate', ], }, { From 1638f92ccf3d349c6b199323ee0d8c5c3e39def2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:27:23 +0000 Subject: [PATCH 2/6] ci(console-pin): read the console filter on each step's own if Round 1 moved the filter verdict from the job-level if into a first step that wrote it to a step output (steps.scope.outputs.affected). That verdict is shell-computed, so no declaration reader in the repo can follow it without trusting the shell, and a pin-only diff stopped deriving check:console-sha / check:console-injection. Every step now carries the filter term in the contract's own spelling (needs.filter.outputs.console != 'false'); the one step an unaffected run executes has the exact complement and writes the NOT BUILT notice and step summary. The job-level if stays !cancelled() alone, so the job still concludes on every run. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .github/workflows/ci.yml | 108 +++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 55 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb31ff916ea..e7886087265 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2325,17 +2325,24 @@ jobs: # that evaluates false concludes `skipped`: the question this gate asks was # never asked, yet the check-run reads like a pass. Ruling ⑤ asks for a # conclusion on every PR — a `success` on an unaffected one — so the filter - # verdict moved from the job's `if:` into the first step (`scope`), and every - # step that costs anything is step-gated on it. THE FILTER CONTRACT (#4928) - # is kept in both halves, only one level down: the `scope` step treats the - # `console` output as "affected" unless it is EXACTLY 'false', so a dead - # `filter` job (output '' or the `|| 'true'` fallback) still builds, and - # `!cancelled()` still displaces the implicit success() at the job level. On - # merge_group / schedule / workflow_dispatch the output is widened to 'true' - # and the full gate runs, exactly as before. + # term moved from the job's `if:` onto EVERY STEP's own `if:`, spelled + # exactly as it was at job level: `needs.filter.outputs.console != 'false'`. + # THE FILTER CONTRACT (#4928) is kept in both halves, one level down: only an + # EXACT 'false' skips a step, so a dead `filter` job (output '') still builds, + # and `!cancelled()` still displaces the implicit success() at the job level. + # On merge_group / schedule / workflow_dispatch the output is widened to + # 'true' and the full gate runs, exactly as before. # - # An unaffected run MUST say so: the `scope` step writes a notice and a step - # summary stating that nothing was built. That `success` is the filter's + # ⛔ Not a step that computes the verdict once into a step output for the + # others to read. An `if:` is a declaration that GitHub, a reader and + # scripts/pm/dispatch-gates.mjs all read the same way — the derivation follows + # a filter term on a step `if:` when the job-level `if:` carries none (#17673) + # — whereas a verdict a shell step wrote is readable only by trusting the + # shell. With it, a pin-only diff stopped deriving check:console-sha and + # check:console-injection at all. + # + # An unaffected run MUST say so: its one running step writes a notice and a + # step summary stating that nothing was built. That `success` is the filter's # verdict, never a measured build — read it as NOT BUILT, not as a pass of the # pinned SHA. # @@ -2351,8 +2358,8 @@ jobs: name: Console Pin Gate needs: filter # See THE FILTER CONTRACT on the filter job's outputs (#4928). `console`, - # not `core` — each job keeps its own filter output, read by the `scope` - # step below rather than here (see the header: a job-level filter term + # not `core` — each job keeps its own filter output, read by each STEP's + # `if:` below rather than here (see the header: a job-level filter term # concludes `skipped`, and this job must conclude on every run). if: ${{ !cancelled() }} runs-on: ubuntu-latest @@ -2361,61 +2368,52 @@ jobs: contents: read steps: - # The filter verdict, read ONCE. Every later step is gated on - # `steps.scope.outputs.affected != 'false'` — "skip only when EXPLICITLY - # false", the contract's own spelling — so a step that forgets its guard - # costs runner time, never a false green: this step fails loudly rather - # than writing anything but 'true' or 'false'. - - name: Read the console filter verdict - id: scope + # The one step an unaffected run executes, and its only output is the + # statement that nothing else ran. Its `if:` is the exact complement of + # every other step's: `== 'false'` here, `!= 'false'` on all the rest. + - name: Report NOT BUILT when the console filter said false + if: needs.filter.outputs.console == 'false' env: - CONSOLE_OUTPUT: ${{ needs.filter.outputs.console }} - FILTER_RESULT: ${{ needs.filter.result }} EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail - echo "filter job result: ${FILTER_RESULT:-}; console output: '${CONSOLE_OUTPUT}'; event: ${EVENT_NAME}" - if [ "$CONSOLE_OUTPUT" = "false" ]; then - echo "affected=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Console Pin Gate: NOT BUILT::The console filter said false for this diff, so nothing was built or checked here. This success is the filter's verdict, not a measured build of the pinned objectui SHA. The merge-queue build widens the filter and runs the full gate." - { - echo "### Console Pin Gate — NOT BUILT on this run" - echo - echo "The \`filter\` job's \`console\` output was \`false\` (event: \`${EVENT_NAME}\`): this diff moves none of the console build inputs listed in ci.yml's \`console\` filter." - echo - echo "- ⛔ No objectui checkout, no console build, no dist restore, no \`check:console-sha\` / \`check:console-injection\` ran." - echo "- This \`success\` is the **filter's verdict**, not a measured pass of the pinned SHA — read it as NOT BUILT." - echo "- On \`merge_group\` the filter is widened to \`'true'\` and this job runs the full gate on the merged tree." - } >> "$GITHUB_STEP_SUMMARY" - else - # '' (a dead filter job) and 'true' both mean RUN — THE FILTER - # CONTRACT's half 1, restated at step level. - echo "affected=true" >> "$GITHUB_OUTPUT" - echo "Console inputs affected (or the filter could not say otherwise): running the full gate." - fi - + echo "::notice title=Console Pin Gate: NOT BUILT::The console filter said false for this diff, so nothing was built or checked here. This success is the filter's verdict, not a measured build of the pinned objectui SHA. The merge-queue build widens the filter and runs the full gate." + { + echo "### Console Pin Gate — NOT BUILT on this run" + echo + echo "The \`filter\` job's \`console\` output was \`false\` (event: \`${EVENT_NAME}\`): this diff moves none of the console build inputs listed in ci.yml's \`console\` filter." + echo + echo "- ⛔ No objectui checkout, no console build, no dist restore, no \`check:console-sha\` / \`check:console-injection\` ran." + echo "- This \`success\` is the **filter's verdict**, not a measured pass of the pinned SHA — read it as NOT BUILT." + echo "- On \`merge_group\` the filter is widened to \`'true'\` and this job runs the full gate on the merged tree." + } >> "$GITHUB_STEP_SUMMARY" + + # Every step from here on carries the filter term in the contract's own + # spelling — skip only when EXPLICITLY 'false' — so a step added without + # it costs runner time on an unaffected run (and fails without a + # checkout), never a false green. - name: Checkout repository - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' uses: actions/checkout@v7 - name: Setup Node.js - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' uses: actions/setup-node@v7 with: node-version: '22' - name: Setup pnpm - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' uses: ./.github/actions/setup-pnpm - name: Get pnpm store directory - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' uses: actions/cache@v6 with: path: ${{ env.STORE_PATH }} @@ -2429,7 +2427,7 @@ jobs: # builds a superset of the client closure needed here and is seeded from # main (same reasoning as the Temporal Conformance job's fallback). - name: Restore Turbo cache - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' uses: actions/cache/restore@v6 with: path: .turbo/cache @@ -2441,7 +2439,7 @@ jobs: ${{ runner.os }}-turbo-build-core- - name: Install dependencies - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' run: pnpm install --frozen-lockfile # build-console.sh bundles THIS tree's @objectstack/client into the console @@ -2453,7 +2451,7 @@ jobs: # script's existing callers (release.yml, showcase-smoke.yml) pre-build for # the same reason. - name: Build @objectstack/client and its dependencies - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' run: pnpm exec turbo run build --filter=@objectstack/client... --concurrency=4 # Same key as release.yml's "Cache vendored Console dist" step. Actions @@ -2470,7 +2468,7 @@ jobs: # the same guarantees as a freshly built one. - name: Restore vendored Console dist (keyed on the objectui pin) id: console-dist - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' uses: actions/cache/restore@v6 with: path: packages/console/dist @@ -2483,7 +2481,7 @@ jobs: - name: Build the Console SPA at the pinned objectui SHA # Both terms: on an unaffected run the restore above is skipped, its # cache-hit output is '' and `'' != 'true'` alone would BUILD. - if: steps.scope.outputs.affected != 'false' && steps.console-dist.outputs.cache-hit != 'true' + if: needs.filter.outputs.console != 'false' && steps.console-dist.outputs.cache-hit != 'true' run: bash scripts/build-console.sh # check:console-sha exits 0 — deliberately — when there is no dist at all @@ -2494,7 +2492,7 @@ jobs: # check with nothing to check and report success. Prove one is really in # place before letting that check speak for the job. - name: Assert a real Console dist is in place - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' env: CACHE_HIT: ${{ steps.console-dist.outputs.cache-hit }} run: | @@ -2511,7 +2509,7 @@ jobs: echo "✓ Console dist present ($(du -sk "$dist" | awk '{print $1}') KB, via $origin) from objectui@$(cut -c1-12 "$dist/.objectui-sha")" - name: Verify the Console dist stamp matches the pin - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' run: pnpm check:console-sha # The spec-injection half of the same question, and the reason it is a @@ -2540,7 +2538,7 @@ jobs: # whose post-step saves even on failure, under this same repo-scoped key — so # a poisoned entry is reachable from here and must not pass quietly. - name: Verify the restored Console dist bundles this tree's spec - if: steps.scope.outputs.affected != 'false' + if: needs.filter.outputs.console != 'false' env: CONSOLE_DIST_CACHE_KEY: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }} run: pnpm check:console-injection --require-stamp @@ -2551,7 +2549,7 @@ jobs: # key moves only when the pin does. A pin-bump PR writes one entry, and the # rest of that PR's pushes reuse it instead of rebuilding the console again. - name: Save vendored Console dist - if: steps.scope.outputs.affected != 'false' && steps.console-dist.outputs.cache-hit != 'true' + if: needs.filter.outputs.console != 'false' && steps.console-dist.outputs.cache-hit != 'true' uses: actions/cache/save@v6 with: path: packages/console/dist From f43415dee1474b76773e04ef91d10b8d28d3e6a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:30:00 +0000 Subject: [PATCH 3/6] fix(dispatch-gates): follow a filter term carried on a job's steps A job that must conclude on every run cannot carry its filter term in its job-level if (a false job if concludes skipped), so it carries it on each step instead. Read only at job level, such a job resolved to no population, and a pin-only diff derived neither check:console-sha nor check:console-injection. stepFilterPopulations reads a job whose own if is absent or !cancelled() alone step by step: each step whose if passes the same job-level whitelist (jobFilterOutputRefs, unchanged) contributes that population to the checks that step runs. The job-filtered tail walks the gated steps plus the job's other conditional ones, so a step whose if restates the population's own term is not counted as conditional, and the rest are still sized. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- scripts/pm/dispatch-gates.mjs | 116 +++++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 9 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 6bd330b6ddc..52f70b69293 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -1132,8 +1132,9 @@ export function extractTriggerPaths(workflowText) { * ## What this closes, and what it deliberately does NOT * * It closes the INDIRECTION: a declared path population the tool could not - * follow. It does not close, and cannot, the gate that declares no population - * ON PURPOSE. `check:objectui-pin-citations` is unfiltered because `lint.yml` + * follow — and, since #17673, the same term carried on a job's STEPS when its + * own `if:` must stay free of it (`stepFilterPopulations`). It does not close, + * and cannot, the gate that declares no population ON PURPOSE. `check:objectui-pin-citations` is unfiltered because `lint.yml` * says a `packages/spec/**` filter "would go dormant on exactly the PR that * moves `.objectui-sha`, which is the PR this exists to catch" — the * correctness requirement and the derivability requirement are in direct @@ -1412,6 +1413,74 @@ export function jobFilterPopulation(job, filterStepsByOutputSource) { return paths.length ? { paths, outputs, dropped } : null; } +/** + * ── The same filter term, one level down: on the STEPS' `if:`s (#17673) ───── + * + * A job-level `if:` that evaluates false concludes `skipped`, which branch + * protection counts as passing, so a job that must CONCLUDE on every run (the + * `Console Pin Gate` of ruling #18900 ⑤) cannot carry its filter term there. + * It carries it on each step instead — `if: needs.filter.outputs.console != + * 'false'` — and the job's own `if:` is left with `!cancelled()` alone. Read + * only at job level, that job resolves to nothing, and a pin-only diff + * derived neither console gate: the #12956 blind spot, reopened one level + * down. + * + * The rule, and all of it: a job whose own `if:` discriminates NO path — + * absent, or `!cancelled()` alone — is read step by step, and each step whose + * OWN `if:` passes the job-level whitelist above (`jobFilterOutputRefs`, + * unchanged: an OR of filter-output comparisons, `!cancelled()` stripped, + * everything else refused whole) contributes that population to the checks + * THAT STEP runs. Steps resolving to the same outputs share one population. + * + * What it deliberately does NOT read, each in the safe direction: + * + * - a job-level `if:` that carries ANY other term: a step under it would + * inherit a condition this does not evaluate (a push-only job would be + * claimed for every PR), so the job contributes nothing at step level; + * - a step whose `if:` the whitelist refuses — an AND with a cache-hit + * output, an inverted `== 'false'`: its checks gain no population; + * - a step carrying no `if:` in such a job: it runs on every PR, which is + * not a claim about YOUR paths, so it is not in the population either; + * - a verdict a shell step computed into a step OUTPUT for later steps to + * read (`steps..outputs.`): following it would mean trusting what + * the shell wrote, the fabrication the header of this section refuses. + * + * Two texts, one per caller, both built from the job's own `steps:` line: + * `text` holds the gated steps ONLY, so the family derivation reads the checks + * the population schedules and nothing else in the job; `walkText` adds every + * other step that carries an `if:`, so the job-filtered tail still SIZES the + * steps it makes no claim about (a cache-hit AND, the inverted NOT BUILT + * step) instead of losing them. Neither holds a step with no `if:`: in a job + * that concludes on every run, that step runs on every PR, and "your path + * scheduled it" would be false of it. + */ +export function stepFilterPopulations(job, filterStepsByOutputSource) { + let jobIf = typeof job.if === 'string' ? job.if.trim() : ''; + const wrapped = /^\$\{\{([\s\S]*)\}\}$/.exec(jobIf); + if (wrapped) jobIf = wrapped[1].trim(); + if (jobIf !== '' && !/^!\s*cancelled\(\)$/.test(jobIf)) return []; + const stepsLine = String(job.text ?? '').split('\n').find((line) => /^[ \t]*steps:\s*$/.test(line)); + if (stepsLine === undefined) return []; + const steps = extractStepBlocks(job.text); + const byOutputs = new Map(); + for (const step of steps) { + const population = jobFilterPopulation({ if: step.if }, filterStepsByOutputSource); + if (!population) continue; + const key = population.outputs.join('|'); + if (!byOutputs.has(key)) byOutputs.set(key, population); + } + return [...byOutputs.values()].map((population) => { + const gated = steps.filter((s) => stepIfIsPopulationTerm(s.if, population.outputs)).map((s) => s.text); + const conditional = steps.filter((s) => s.if).map((s) => s.text); + return { + ...population, + gate: 'step', + text: [stepsLine, ...gated].join('\n'), + walkText: [stepsLine, ...conditional].join('\n'), + }; + }); +} + /** * Every job in one workflow whose `if:` resolves to a path population, WITH the * job's own text: `[{ job, name, text, outputs, paths, dropped }]`. @@ -1442,8 +1511,15 @@ export function jobFilterPopulations(workflowText) { const out = []; for (const job of jobs) { const population = jobFilterPopulation(job, byOutput); - if (!population) continue; - out.push({ job: job.id, name: job.name, text: job.text, ...population }); + if (population) { + out.push({ job: job.id, name: job.name, text: job.text, ...population }); + continue; + } + // A job whose filter term sits on its steps (#17673) — see + // `stepFilterPopulations` for the rule and what it refuses. + for (const stepPopulation of stepFilterPopulations(job, byOutput)) { + out.push({ job: job.id, name: job.name, ...stepPopulation }); + } } return out; } @@ -2793,7 +2869,9 @@ export function alwaysRunSteps(entries) { * per-card claim is available; * - a step carrying an `if:`: counted and RETURNED, never silently dropped — * the claim is "CI runs this for your path", and a condition this walk did - * not evaluate could falsify it, the same standard the tail holds; + * not evaluate could falsify it, the same standard the tail holds. The one + * `if:` it does evaluate is the population's OWN filter term restated on + * the step (`stepIfIsPopulationTerm`, #17673): that adds no condition; * - a step the family derivation DOES name a check family for: it is already * in the matched block above, and a row here would double-count it. The two * halves are disjoint by construction, which is what makes either count @@ -2824,7 +2902,10 @@ export function jobFilteredSteps(entries, paths) { if (hits.length === 0) continue; counts.covering += 1; const steps = []; - for (const step of extractStepBlocks(job.text)) { + // A step-level population (#17673) hands this walk its `walkText`: the + // gated steps plus the job's other conditional ones, so those are sized + // below rather than lost. + for (const step of extractStepBlocks(job.walkText ?? job.text)) { // Continuations are SPLICED before the split, so a row is the command // the shell sees rather than a fragment of its argv. It is the same // reading `extractCheckInvocations` takes for the same body, and here @@ -2841,7 +2922,10 @@ export function jobFilteredSteps(entries, paths) { .map((l) => l.trim()) .filter((l) => l !== ''); if (commands.length === 0) continue; - if (step.if) { + // A step whose `if:` IS the population's own filter term adds no + // condition to it — the shape of a job that carries its filter on its + // steps (#17673, `stepFilterPopulations`), where every step does. + if (step.if && !stepIfIsPopulationTerm(step.if, job.outputs)) { counts.conditionalSteps += 1; continue; } @@ -2855,12 +2939,26 @@ export function jobFilteredSteps(entries, paths) { } if (steps.length === 0) continue; counts.named += 1; - rows.push({ workflow: file, job: job.name, outputs: job.outputs, hits, dropped: job.dropped, steps }); + rows.push({ workflow: file, job: job.name, outputs: job.outputs, gate: job.gate ?? 'job', hits, dropped: job.dropped, steps }); } } return { rows, counts }; } +/** + * Does a step's `if:` resolve, under the job-level whitelist, to EXACTLY the + * filter outputs a population was read from? Then it restates the condition + * the population already carries and adds none; anything else — another + * output, an AND, an unreadable term — is a condition this walk did not + * evaluate, and the step stays conditional. + */ +export function stepIfIsPopulationTerm(stepIf, outputs) { + const refs = jobFilterOutputRefs(stepIf); + if (!refs || !Array.isArray(outputs)) return false; + const own = [...new Set(refs.map((r) => `${r.job}.${r.output}`))]; + return own.length === outputs.length && own.every((o) => outputs.includes(o)); +} + /** * Does this `run:` line invoke a TypeScript type-check PROGRAM? * @@ -11956,7 +12054,7 @@ export function jobFilteredStepLines(rows, counts) { const via = row.hits.map((h) => `${h.path} ⇢ '${h.pattern}'`).join('; '); const dropped = row.dropped ? `, ${row.dropped} glob(s) of it untranslatable and dropped` : ''; lines.push( - ` - [${row.workflow} · ${row.job}] scheduled by ${via} (job \`if:\` reads ${row.outputs.join(', ')}${dropped})`, + ` - [${row.workflow} · ${row.job}] scheduled by ${via} (${row.gate === 'step' ? 'its steps\' `if:`s read' : 'job `if:` reads'} ${row.outputs.join(', ')}${dropped})`, ); for (const { step, commands } of row.steps) { lines.push(` · ${step}`); From f59f2b8c60c9aaafd501f7569f090cc74d416cb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:31:21 +0000 Subject: [PATCH 4/6] test(dispatch-gates): pin the step-level filter shape, fixture and live Fixture cases for a job whose filter term sits on its steps: the population resolves and reaches only the gated step's check; a step with no if, an inverted step, an AND with a refused term and a job-level if carrying another term are all refused; the job-filtered tail lists a step that restates the population term and sizes the rest. A live case pins that the Console Pin Gate population is read off its steps. The four live console cases are unchanged. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- scripts/pm/dispatch-gates.mjs | 98 ++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 52f70b69293..fb228641be5 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -1530,7 +1530,7 @@ export function jobFilterPopulations(workflowText) { */ export function jobPathPopulations(workflowText, workflowFile) { const out = []; - for (const { text, ...population } of jobFilterPopulations(workflowText)) { + for (const { text, walkText, ...population } of jobFilterPopulations(workflowText)) { const checks = [...new Set(extractCheckInvocations(text, workflowFile).map((i) => i.check))]; if (!checks.length) continue; out.push({ ...population, checks }); @@ -21238,6 +21238,95 @@ function selfTest() { jobPathPopulations("on:\n pull_request:\n branches: [main]\njobs:\n a:\n steps:\n - run: pnpm check:x\n", 'x.yml').length === 0, ); + // ── The same term on a job's STEPS (#17673) ──────────────────────────────── + // + // A job that must CONCLUDE on every run keeps `!cancelled()` alone at job + // level and carries its filter term on each step. Every case here is red on + // the job-level-only derivation, where the console job below resolves to + // nothing; the refusals are pinned beside it because they are the half that + // keeps the step-level reading from claiming a step your path never ran. + const stepGatedWf = [ + 'name: Fixture', + 'on:', + ' pull_request:', + ' branches: [main]', + 'jobs:', + ' filter:', + ' runs-on: ubuntu-latest', + ' outputs:', + " console: ${{ steps.changes.outputs.console || 'true' }}", + ' steps:', + ' - uses: dorny/paths-filter@v4', + ' id: changes', + ' with:', + ' filters: |', + ' console:', + " - '.objectui-sha'", + ' console-pin:', + ' name: Console Pin Gate', + ' needs: filter', + ' if: ${{ !cancelled() }}', + ' steps:', + ' - name: Report NOT BUILT', + " if: needs.filter.outputs.console == 'false'", + ' run: pnpm check:inverted', + ' - name: Every run', + ' run: pnpm check:every-run', + ' - name: Gated', + " if: needs.filter.outputs.console != 'false'", + ' run: pnpm check:console-sha', + ' - name: Gated and cache-conditional', + " if: needs.filter.outputs.console != 'false' && steps.cache.outputs.cache-hit != 'true'", + ' run: pnpm check:anded', + ' - name: Gated setup', + " if: needs.filter.outputs.console != 'false'", + ' run: pnpm install --frozen-lockfile', + ' push-only:', + ' name: Push Only', + " if: github.event_name == 'push'", + ' steps:', + ' - name: Gated under a job condition this does not evaluate', + " if: needs.filter.outputs.console != 'false'", + ' run: pnpm check:push-only', + '', + ].join('\n'); + const stepPops = jobPathPopulations(stepGatedWf, 'fixture.yml'); + const stepPin = stepPops.find((p) => p.job === 'console-pin'); + t( + '⭐ STEP-LEVEL (#17673): a job whose filter term sits on its steps, not its own if:, resolves to that population', + Boolean(stepPin) && stepPin.paths.join('|') === '.objectui-sha' && stepPin.outputs.join('|') === 'filter.console', + JSON.stringify(stepPops), + ); + t('…and attributes it to the check its GATED step runs, and to nothing else', stepPin?.checks.join('|') === 'check:console-sha'); + t('…never to a step with no if: — in a job that concludes on every run, that step runs whatever your path is', Boolean(stepPin) && !stepPin.checks.includes('check:every-run')); + t('…never to the inverted step — it runs when the filter said FALSE', Boolean(stepPin) && !stepPin.checks.includes('check:inverted')); + t('…never to a step whose if: ANDs a term the whitelist refuses', Boolean(stepPin) && !stepPin.checks.includes('check:anded')); + t( + 'a job-level if: carrying any OTHER term contributes nothing at step level — a push-only job is not claimed for a PR', + !stepPops.some((p) => p.job === 'push-only') + && stepFilterPopulations(extractJobBlocks(stepGatedWf).find((j) => j.id === 'push-only') ?? {}, new Map([['filter.console', ['.objectui-sha']]])).length === 0, + ); + t( + 'a job-level population is untouched by the step-level reading — the #12956 fixture still resolves exactly as before', + fixturePops.map((p) => `${p.job}:${p.gate ?? 'job'}`).join('|') === 'console-pin:job|both:job', + ); + const stepTail = jobFilteredSteps([{ file: 'fixture.yml', text: stepGatedWf }], ['.objectui-sha']); + const stepTailRow = stepTail.rows.find((r) => r.job === 'Console Pin Gate') ?? { steps: [], gate: null }; + t('TAIL: a step whose if: restates the population term adds no condition — its unaccounted setup step is listed', stepTailRow.steps.some((s) => s.step === 'Gated setup')); + t('TAIL: the steps it makes no claim about are SIZED as conditional, not lost (the inverted one and the AND)', stepTail.counts.conditionalSteps === 2, JSON.stringify(stepTail.counts)); + t('TAIL: the every-run step is neither listed nor counted — no path of yours scheduled it', !stepTailRow.steps.some((s) => s.step === 'Every run') && stepTail.counts.steps === 2); + t( + "TAIL: the row says its STEPS' if:s read the output, not a job if: it no longer carries", + stepTailRow.gate === 'step' && jobFilteredStepLines(stepTail.rows, stepTail.counts).some((l) => l.includes("its steps' `if:`s read filter.console")), + ); + t( + 'stepIfIsPopulationTerm: the same term restates the population; another output, an AND or no if: does not', + stepIfIsPopulationTerm("needs.filter.outputs.console != 'false'", ['filter.console']) + && !stepIfIsPopulationTerm("needs.filter.outputs.core != 'false'", ['filter.console']) + && !stepIfIsPopulationTerm("needs.filter.outputs.console != 'false' && github.event_name == 'push'", ['filter.console']) + && !stepIfIsPopulationTerm(null, ['filter.console']), + ); + // Matching, and the precedence question the new key raises. A job filter is a // DECLARATION CI obeys, so it outranks a literal scanned out of a script and // sits under the workflow trigger, which decides whether the job runs at all. @@ -21284,6 +21373,13 @@ function selfTest() { 'both console gates are reached, not just the one the card named', Boolean(livePinJob?.checks.includes('check:console-sha') && livePinJob?.checks.includes('check:console-injection')), ); + // The live SHAPE those four now hold through (#17673): the job concludes on + // every run, so its own `if:` names no filter output and the population above + // comes from its steps. Red on the job-level-only derivation. + t( + "⭐ LIVE (#17673): the console job's population is read off its STEPS — its own if: carries no filter term, so it concludes on every run", + jobFilterPopulations(readFileSync(nodePath.join(ROOT, '.github/workflows/ci.yml'), 'utf8')).find((p) => p.name === 'Console Pin Gate')?.gate === 'step', + ); // The NEGATIVE control, and it is the half that keeps the widening honest: a // path in none of the four filters must derive nothing extra. AGENTS.md is a // repo-root file no filter names. From d37dd144093753e7abe10989b53b5cfb82b25e1c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:33:24 +0000 Subject: [PATCH 5/6] fix(pm/expected-skips): Console Pin Gate leaves the skip-by-design roster Its job now concludes on every run (own if: !cancelled() alone, the console filter term on each step), so a skipped Console Pin Gate is no longer by design and reads as exit 4 like any other unrostered name. The measured 39-run head stays verbatim: its skipped Console Pin Gate is now the one unexpected skip (18 expected, exit 4). A derived copy with that one row concluding success carries the OK-path cases and the CLI exit-0 case. Two roster cases pin the absence and tie it to the live job's own if: carrying no filter output. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- scripts/pm/check-expected-skips.mjs | 74 +++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/scripts/pm/check-expected-skips.mjs b/scripts/pm/check-expected-skips.mjs index 775cc9f4acc..8b5656f967c 100644 --- a/scripts/pm/check-expected-skips.mjs +++ b/scripts/pm/check-expected-skips.mjs @@ -216,8 +216,9 @@ const CORE_REASON = * the gate kind (pinned against the live `if:` by `--self-test`) and a one-line * reason a seat can read at enqueue time. * - * Measured, not remembered: the eleven names below are the union of every - * `skipped` name over the ten landed heads the header lists. Five of them + * Measured, not remembered: the roster was eleven names, the union of every + * `skipped` name over the ten landed heads the header lists — ten below since + * `Console Pin Gate` left it (see the end of this block). Five of them * (`Build Core`, `Temporal Conformance`, both matrix templates, `Dogfood Verify * CLI`) skipped on ALL ten; `Build Docs` and `Console Pin Gate` on all ten; * `Check Changeset` on all ten (every one carried `skip-changeset`); @@ -229,6 +230,12 @@ const CORE_REASON = * ⛔ Adding a row is a declaration that the skip is BY DESIGN, and the row must * name the mechanism — a row added to silence an exit 4 without one is the * finding written somewhere quieter. + * + * `Console Pin Gate` LEFT the roster (#17673, ruling #18900 ⑤): its job now + * concludes on every run — its own `if:` is `!cancelled()` alone and the + * `console` filter term sits on each step — so an unaffected PR reads + * `success` with a NOT BUILT notice, and a skipped `Console Pin Gate` is no + * longer by design. It is exit 4, like any other name outside the roster. */ export const EXPECTED_SKIPS = Object.freeze([ { @@ -303,13 +310,6 @@ export const EXPECTED_SKIPS = Object.freeze([ gate: { kind: 'filter-output', outputs: ['docs'] }, reason: "gated on ci.yml's `filter` job `docs` output (apps/docs, content, the lockfile, ci.yml itself); a diff outside it skips the docs build on the PR head", }, - { - name: 'Console Pin Gate', - 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", - }, ]); /** Conclusions that make a same-suite skip a dependency-skip suspect. */ @@ -837,6 +837,12 @@ function fixtureRun(name, conclusion, suite = 1, extra = {}) { * on one head. Names, conclusions and check-suite ids VERBATIM from the * listing. This is the real shape the roster must accept, kept here so the * measured family and the declared family cannot drift apart unnoticed. + * + * ⚠️ It predates #17673, so ONE of its 19 skips — `Console Pin Gate` — is a + * finding under today's roster, and the cases below say so rather than edit + * a measurement. What the same head reads NOW is `CONCLUDING_18315`: that one + * row as the job concludes on an unaffected run since #17673 (`success`, with + * a NOT BUILT notice), every other row untouched — DERIVED, not measured. */ const MEASURED_18315 = [ ['Auto Label', 'skipped', 94769348293], @@ -880,8 +886,12 @@ const MEASURED_18315 = [ ['TypeScript Type Check', 'success', 94769267736], ]; -function measuredPayload() { - return { total_count: MEASURED_18315.length, check_runs: MEASURED_18315.map(([name, conclusion, suite]) => fixtureRun(name, conclusion, suite)) }; +const CONCLUDING_18315 = MEASURED_18315.map(([name, conclusion, suite]) => + name === 'Console Pin Gate' ? [name, 'success', suite] : [name, conclusion, suite], +); + +function measuredPayload(rows = MEASURED_18315) { + return { total_count: rows.length, check_runs: rows.map(([name, conclusion, suite]) => fixtureRun(name, conclusion, suite)) }; } /** Spawn this file's CLI on a payload file, offline; returns { status, stdout, stderr }. */ @@ -915,6 +925,19 @@ export function selfTest() { t('roster: a never-skipping required context is NOT in it (Lint & Repo Gates)', EXPECTED_SKIPS.some((r) => r.name === 'Lint & Repo Gates'), false); t('roster: …nor the ci.yml aggregates, which run if: always()', EXPECTED_SKIPS.some((r) => r.name === 'Test Core' || r.name === 'Dogfood Regression Gate'), false); t('roster: …nor the queue guard', EXPECTED_SKIPS.some((r) => r.name === 'Governed Surface Queue Guard'), false); + // #17673: the console gate concludes on every run, so its skip is no longer + // by design. Tied to the tree, not just to the array: the live job's own + // `if:` must carry no filter term — a job that could skip by design again + // belongs back in the roster, and this case reds first. + t('roster: …nor Console Pin Gate, which concludes on every run since #17673', EXPECTED_SKIPS.some((r) => r.name === 'Console Pin Gate'), false); + t( + "roster: …and the live console-pin job's own if: reads no filter output, so it cannot skip by design", + (() => { + const job = readLive('ci.yml')?.jobs?.['console-pin']; + return Boolean(job) && job.name === 'Console Pin Gate' && typeof job.if === 'string' && !job.if.includes('needs.filter.outputs'); + })(), + true, + ); // The audit must go RED on synthetic drift, in every direction it claims to see. const liveCi = readLive('ci.yml'); @@ -937,15 +960,27 @@ export function selfTest() { t('audit: a clean roster over the live tree yields zero findings for the label rows too', auditRoster(EXPECTED_SKIPS.filter((r) => r.gate.kind === 'label'), readLive).length, 0); // ---- the judge ------------------------------------------------------------- + // The measured head, VERBATIM: every one of its 19 skips was expected when it + // was read, and one of them is a finding now (#17673). const measured = judgeCheckRuns(measuredPayload()); t('measured head: judged (no pending run)', measured.kind, 'judged'); t('measured head: 20 success', measured.success, 20); - t('measured head: 19 skipped, every one expected', measured.expected.reduce((n, e) => n + e.count, 0), 19); - t('measured head: zero unexpected skips', measured.unexpected.length, 0); - t('measured head: eleven of eleven rostered names appear', measured.expected.length, 11); - t('measured head: exit 0', verdictExit(measured), 0); + t('measured head: 19 skipped, 18 of them expected', measured.expected.reduce((n, e) => n + e.count, 0), 18); + t('measured head: its skipped Console Pin Gate is now the one UNEXPECTED skip (#17673)', measured.unexpected.map((u) => u.name).join('|'), 'Console Pin Gate'); + t('measured head: …classified as a filter miss (no failure in its suite)', measured.unexpected[0]?.classification.kind, 'filter-miss'); + t('measured head: ten of ten rostered names appear', measured.expected.length, 10); + t('measured head: exit 4', verdictExit(measured), 4); t('measured head: the multiplicity is reported (Auto Label ×3)', measured.expected.find((e) => e.name === 'Auto Label')?.count, 3); - t('measured head: the report ends on the OK verdict line', renderReport(measured, { head: 'b671f83b', repo: 'objectstack-ai/objectstack' }).at(-1).startsWith('VERDICT check-expected-skips: OK'), true); + t('measured head: the report ends on the exit-4 verdict line', renderReport(measured, { head: 'b671f83b', repo: 'objectstack-ai/objectstack' }).at(-1).includes('(exit 4)'), true); + + // The same head as it reads since #17673 — `Console Pin Gate` concluding + // `success` on an unaffected run, every other row verbatim. + const concluding = judgeCheckRuns(measuredPayload(CONCLUDING_18315)); + t('concluding head: 21 success', concluding.success, 21); + t('concluding head: 18 skipped, every one expected', concluding.expected.reduce((n, e) => n + e.count, 0), 18); + t('concluding head: zero unexpected skips', concluding.unexpected.length, 0); + t('concluding head: exit 0', verdictExit(concluding), 0); + t('concluding head: the report ends on the OK verdict line', renderReport(concluding, { head: 'b671f83b', repo: 'objectstack-ai/objectstack' }).at(-1).startsWith('VERDICT check-expected-skips: OK'), true); const oneExpected = judgeCheckRuns({ total_count: 2, check_runs: [fixtureRun('Lint & Repo Gates', 'success'), fixtureRun('Build Core', 'skipped')] }); t('fixture: one expected skip → exit 0', verdictExit(oneExpected), 0); @@ -1014,9 +1049,9 @@ export function selfTest() { const dir = mkdtempSync(join(tmpdir(), 'check-expected-skips-')); try { const okFile = join(dir, 'ok.json'); - writeFileSync(okFile, JSON.stringify({ head_sha: 'b671f83b', ...measuredPayload() })); + writeFileSync(okFile, JSON.stringify({ head_sha: 'b671f83b', ...measuredPayload(CONCLUDING_18315) })); const ok = spawnSelf(['--check-runs-json', okFile]); - t('cli: the measured head on disk → exit 0', ok.status, 0); + t('cli: the concluding head on disk → exit 0', ok.status, 0); t('cli: …with the OK verdict line on stdout', ok.stdout.includes('VERDICT check-expected-skips: OK'), true); t('cli: …naming the head it read from the payload', ok.stdout.includes('head b671f83b'), true); @@ -1085,7 +1120,8 @@ export function selfTest() { console.log( `✓ check-expected-skips self-test: ${cases.length} cases pass (the exit register; the roster's shape — reasons, no duplicates, ` + 'declared gate kinds — and its truth on this checkout\'s workflows, with the audit driven red on a deleted, renamed, un-gated and ' + - "re-gated job, a lost || 'true' widening and an unreadable workflow; the judge on the measured 39-run head and on fixtures for an " + + "re-gated job, a lost || 'true' widening and an unreadable workflow; the judge on the measured 39-run head (its Console Pin Gate " + + 'skip a finding since #17673) and on that head as it concludes today, and on fixtures for an ' + 'expected skip, an unexpected skip named as a filter miss, a same-suite failure read as a dependency skip, a raw matrix template, ' + 'other conclusions, a pending run and an empty head; read classification for 422 / 404 / 401 / 403 / 5xx / network; argv; the real ' + 'CLI on payload files for 0 / 4 / 3 and --json; and the structural no-write-path, single-sourced transport and SKILL.md pointer pins).', From d134f4cd0e874e3e921f133318ab63b4c17fc6a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:35:38 +0000 Subject: [PATCH 6/6] docs(required-set): name the registered eighth context, true on both sides of the click AGENTS.md's merge-queue sentence and the pm-dispatch readings ledger both state the required set. Console Pin Gate is registered in REQUIRED_CONTEXTS and blocks only once the maintainer adds it in Settings, so both now say seven block and an eighth joins on that click; "those seven" becomes "those eight" where the sentence means the names the registry pins. Both files stay at their line ceilings: the new words fill slack in an existing line and no text moved between lines. check-required-contexts: two comments re-worded for the step-level filter term and for the whole-file literal-scan residual. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../pm-dispatch/references/platform-readings.md | 2 +- AGENTS.md | 6 +++--- scripts/check-required-contexts.mjs | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/platform-readings.md b/.claude/skills/pm-dispatch/references/platform-readings.md index 685fd89cb41..f043bc17bd8 100644 --- a/.claude/skills/pm-dispatch/references/platform-readings.md +++ b/.claude/skills/pm-dispatch/references/platform-readings.md @@ -395,7 +395,7 @@ - 本车道强制多是过宽的回忆:`dispatch-gates.mjs --tier PATH` 逐路径现推,路径线是下限非放行。 - 该脚本只答自己那棵树:姊妹仓路径回 absent from this tree,姊妹仓的档位与条款②只能手推。 - `os-verify-lock.sh` 只住 objectstack:objectui 无它,跑本仓副本读的是容器级锁,非该仓深度。 -- required checks 的名单是每仓事实,objectstack 七个: +- required checks 的名单是每仓事实,objectstack 七个(`Console Pin Gate` 已登记,入 Settings 即第八): `TypeScript Type Check` · `Lint & Repo Gates` · `Test Core` · `Dogfood Regression Gate` · `Build Core` · `Temporal Conformance (live PG + MySQL)` · `Governed Surface Queue Guard`。 - ⭐ 本表的 `mustName` 声明 `statesTheSet` ⇒ 必须逐字等于注册表,加注册行不跟改本表即红。 diff --git a/AGENTS.md b/AGENTS.md index b2721b2cc4e..c8592c6610e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -513,12 +513,12 @@ Even inside your own worktree, operate defensively: yet"; `in_progress` is not a pass. Arming a red PR does not queue it, it hides it: every poll then misreads "not on `main` yet" as "queued". Always read *two* things: the queue branch **and** `origin/main`. And **the queue enforces only the required - set** — seven contexts block: `Lint & Repo Gates` (all `check:*` gates), + set** — seven block (eight once Settings adds `Console Pin Gate`): `Lint & Repo Gates` (all `check:*` gates), `TypeScript Type Check`, `Test Core`, `Dogfood Regression Gate`, `Build Core`, `Temporal Conformance (live PG + MySQL)` and `Governed Surface Queue Guard`. A check outside - those seven is advisory and rides through, and an advisory red that lands rides `main`'s + those eight is advisory and rides through, and an advisory red that lands rides `main`'s merge ref into every later PR until stanched. A required context is matched by check-run - name, so a rename detaches its gate silently — treat those seven names as contract. + name, so a rename detaches its gate silently — treat those eight names as contract. **Re-arm awareness** — none of these is a reason to avoid the queue; all are reasons to confirm a PR is still *in* it: a red queue build **ejects** your entry and drops diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 78fea5b66bc..767b46ce8ed 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -166,10 +166,10 @@ * which is why assertion 7 above judges the trigger and not the job. Asserting * "no `if:`" uniformly would have made this gate red on `main` the day it * landed, against four jobs that are correct. (`console-pin` has since moved - * its filter term from the job's `if:` into a first step, so it concludes on - * every run rather than reading `skipped` — #17673, ruling #18900 ⑤; see its - * registry row. That is a property of that one job, not a rule this pin - * asserts of the others.) + * its filter term from the job's `if:` onto each of its steps' `if:`s, so it + * concludes on every run rather than reading `skipped` — #17673, ruling #18900 + * ⑤; see its registry row. That is a property of that one job, not a rule this + * pin asserts of the others.) * * The two aggregate gates are a further special case — they need `if: always()` * for a reason of their own (#3622: a gate that skips because a dependency @@ -598,10 +598,10 @@ export const INSTRUCTION_SURFACES = [ 'Temporal Conformance (live PG + MySQL)', 'Governed Surface Queue Guard', // The eighth, re-enrolled ahead of its Settings half by #17673 (ruling - // #18900 ⑤). ⚠️ This file already named it in its Post-Task Checklist - // item 4, so the literal scan is satisfied by that mention whether or - // not the merge-queue sentence above follows — see the PR for that - // residual. + // #18900 ⑤); the merge-queue sentence names it. ⚠️ Residual: this file + // also names it in its Post-Task Checklist item 4, and the literal scan + // is a whole-file `includes`, so that mention alone would satisfy it — + // the sentence following is held by review, not by this array. 'Console Pin Gate', ], },