From ab6fc3e54604a3977ef9869329793cf5b34a0e3d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:13:14 +0000 Subject: [PATCH 1/5] ci(ci): the dogfood, temporal and console-pin legs run in the full battery only `Temporal Conformance (live PG + MySQL)`, the three `Dogfood Regression Gate` shards, `Dogfood Verify CLI` and `Console Pin Gate` gain one event clause in their `if:` (`github.event_name != 'pull_request' && github.event_name != 'merge_group'`), so a PR or queue build carries Build Core, Test Core and Build Docs, while main's push, hourly schedule and dispatch runs carry the full battery. The `on:` block is unchanged; `dogfood-gate` keeps `if: always()` and counts zero expected attestations when both legs skip. check-expected-skips' roster reasons for the four rows now say why they skip on every PR head (the event), instead of promising a queue-build run. Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB Co-authored-by: Claude --- .github/workflows/ci.yml | 82 ++++++++++++++++++++--------- scripts/pm/check-expected-skips.mjs | 14 +++-- 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bba0be0dc6..c8d0168a7ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,28 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +# ── THE TWO SPEEDS (#22998) ────────────────────────────────────────────────── +# A `pull_request` or `merge_group` run carries the FAST SET: `Build Core`, +# `Test Core` (affected-only) and `Build Docs`. `main`'s hourly `schedule` run, +# its `push` runs and an on-demand `workflow_dispatch` carry the FULL BATTERY: +# the fast set plus the four jobs below that spell +# +# github.event_name != 'pull_request' && github.event_name != 'merge_group' +# +# in their `if:` — `temporal-conformance`, `dogfood` (three shards), +# `dogfood-verify` and `console-pin`. Nothing is deleted: the jobs moved off the +# per-PR path. A red in one of them is a main-red for the owning lane, and the +# publish lane reads the full battery before its approval (release.yml's +# `full-battery` job, `scripts/ci/scheduled-full-run.mjs --release-gate`). +# +# Spelled as a DENY-list of the two per-PR events on purpose — the filter +# contract's half 1 below, applied to events: a trigger added to `on:` later +# RUNS these jobs, and the failure direction of a wrong guess is a slower PR, +# never a full battery that silently stopped running. `dogfood-gate` keeps +# `if: always()` (check:shard-attestation pins it): with both legs skipped it +# counts zero expected attestations and concludes success, exactly as on a +# filter-skipped diff. + jobs: filter: runs-on: ubuntu-latest @@ -276,9 +298,10 @@ jobs: # 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. + # on the push that lands it (the job is full-battery only since #22998; + # see THE TWO SPEEDS above). #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). @@ -1564,15 +1587,16 @@ jobs: # The job also carries the non-SQL half of that axis (core, formula, # driver-memory, driver-mongodb, service-analytics) — the other backends the # temporal conformance matrix holds to one standard. Its NAME still says - # "live PG + MySQL" on purpose: the name IS the required check, so renaming - # it would silently drop the gate wherever branch protection lists it — the - # same trap the dogfood shards note below. `check:required-contexts` pins - # this literal, parenthetical and all (#6865). + # "live PG + MySQL" on purpose: it was a required check until #22998 moved + # the job to the full battery, and its readers (the hourly run's red card, + # merge-queue triage history, every instruction that names it) find it by + # that literal — renaming it is a contract change, not a tidy-up. temporal-conformance: name: Temporal Conformance (live PG + MySQL) needs: filter - # See THE FILTER CONTRACT on the filter job's outputs (#4928). - if: ${{ !cancelled() && needs.filter.outputs.core != 'false' }} + # See THE FILTER CONTRACT on the filter job's outputs (#4928). Full battery + # only: see THE TWO SPEEDS above `jobs:` (#22998). + if: ${{ !cancelled() && needs.filter.outputs.core != 'false' && github.event_name != 'pull_request' && github.event_name != 'merge_group' }} runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -1904,13 +1928,14 @@ jobs: # own in-process app; a single 4-vCPU runner needed ~7½ minutes for the # lot, and at 2 shards each half still ran ~7 min — the longest pole left # once Test Core went 3-way (#4859). vitest partitions the file list - # deterministically across shards; all shards must pass. Branch protection - # requires only the bare "Dogfood Regression Gate" context, carried by the - # dogfood-gate job below — the shard count can change without touching it. + # deterministically across shards; all shards must pass. The bare + # "Dogfood Regression Gate" context is carried by the dogfood-gate job + # below — the shard count can change without touching it. name: Dogfood Regression Gate (${{ matrix.shard }}/3) needs: filter - # See THE FILTER CONTRACT on the filter job's outputs (#4928). - if: ${{ !cancelled() && needs.filter.outputs.core != 'false' }} + # See THE FILTER CONTRACT on the filter job's outputs (#4928). Full battery + # only: see THE TWO SPEEDS above `jobs:` (#22998). + if: ${{ !cancelled() && needs.filter.outputs.core != 'false' && github.event_name != 'pull_request' && github.event_name != 'merge_group' }} runs-on: ubuntu-latest # Backstop only — the stall guard on the test step is the primary detector # for a #4250-style hang (see Test Core). 30 min is ~6× a shard (~5 min; @@ -2135,8 +2160,9 @@ jobs: dogfood-verify: name: Dogfood Verify CLI needs: filter - # See THE FILTER CONTRACT on the filter job's outputs (#4928). - if: ${{ !cancelled() && needs.filter.outputs.core != 'false' }} + # See THE FILTER CONTRACT on the filter job's outputs (#4928). Full battery + # only: see THE TWO SPEEDS above `jobs:` (#22998). + if: ${{ !cancelled() && needs.filter.outputs.core != 'false' && github.event_name != 'pull_request' && github.event_name != 'merge_group' }} runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -2225,22 +2251,25 @@ jobs: overwrite: true dogfood-gate: - # Stable required-check name for a SHARDED job (#3622 follow-up). + # Stable check name for a SHARDED job (#3622 follow-up). Since #22998 its + # legs run in the full battery only (THE TWO SPEEDS above `jobs:`) and the + # context leaves the required set; on a PR or queue build both legs skip + # and this gate counts zero expected attestations. The history below is + # why the name and the `if: always()` stay. # - # Branch protection requires the context "Dogfood Regression Gate". Once + # Branch protection required the context "Dogfood Regression Gate". Once # the job became a 2-way matrix its checks publish as "Dogfood Regression # Gate (1/2)" / "(2/2)" — the bare context could never appear again, so # EVERY pull request in the repo sat permanently BLOCKED (mergeable, all # checks green, merge button dead). #3622's own comment called for updating # branch protection; keeping the contract HERE instead means a future - # shard-count change cannot deadlock the repo a second time — and since - # #6865 the bare name is pinned by `check:required-contexts` rather than - # only described here, so a rename fails in the ESLint job instead of in - # the queue. + # shard-count change cannot deadlock the repo a second time. From #6865 + # until #22998 the bare name was pinned by `check:required-contexts`; it + # left that registry with the required set, so a rename now falsifies the + # prose that names it without reddening anything — treat it as contract. # # Also aggregates dogfood-verify (the CLI pass that used to ride shard 1), - # so the one required context still covers everything it covered before - # the split. + # so the one context still covers everything it covered before the split. # # `if: always()` + attestation counting so a legitimately skipped matrix # (the `filter` job says no core paths changed) still satisfies the gate — @@ -2784,8 +2813,9 @@ 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. Full battery only: see + # THE TWO SPEEDS above `jobs:` (#22998). + if: ${{ !cancelled() && needs.filter.outputs.console != 'false' && github.event_name != 'pull_request' && github.event_name != 'merge_group' }} runs-on: ubuntu-latest timeout-minutes: 45 permissions: diff --git a/scripts/pm/check-expected-skips.mjs b/scripts/pm/check-expected-skips.mjs index 8a58a8c0e1e..6df58d83c1b 100644 --- a/scripts/pm/check-expected-skips.mjs +++ b/scripts/pm/check-expected-skips.mjs @@ -45,7 +45,9 @@ * family the merge-queue build answers it: on `merge_group` every filter output * widens to `'true'` and the family runs on the merged tree before anything * reaches `main` (the `|| 'true'` half of ci.yml's filter contract, pinned by - * `--self-test`). For the event- and label-conditional advisory jobs the reason + * `--self-test`) — except the full-battery-only jobs (ci.yml's THE TWO SPEEDS), + * which skip on every PR head and queue build and are answered by `main`'s + * push and hourly runs instead. For the event- and label-conditional advisory jobs the reason * column names the condition a reader checks by hand. Deriving expectedness * live from the workflows' `paths` filters would answer the diff question, at * the cost of a SECOND evaluator of the platform's own semantics — the @@ -209,6 +211,8 @@ const EVENT_REASON = "job-level `if:` excludes the `labeled` / `unlabeled` / `edited` pull_request events; each such event is its own run on the same head, beside the `opened` / `synchronize` run that concluded"; const CORE_REASON = "gated on ci.yml's `filter` job `core` output; a diff outside the core path set skips it on the PR head, and the merge-queue build widens every output to 'true' and runs it on the merged tree"; +const FULL_BATTERY_REASON = + "full battery only (ci.yml's THE TWO SPEEDS): its `if:` excludes `pull_request` and `merge_group`, so it skips on every PR head and every queue build and runs on main's push, hourly schedule and dispatch runs"; /** * THE ROSTER — declared once, here, as data. One row per check-run NAME that @@ -277,21 +281,21 @@ export const EXPECTED_SKIPS = Object.freeze([ workflow: 'ci.yml', job: 'temporal-conformance', gate: CORE_GATE, - reason: `${CORE_REASON} (a REQUIRED context: the queue build is where its verdict is taken)`, + reason: `${FULL_BATTERY_REASON}; on a push it also needs the \`filter\` job's \`core\` output`, }, { name: 'Dogfood Regression Gate (${{ matrix.shard }}/3)', workflow: 'ci.yml', job: 'dogfood', gate: CORE_GATE, - reason: `${CORE_REASON}; the raw matrix template IS the name a job skipped before matrix expansion reports (the aggregate \`Dogfood Regression Gate\` runs \`if: always()\` and never skips)`, + reason: `${FULL_BATTERY_REASON}; the raw matrix template IS the name a job skipped before matrix expansion reports (the aggregate \`Dogfood Regression Gate\` runs \`if: always()\` and never skips)`, }, { name: 'Dogfood Verify CLI', workflow: 'ci.yml', job: 'dogfood-verify', gate: CORE_GATE, - reason: CORE_REASON, + reason: `${FULL_BATTERY_REASON}; on a push it also needs the \`filter\` job's \`core\` output`, }, { name: 'Test Core (${{ matrix.shard }}/6)', @@ -313,7 +317,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, 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", + reason: `${FULL_BATTERY_REASON}; on a push it also needs the \`filter\` job's \`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\`)`, }, ]); From eba3a86836d3c3e7c99a8474ab290a00deab6a59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:19:39 +0000 Subject: [PATCH 2/5] ci(release): the publish lane reads the full battery before the approval prompt exists A new `full-battery` job, needed by `publish` (whose `if:` opens with `success()`), runs `scripts/ci/scheduled-full-run.mjs --release-gate`: per workflow (`CI`, `Lint & Type Check`), the newest completed `schedule` or `push` run on main whose head is the version commit or a first-parent ancestor of it must have concluded `success`. A `cancelled` run carries no verdict and is passed over; the nearest commit with a verdict decides, and on it the newest run. A refusal skips `publish`, so no deployment is queued at the `release` environment for a version whose battery is red. The rule lives in scheduled-full-run.mjs as its third half, so the --self-test lint.yml already runs pins it: three new batteries over a recorded 2026-10-12 run listing (scripts/fixtures/release-full-battery/), synthetic runs, and the release.yml wiring (the needs edge, the invocation, publish's `success()`, no continue-on-error), each wiring case driven red. Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB Co-authored-by: Claude --- .github/workflows/release.yml | 73 ++- scripts/ci/scheduled-full-run.mjs | 431 ++++++++++++++- .../release-full-battery/runs-2026-10-12.json | 516 ++++++++++++++++++ 3 files changed, 1014 insertions(+), 6 deletions(-) create mode 100644 scripts/fixtures/release-full-battery/runs-2026-10-12.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bd822b65ee..7edb4f88ce9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,6 +75,11 @@ name: Release # still waiting for a version that is # already on npm. Mints nothing; never # touches a job that has started. +# → `full-battery` (#22998) reads the newest full-battery +# runs of CI and Lint & Type Check at +# or before the version commit; +# `publish` needs it, so a red battery +# queues no approval prompt at all. # workflow_dispatch → `publish` the repair lane. Takes no version; # (WITHOUT audits main the same way, minus the # `refresh_version_pr`) push range (a dispatch is one human @@ -981,7 +986,7 @@ jobs: # below reads this output, and IT stops dead on the `release` # environment until a required reviewer approves it. echo "publish-pending=true" >> "$GITHUB_OUTPUT" - echo "::notice::main carries @objectstack/cli@${version}, which is NOT on npm; this ${EVENT} queues its publish on the version commit ${version_commit}. A deployment is queued and is waiting for a maintainer to approve the 'release' environment." + echo "::notice::main carries @objectstack/cli@${version}, which is NOT on npm; this ${EVENT} queues its publish on the version commit ${version_commit}. Once the full-battery job reads the battery at or before that commit green, a deployment is queued and waits for a maintainer to approve the 'release' environment." { echo "## Release ${version} is waiting for your approval" echo @@ -991,6 +996,8 @@ jobs: echo "The **Publish ${version} to npm** job below is held at the \`release\`" echo "environment gate. Nothing has been checked out, built or published —" echo "GitHub holds the whole job until a required reviewer approves it." + echo "It starts only after **Full battery green at the version commit** finds" + echo "the newest full-battery runs at or before \`${version_commit}\` green." echo echo "**Review this before approving:** approving publishes the tree of the" echo "version commit \`${version_commit}\` — the commit that brought main to" @@ -1364,6 +1371,65 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node scripts/release-pending-publish.mjs sweep --workflow release.yml + # ══════════════════════════════════════════════════════════════════════════ + # PUSH LANE 4 — the full battery, read before the approval exists (#22998). + # Reads; never publishes, never approves, never cancels. + # ══════════════════════════════════════════════════════════════════════════ + full-battery: + name: Full battery green at the version commit + # A PR or queue build carries ci.yml's FAST SET since #22998; the dogfood, + # temporal and console-pin legs run only on main's push, hourly and + # dispatch runs. So "tested before a release" is read HERE, as a gate: the + # newest completed `schedule` or `push` runs of `CI` and `Lint & Type + # Check` whose head is the version commit or a first-parent ancestor of it + # must both have concluded `success` (a `cancelled` run carries no + # verdict). The selection rule and its pins live in + # scripts/ci/scheduled-full-run.mjs (HALF 3), whose --self-test lint.yml + # runs; this job only calls it. + # + # `publish` needs this job and its `if:` opens with `success()`, so a + # refusal here skips `publish` before any deployment is queued: no + # approval prompt appears for a version whose battery is red. Recovery is + # the ordinary one — fix or re-run the red run at or before the version + # commit, then the repair dispatch re-audits and queues the publish. + # + # Same predicate as `publish`, verbatim, so the read happens exactly when + # a publish would be queued and on no other event; a skipped gate reads as + # a skipped `needs` to `publish`, whose `success()` is then false — the + # same fail-closed direction. + needs: [release-integrity] + if: >- + success() && + (needs.release-integrity.outputs.publish-pending == 'true' || + (github.event_name == 'workflow_dispatch' && inputs.force && + !inputs.refresh_version_pr)) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # `actions: read` is the run listings of ci.yml and lint.yml; `contents: + # read` is the checkout. + actions: read + contents: read + steps: + # `fetch-depth: 0`: "at or before the version commit" is a first-parent + # walk from it, and the script refuses a shallow clone rather than read a + # window that stops at the graft. + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Read the full battery at or before the version commit + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION_COMMIT: ${{ needs.release-integrity.outputs.version-commit }} + run: node scripts/ci/scheduled-full-run.mjs --release-gate --release-commit "$VERSION_COMMIT" + # ══════════════════════════════════════════════════════════════════════════ # HUMAN LANE — the ONLY job in this repository that publishes. # ══════════════════════════════════════════════════════════════════════════ @@ -1376,7 +1442,10 @@ jobs: # version in that failure's log was @object-ui/console@17.5.0, the vendored # objectui build, while the repo was publishing 17.1.0. name: Publish ${{ needs.release-integrity.outputs.cli-version }} to npm (awaiting approval) - needs: [release-integrity] + # `full-battery` (#22998): the publish lane refuses, before the approval + # prompt exists, a version whose full battery at or before the version + # commit is not green. `success()` below is what makes that refusal bind. + needs: [release-integrity, full-battery] # ⛔ THE barrier is now the environment, not the trigger (ADR-0125 D3). # # An environment with NO protection rules passes AUTOMATICALLY and diff --git a/scripts/ci/scheduled-full-run.mjs b/scripts/ci/scheduled-full-run.mjs index 4a9710930d2..bc87f666f6d 100644 --- a/scripts/ci/scheduled-full-run.mjs +++ b/scripts/ci/scheduled-full-run.mjs @@ -1,13 +1,20 @@ #!/usr/bin/env node // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// scheduled-full-run -- the two halves of the hourly full run's contract that -// nothing else can hold (#16467). +// scheduled-full-run -- the three halves of the full battery's contract that +// nothing else can hold (#16467, #22998). // // node scripts/ci/scheduled-full-run.mjs --check-concurrency +// node scripts/ci/scheduled-full-run.mjs --release-gate --release-commit // node scripts/ci/scheduled-full-run.mjs --self-test // -// ## Why one file holds both halves +// Half 3 (#22998) is the READER the full battery gained when PR and queue runs +// became the fast set: release.yml's `full-battery` job runs `--release-gate` +// before the `release` environment approval, and the publish lane refuses +// unless the newest full-battery runs of `CI` and `Lint & Type Check` at or +// before the version commit concluded `success`. See HALF 3 below. +// +// ## Why one file holds the first two halves // // `push` on `main` now tests the AFFECTED set. The thing that keeps "main is // green" a statement about the whole workspace is the hourly `schedule` run of @@ -37,9 +44,12 @@ // token it does not understand rather than guessing a value, because a guess // makes two groups compare equal or unequal for a reason nobody wrote down. +import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { EXIT_FINDINGS, EXIT_PREREQUISITE_NOT_MET } from '../import-prerequisite.mjs'; import { isEntrypoint } from '../invoked-as.mjs'; // The workflows that carry the hourly full run. Spelled as quoted repo-relative @@ -367,6 +377,245 @@ export function renderBody({ .join('\n'); } +// --------------------------------------------------------------------------- +// HALF 3 -- the release reads the full battery (#22998) +// --------------------------------------------------------------------------- +// +// A PR or queue build carries the FAST SET since #22998 (ci.yml's THE TWO +// SPEEDS); the dogfood, temporal and console-pin legs run only on `main`'s +// push, hourly schedule and dispatch runs. "Tested before a release" is +// therefore a statement about those runs, and this half makes it a gate: +// release.yml's `full-battery` job, which `publish` needs, so a refusal here +// means no deployment is queued at the `release` environment at all. +// +// THE SELECTION RULE, per workflow (`CI`, `Lint & Type Check`), pinned below: +// +// 1. candidates are runs whose event is `schedule` or `push`, on `main`, +// `completed`, whose head is the release commit or one of its first-parent +// ancestors in the window read from git; +// 2. a `cancelled` run carries no verdict and is passed over -- push runs on +// `main` cancel each other (62 of 100 `CI` push runs and 81 of 100 +// `Lint & Type Check` push runs in the 2026-10-12 recording), so counting +// them would refuse nearly every release for a reason that says nothing +// about the tree; +// 3. the NEAREST commit with a candidate decides, and on that commit the +// newest run (`run_started_at`, which a re-run moves) -- so an older +// green never outvotes a newer red, and a re-run that went green after a +// flake does count; +// 4. the release passes only when BOTH workflows' chosen runs concluded +// `success`; every other conclusion, and no candidate at all, refuses. +// +// ⛔ It never waits and never reads a run AFTER the release commit: a run on a +// later `main` head tested a different tree. ⚠️ A `push` run's Test Core is +// affected-only (#16467), so a push run is a narrower verdict than an hourly +// one; the ruled rule reads both events, and that narrowing is recorded on +// #22998 rather than decided here. + +// The workflows whose full-battery runs the release reads, by file (the API's +// stable key) and by the name a reader sees. +export const RELEASE_GATE_WORKFLOWS = Object.freeze([ + Object.freeze({ file: 'ci.yml', name: 'CI' }), + Object.freeze({ file: 'lint.yml', name: 'Lint & Type Check' }), +]); + +// The events that carry the full battery on `main`, and the one conclusion that +// carries no verdict. Both closed on purpose: widening either is a decision +// about what "tested" means, never a spelling fix. +export const FULL_BATTERY_EVENTS = Object.freeze(['schedule', 'push']); +export const NO_VERDICT_CONCLUSIONS = Object.freeze(['cancelled']); + +// The workflow file that wires this half, as a quoted repo-relative literal so +// the dispatch derivation schedules this family for a card touching it. +export const RELEASE_WORKFLOW = '.github/workflows/release.yml'; +// The job id in it that runs `--release-gate`, and the job that must need it. +export const RELEASE_GATE_JOB = 'full-battery'; +export const RELEASE_PUBLISH_JOB = 'publish'; + +// How many first-parent commits the window spans, and how many pages of 100 +// completed runs per event per workflow are read. Generous against an hourly +// cadence: the nearest verdict is normally a handful of commits back. +export const RELEASE_GATE_WINDOW = 400; +export const RELEASE_GATE_PAGES = 3; + +const startedAt = (run) => String(run?.run_started_at ?? run?.created_at ?? ''); + +// Rule 1-3: the run that decides for one workflow, or null when none qualifies. +export function selectFullBatteryRun({ history, runs, workflowFile }) { + const position = new Map((history ?? []).map((sha, index) => [sha, index])); + const candidates = (runs ?? []).filter( + (run) => + FULL_BATTERY_EVENTS.includes(run?.event) && + run?.head_branch === 'main' && + run?.status === 'completed' && + !NO_VERDICT_CONCLUSIONS.includes(run?.conclusion) && + position.has(run?.head_sha) && + (run?.path === undefined || String(run.path).endsWith(`/${workflowFile}`)) + ); + candidates.sort( + (a, b) => + position.get(a.head_sha) - position.get(b.head_sha) || + startedAt(b).localeCompare(startedAt(a)) || + Number(b.id) - Number(a.id) + ); + const run = candidates[0]; + return run ? { run, distance: position.get(run.head_sha) } : null; +} + +// Rule 4, over both workflows. `history[0]` is the release commit. +export function releaseGateVerdict({ history, runsByWorkflow, workflows = RELEASE_GATE_WORKFLOWS }) { + const releaseCommit = history?.[0] ?? ''; + if (!releaseCommit) { + return { ok: false, lines: [], refusals: ['no first-parent history was read, so no run can be placed at or before the release commit'] }; + } + const lines = []; + const refusals = []; + for (const { file, name } of workflows) { + const chosen = selectFullBatteryRun({ history, runs: runsByWorkflow?.[file], workflowFile: file }); + if (!chosen) { + refusals.push( + `${name}: no completed ${FULL_BATTERY_EVENTS.join(' or ')} run on main at or before ${releaseCommit.slice(0, 10)} ` + + `in the ${history.length}-commit window (a cancelled run carries no verdict)` + ); + continue; + } + const { run, distance } = chosen; + const where = `run ${run.id} (${run.event}, attempt ${run.run_attempt ?? 1}) on ${String(run.head_sha).slice(0, 10)}, ${distance} first-parent commit(s) before the release commit`; + lines.push(`${name}: ${run.conclusion} -- ${where}`); + if (run.conclusion !== 'success') { + refusals.push(`${name}: the newest full-battery run at or before the release commit concluded ${run.conclusion} -- ${where}`); + } + } + return { ok: refusals.length === 0, lines, refusals }; +} + +// The exit a verdict maps to: 0 pass, EXIT_FINDINGS refuse. +export function releaseGateExit(verdict) { + return verdict?.ok === true ? 0 : EXIT_FINDINGS; +} + +// The wiring the verdict depends on, read from release.yml as TEXT (this file +// takes no dependency beyond node): the gate job runs `--release-gate`, the +// publish job needs it, and publish's `if:` opens with `success()` -- under +// `!cancelled()` a failed gate would no longer stop the publish. +function jobBlocks(yamlText) { + const lines = String(yamlText).split('\n'); + const start = lines.findIndex((line) => line === 'jobs:'); + const blocks = new Map(); + if (start === -1) return blocks; + let current = null; + for (const line of lines.slice(start + 1)) { + if (/^\S/.test(line)) break; + const m = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line); + if (m) { + current = m[1]; + blocks.set(current, []); + continue; + } + if (current) blocks.get(current).push(line); + } + return blocks; +} + +export function releaseGateWiring(yamlText) { + const findings = []; + const blocks = jobBlocks(yamlText); + if (blocks.size === 0) return [`${RELEASE_WORKFLOW} has no \`jobs:\` block this reader can see`]; + const gate = blocks.get(RELEASE_GATE_JOB); + const code = (block) => block.filter((line) => !/^\s*#/.test(line)); + if (!gate) { + findings.push(`${RELEASE_WORKFLOW} has no \`${RELEASE_GATE_JOB}\` job`); + } else { + if (!code(gate).some((line) => /node scripts\/ci\/scheduled-full-run\.mjs --release-gate\b/.test(line))) { + findings.push(`${RELEASE_WORKFLOW} job \`${RELEASE_GATE_JOB}\` never runs \`scripts/ci/scheduled-full-run.mjs --release-gate\``); + } + if (code(gate).some((line) => /^\s*continue-on-error:\s*true\b/.test(line))) { + findings.push(`${RELEASE_WORKFLOW} job \`${RELEASE_GATE_JOB}\` sets continue-on-error, so its refusal cannot conclude failure`); + } + } + const publish = blocks.get(RELEASE_PUBLISH_JOB); + if (!publish) { + findings.push(`${RELEASE_WORKFLOW} has no \`${RELEASE_PUBLISH_JOB}\` job`); + return findings; + } + const needsLine = code(publish).find((line) => /^ {4}needs:/.test(line)) ?? ''; + const needs = needsLine.replace(/^ {4}needs:\s*/, '').replace(/[[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean); + if (!needs.includes(RELEASE_GATE_JOB)) { + findings.push(`${RELEASE_WORKFLOW} job \`${RELEASE_PUBLISH_JOB}\` does not need \`${RELEASE_GATE_JOB}\` (needs: ${needs.join(', ') || 'none'})`); + } + const ifAt = code(publish).findIndex((line) => /^ {4}if:/.test(line)); + const ifText = ifAt === -1 ? '' : code(publish).slice(ifAt).join('\n'); + const firstTerm = ifText.replace(/^ {4}if:\s*(>-|\|)?\s*/, '').trim(); + if (!firstTerm.startsWith('success() &&')) { + findings.push(`${RELEASE_WORKFLOW} job \`${RELEASE_PUBLISH_JOB}\`'s \`if:\` does not open with \`success() &&\`, so a refused \`${RELEASE_GATE_JOB}\` would not stop it`); + } + return findings; +} + +// -- The live half: git for the window, the Actions API for the runs. -------- + +function git(args) { + const res = spawnSync('git', args, { encoding: 'utf8' }); + if (res.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${String(res.stderr || res.error || '').trim()}`); + return res.stdout.trim(); +} + +export function readFirstParentHistory(releaseCommit, window = RELEASE_GATE_WINDOW) { + if (!/^[0-9a-f]{40}$/.test(String(releaseCommit))) { + throw new Error(`the release commit '${releaseCommit}' is not a 40-hex sha`); + } + if (git(['rev-parse', '--is-shallow-repository']) !== 'false') { + throw new Error('shallow clone: a first-parent window read here would stop at the graft, so the checkout must keep fetch-depth: 0'); + } + return git(['rev-list', '--first-parent', `--max-count=${window}`, releaseCommit]).split('\n').filter(Boolean); +} + +export async function readRuns({ repo, token, file, event, pages = RELEASE_GATE_PAGES }) { + const runs = []; + for (let page = 1; page <= pages; page += 1) { + const url = + `https://api.github.com/repos/${repo}/actions/workflows/${file}/runs` + + `?branch=main&event=${event}&status=completed&per_page=100&page=${page}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, + }); + if (!res.ok) throw new Error(`GET ${url} answered HTTP ${res.status}`); + const body = await res.json(); + const batch = Array.isArray(body?.workflow_runs) ? body.workflow_runs : null; + if (!batch) throw new Error(`GET ${url} answered no workflow_runs array`); + runs.push(...batch); + if (batch.length < 100) break; + } + return runs; +} + +async function releaseGate(argv) { + const at = argv.indexOf('--release-commit'); + const releaseCommit = at === -1 ? '' : String(argv[at + 1] ?? ''); + const repo = process.env.GITHUB_REPOSITORY ?? ''; + const token = process.env.GITHUB_TOKEN ?? ''; + let history; + const runsByWorkflow = {}; + try { + if (!repo || !token) throw new Error('GITHUB_REPOSITORY and GITHUB_TOKEN are both required'); + history = readFirstParentHistory(releaseCommit); + for (const { file } of RELEASE_GATE_WORKFLOWS) { + runsByWorkflow[file] = []; + for (const event of FULL_BATTERY_EVENTS) runsByWorkflow[file].push(...(await readRuns({ repo, token, file, event }))); + } + } catch (error) { + console.log(`::error::release gate NOT MEASURED -- ${error.message}. Refusing: an unread full battery is not a green one.`); + process.exit(EXIT_PREREQUISITE_NOT_MET); + } + const verdict = releaseGateVerdict({ history, runsByWorkflow }); + for (const line of verdict.lines) console.log(`scheduled-full-run --release-gate: ${line}`); + if (verdict.ok) { + console.log(`scheduled-full-run --release-gate: PASS -- the full battery at or before ${releaseCommit.slice(0, 10)} is green in both workflows.`); + } else { + for (const refusal of verdict.refusals) console.log(`::error::release refused -- ${refusal}`); + } + process.exit(releaseGateExit(verdict)); +} + // --------------------------------------------------------------------------- // -- The self-test's own battery roster and floor --------------------------- // @@ -376,8 +625,11 @@ export function renderBody({ const SELF_TEST_BATTERIES = Object.freeze({ 'scheduled-full-run concurrency isolation': 20, 'scheduled-full-run card identity and de-dup': 23, + 'release gate: the selection rule on the recorded 2026-10-12 listing': 6, + 'release gate: the selection rule on synthetic runs': 14, + 'release gate: release.yml wiring': 5, }); -const SELF_TEST_BATTERY_FLOOR = 2; +const SELF_TEST_BATTERY_FLOOR = 5; const UNATTRIBUTED_BATTERY = '(no battery open)'; // Returned by `selfTest()` only after its verdict is printed, so a `return` @@ -743,6 +995,171 @@ async function selfTest() { } }); + // ── HALF 3: the release gate ──────────────────────────────────────────── + battery('release gate: the selection rule on the recorded 2026-10-12 listing'); + // Read through the module's own location, never the cwd: the fixture is + // evidence recorded from the Actions API, and its provenance line says how. + const recorded = JSON.parse( + readFileSync(fileURLToPath(new URL('../fixtures/release-full-battery/runs-2026-10-12.json', import.meta.url)), 'utf8') + ); + const recordedVerdict = (fromIndex) => + releaseGateVerdict({ history: recorded.firstParent.slice(fromIndex), runsByWorkflow: recorded.runs }); + await check(() => { + // Release commit 0524c1619b: both workflows' nearest verdict is green. + const v = recordedVerdict(0); + if (!v.ok) throw new Error(`recorded: 0524c1619b should pass, refused: ${v.refusals.join(' | ')}`); + }); + await check(() => { + // ...and on 712361b7a8 CI carries a push success (07:57) AND a schedule + // success (08:15): the NEWER of the two is named. + const chosen = selectFullBatteryRun({ history: recorded.firstParent, runs: recorded.runs['ci.yml'], workflowFile: 'ci.yml' }); + if (chosen?.run.id !== 38222448090 || chosen.distance !== 3) { + throw new Error(`recorded: expected CI run 38222448090 at distance 3, got ${chosen?.run.id} at ${chosen?.distance}`); + } + }); + await check(() => { + // The commit 2 back (eede467f3d) carries only a CANCELLED push run in both + // workflows: it is passed over, never read as a refusal. + const chosen = selectFullBatteryRun({ history: recorded.firstParent, runs: recorded.runs['lint.yml'], workflowFile: 'lint.yml' }); + if (chosen?.run.id !== 38223935346) throw new Error(`recorded: expected Lint run 38223935346, got ${chosen?.run.id}`); + }); + await check(() => { + // FIRING CONTROL on real data. Release commit 19805d8956 (index 5): its own + // push run was cancelled, its hourly CI run concluded failure -> refused, + // naming CI and that run; Lint's nearest verdict (1f312fdfa6, 6 back) is + // green, so Lint is NOT named. + const v = recordedVerdict(5); + if (v.ok) throw new Error('recorded: 19805d8956 should be refused (the 07:25 hourly CI run failed)'); + if (v.refusals.length !== 1 || !v.refusals[0].startsWith('CI:') || !v.refusals[0].includes('38217670691')) { + throw new Error(`recorded: the refusal must name CI and run 38217670691 alone, got ${v.refusals.join(' | ')}`); + } + }); + await check(() => { + // Runs on commits that landed AFTER the release commit (48cca4ae00 and + // 6a40a09ed9 in the recording) tested another tree. + const onLater = recorded.runs['ci.yml'].filter((r) => !recorded.firstParent.includes(r.head_sha)); + if (onLater.length === 0) throw new Error('recorded: the fixture lost its runs on later commits, so this case tests nothing'); + for (let i = 0; i < 10; i += 1) { + const chosen = selectFullBatteryRun({ history: recorded.firstParent.slice(i), runs: recorded.runs['ci.yml'], workflowFile: 'ci.yml' }); + if (chosen && onLater.includes(chosen.run)) throw new Error(`recorded: a run after the release commit was chosen (${chosen.run.id})`); + } + }); + await check(() => { + // A release commit outside the recorded window: nothing to place, refused. + const v = releaseGateVerdict({ history: ['f'.repeat(40)], runsByWorkflow: recorded.runs }); + if (v.ok || v.refusals.length !== 2) throw new Error(`recorded: an unplaced commit should refuse for both workflows, got ${JSON.stringify(v)}`); + }); + + battery('release gate: the selection rule on synthetic runs'); + const sha = (n) => String(n).repeat(40).slice(0, 40); + const H = [sha(1), sha(2), sha(3), sha(4)]; + const run = (id, headIndex, conclusion, extra = {}) => ({ + id, + path: '.github/workflows/ci.yml', + event: 'schedule', + status: 'completed', + conclusion, + head_sha: H[headIndex], + head_branch: 'main', + run_started_at: `2026-10-12T0${id % 10}:00:00Z`, + run_attempt: 1, + ...extra, + }); + const pick = (runs) => selectFullBatteryRun({ history: H, runs, workflowFile: 'ci.yml' })?.run.id ?? null; + const both = (ciRuns, lintRuns) => + releaseGateVerdict({ + history: H, + runsByWorkflow: { 'ci.yml': ciRuns, 'lint.yml': lintRuns.map((r) => ({ ...r, path: '.github/workflows/lint.yml' })) }, + }); + await check(() => { + if (pick([run(1, 0, 'cancelled'), run(2, 1, 'cancelled')]) !== null) throw new Error('synthetic: cancelled-only was given a verdict'); + }); + await check(() => { + if (!both([run(1, 0, 'cancelled')], [run(2, 0, 'success')]).refusals.some((r) => r.startsWith('CI: no completed'))) { + throw new Error('synthetic: no verdict-bearing CI run must refuse, naming CI'); + } + }); + await check(() => { + // A nearer red beats an older green, whatever the clock says. + if (pick([run(9, 2, 'success'), run(1, 1, 'failure')]) !== 1) throw new Error('synthetic: an older green outvoted a nearer red'); + }); + await check(() => { + // On one commit, the newer run decides: a re-run that went green counts... + if (pick([run(1, 1, 'failure'), run(2, 1, 'success')]) !== 2) throw new Error('synthetic: a newer green re-run on the same commit was ignored'); + }); + await check(() => { + // ...and so does a newer red after an older green. + if (pick([run(1, 1, 'success'), run(2, 1, 'failure')]) !== 2) throw new Error('synthetic: an older green outvoted a newer red on one commit'); + }); + await check(() => { + // Equal start times fall back to the higher run id, never to input order. + const a = run(3, 1, 'failure', { run_started_at: 'T' }); + const b = run(4, 1, 'success', { run_started_at: 'T' }); + if (pick([b, a]) !== 4 || pick([a, b]) !== 4) throw new Error('synthetic: the tie-break depends on input order'); + }); + for (const event of ['pull_request', 'merge_group', 'workflow_dispatch']) { + await check(() => { + if (pick([run(1, 0, 'failure', { event }), run(2, 2, 'success')]) !== 2) throw new Error(`synthetic: a ${event} run was read as full battery`); + }); + } + await check(() => { + if (pick([run(1, 0, 'failure', { head_branch: 'feature' }), run(2, 2, 'success')]) !== 2) throw new Error('synthetic: a non-main run was read'); + }); + await check(() => { + if (pick([run(1, 0, 'failure', { status: 'in_progress', conclusion: null }), run(2, 2, 'success')]) !== 2) { + throw new Error('synthetic: an unfinished run was read'); + } + }); + await check(() => { + // Not `cancelled`, not `success`: a verdict, and a refusing one. + if (both([run(1, 0, 'startup_failure')], [run(2, 0, 'success')]).ok) throw new Error('synthetic: startup_failure passed'); + }); + await check(() => { + if (pick([run(1, 0, 'failure', { path: '.github/workflows/lint.yml' }), run(2, 2, 'success')]) !== 2) { + throw new Error("synthetic: another workflow's run was read as this one's"); + } + }); + await check(() => { + // Both must be green: either one red refuses, and only the red one is named. + const v = both([run(1, 0, 'success')], [run(2, 0, 'failure')]); + if (v.ok || v.refusals.length !== 1 || !v.refusals[0].startsWith('Lint & Type Check:')) { + throw new Error(`synthetic: a red Lint run did not refuse alone (${v.refusals.join(' | ')})`); + } + if (releaseGateExit(v) !== EXIT_FINDINGS || releaseGateExit(both([run(1, 0, 'success')], [run(2, 0, 'success')])) !== 0) { + throw new Error('synthetic: the verdict did not map to exit 1 / exit 0'); + } + }); + await check(() => { + if (releaseGateVerdict({ history: [], runsByWorkflow: {} }).ok) throw new Error('synthetic: an empty history passed'); + }); + + battery('release gate: release.yml wiring'); + const releaseYml = readFileSync(fileURLToPath(new URL('../../.github/workflows/release.yml', import.meta.url)), 'utf8'); + await check(() => { + const findings = releaseGateWiring(releaseYml); + if (findings.length > 0) throw new Error(`wiring: the checked-in release.yml: ${findings.join(' | ')}`); + }); + await check(() => { + const cut = releaseYml.replace(/(\n {4}needs: \[release-integrity), full-battery\]/, '$1]'); + if (cut === releaseYml) throw new Error('wiring: the needs: anchor did not match, so this case tests nothing'); + if (!releaseGateWiring(cut).some((f) => f.includes('does not need'))) throw new Error('wiring: publish without the gate in needs: passed'); + }); + await check(() => { + const cut = releaseYml.replace('node scripts/ci/scheduled-full-run.mjs --release-gate', 'node scripts/ci/scheduled-full-run.mjs --self-test'); + if (cut === releaseYml) throw new Error('wiring: the run anchor did not match, so this case tests nothing'); + if (!releaseGateWiring(cut).some((f) => f.includes('never runs'))) throw new Error('wiring: a gate job that never runs --release-gate passed'); + }); + await check(() => { + const cut = releaseYml.replace(/(\n {2}publish:[\s\S]*?\n {4}if: >-\n\s*)success\(\) &&/, '$1!cancelled() &&'); + if (cut === releaseYml) throw new Error("wiring: publish's if: anchor did not match, so this case tests nothing"); + if (!releaseGateWiring(cut).some((f) => f.includes('success() &&'))) throw new Error("wiring: publish's if: under !cancelled() passed"); + }); + await check(() => { + const cut = releaseYml.replace(/(\n {2}full-battery:\n)/, '$1 continue-on-error: true\n'); + if (cut === releaseYml) throw new Error('wiring: the gate job anchor did not match, so this case tests nothing'); + if (!releaseGateWiring(cut).some((f) => f.includes('continue-on-error'))) throw new Error('wiring: a continue-on-error gate passed'); + }); + // -- The floor. Evaluated before the verdict, so the success line can only be // printed by a run in which the set of batteries that registered EQUALS // the set declared. @@ -824,8 +1241,14 @@ async function main() { return; } + if (argv.includes('--release-gate')) { + await releaseGate(argv); + return; + } + console.error( 'usage: scheduled-full-run.mjs --check-concurrency\n' + + ' scheduled-full-run.mjs --release-gate --release-commit \n' + ' scheduled-full-run.mjs --self-test' ); process.exit(1); diff --git a/scripts/fixtures/release-full-battery/runs-2026-10-12.json b/scripts/fixtures/release-full-battery/runs-2026-10-12.json new file mode 100644 index 00000000000..23b65ca416e --- /dev/null +++ b/scripts/fixtures/release-full-battery/runs-2026-10-12.json @@ -0,0 +1,516 @@ +{ + "provenance": "Read 2026-10-12T09:09Z through GET /repos/objectstack-ai/objectstack/actions/workflows/{ci.yml,lint.yml}/runs?branch=main&event={push,schedule}&status=completed&per_page=100, kept to runs created at or after 2026-10-12T04:00Z and trimmed to the fields scheduled-full-run.mjs --release-gate reads. firstParent is `git rev-list --first-parent --max-count=25 0524c1619b6eea808255b80644a68be5fe6ec2ce` on the same day; index 0 is that commit. Two runs per workflow sit on 48cca4ae00 and 6a40a09ed9, commits that landed AFTER it, so they are not at or before it.", + "firstParent": [ + "0524c1619b6eea808255b80644a68be5fe6ec2ce", + "340eab79afa2bf2f762387de9346d4960fd6f684", + "eede467f3d0bce273b4e015242aafe93a97ea9db", + "712361b7a8709744ccd8a4fe4f37628a90e9d191", + "a2240c4dcfc3a19f058cb0b3a4f15a351908acde", + "19805d8956f6b6728b9f4945da377f83c40c2cf0", + "117817ec2854c768538580960a3916869c98e863", + "246edf5b398a937126bb310cddbef9d3669bd89d", + "8d8f965374d68819626c698960783dd6af9c6245", + "39cca271c738be8bd2b48b1ef84cf7ca7f350b1c", + "5f4e8a544cc234a290737d0fbd5a0092d8d938e8", + "1f312fdfa680d210cb801acad543c1598b02a053", + "e23eccb164e2e5bd4acc5221b2d9f733818258fa", + "21914c3fb3bd0f286716069944946d6a34b6a991", + "e5687cb6b326983d6260d0f6fd7f1c73cd6ba5ba", + "da9c2bf2f25d61a9fec44afc300b657ae10399bc", + "51b43a8dc45fef9d86229509ab6b2679ffa9d501", + "4426d9d51c9f00251c0722cb3e58477415063c12", + "f86a10d572c1429d72bdfa5047c6dbcc83265859", + "e357a441c6423798a673d4a39ecee4b31adacf79", + "f868e688ff726d0c7b46ce808f702a7bb7d62af4", + "1bd612bd68931e97798201a6049a08b1c872b999", + "e4ad6776d138132d170c1e7d51ad7239167b9821", + "06cc45dc366de3f1e25e18b0281f0cc38ba4c1e6", + "85b4ac634e3f3997bee7cf038090d24d029bc82c" + ], + "runs": { + "ci.yml": [ + { + "id": 38226947258, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "48cca4ae009c13b1b15d1c01c7694b33ae334daa", + "head_branch": "main", + "run_started_at": "2026-10-12T08:59:03Z", + "created_at": "2026-10-12T08:59:03Z", + "run_attempt": 1 + }, + { + "id": 38224556104, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "success", + "head_sha": "6a40a09ed952f83b1f765d47cd76aee4a41f6672", + "head_branch": "main", + "run_started_at": "2026-10-12T08:36:03Z", + "created_at": "2026-10-12T08:36:03Z", + "run_attempt": 1 + }, + { + "id": 38224326416, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "eede467f3d0bce273b4e015242aafe93a97ea9db", + "head_branch": "main", + "run_started_at": "2026-10-12T08:33:52Z", + "created_at": "2026-10-12T08:33:52Z", + "run_attempt": 1 + }, + { + "id": 38222448090, + "path": ".github/workflows/ci.yml", + "event": "schedule", + "status": "completed", + "conclusion": "success", + "head_sha": "712361b7a8709744ccd8a4fe4f37628a90e9d191", + "head_branch": "main", + "run_started_at": "2026-10-12T08:15:17Z", + "created_at": "2026-10-12T08:15:17Z", + "run_attempt": 1 + }, + { + "id": 38220718958, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "success", + "head_sha": "712361b7a8709744ccd8a4fe4f37628a90e9d191", + "head_branch": "main", + "run_started_at": "2026-10-12T07:57:54Z", + "created_at": "2026-10-12T07:57:54Z", + "run_attempt": 1 + }, + { + "id": 38218487767, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "success", + "head_sha": "a2240c4dcfc3a19f058cb0b3a4f15a351908acde", + "head_branch": "main", + "run_started_at": "2026-10-12T07:34:23Z", + "created_at": "2026-10-12T07:34:23Z", + "run_attempt": 1 + }, + { + "id": 38217670691, + "path": ".github/workflows/ci.yml", + "event": "schedule", + "status": "completed", + "conclusion": "failure", + "head_sha": "19805d8956f6b6728b9f4945da377f83c40c2cf0", + "head_branch": "main", + "run_started_at": "2026-10-12T07:25:40Z", + "created_at": "2026-10-12T07:25:40Z", + "run_attempt": 1 + }, + { + "id": 38217564899, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "19805d8956f6b6728b9f4945da377f83c40c2cf0", + "head_branch": "main", + "run_started_at": "2026-10-12T07:24:32Z", + "created_at": "2026-10-12T07:24:32Z", + "run_attempt": 1 + }, + { + "id": 38216265439, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "117817ec2854c768538580960a3916869c98e863", + "head_branch": "main", + "run_started_at": "2026-10-12T07:10:16Z", + "created_at": "2026-10-12T07:10:16Z", + "run_attempt": 1 + }, + { + "id": 38214595493, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "246edf5b398a937126bb310cddbef9d3669bd89d", + "head_branch": "main", + "run_started_at": "2026-10-12T06:51:17Z", + "created_at": "2026-10-12T06:51:17Z", + "run_attempt": 1 + }, + { + "id": 38212629827, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "success", + "head_sha": "1f312fdfa680d210cb801acad543c1598b02a053", + "head_branch": "main", + "run_started_at": "2026-10-12T06:27:47Z", + "created_at": "2026-10-12T06:27:47Z", + "run_attempt": 1 + }, + { + "id": 38211546047, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "e23eccb164e2e5bd4acc5221b2d9f733818258fa", + "head_branch": "main", + "run_started_at": "2026-10-12T06:14:12Z", + "created_at": "2026-10-12T06:14:12Z", + "run_attempt": 1 + }, + { + "id": 38210849670, + "path": ".github/workflows/ci.yml", + "event": "schedule", + "status": "completed", + "conclusion": "failure", + "head_sha": "21914c3fb3bd0f286716069944946d6a34b6a991", + "head_branch": "main", + "run_started_at": "2026-10-12T06:05:15Z", + "created_at": "2026-10-12T06:05:15Z", + "run_attempt": 1 + }, + { + "id": 38210639878, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "21914c3fb3bd0f286716069944946d6a34b6a991", + "head_branch": "main", + "run_started_at": "2026-10-12T06:02:36Z", + "created_at": "2026-10-12T06:02:36Z", + "run_attempt": 1 + }, + { + "id": 38210359517, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "e5687cb6b326983d6260d0f6fd7f1c73cd6ba5ba", + "head_branch": "main", + "run_started_at": "2026-10-12T05:59:07Z", + "created_at": "2026-10-12T05:59:07Z", + "run_attempt": 1 + }, + { + "id": 38208625537, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "success", + "head_sha": "da9c2bf2f25d61a9fec44afc300b657ae10399bc", + "head_branch": "main", + "run_started_at": "2026-10-12T05:35:51Z", + "created_at": "2026-10-12T05:35:51Z", + "run_attempt": 1 + }, + { + "id": 38206660804, + "path": ".github/workflows/ci.yml", + "event": "schedule", + "status": "completed", + "conclusion": "success", + "head_sha": "51b43a8dc45fef9d86229509ab6b2679ffa9d501", + "head_branch": "main", + "run_started_at": "2026-10-12T05:08:29Z", + "created_at": "2026-10-12T05:08:29Z", + "run_attempt": 1 + }, + { + "id": 38204522474, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "success", + "head_sha": "51b43a8dc45fef9d86229509ab6b2679ffa9d501", + "head_branch": "main", + "run_started_at": "2026-10-12T04:37:11Z", + "created_at": "2026-10-12T04:37:11Z", + "run_attempt": 1 + }, + { + "id": 38203196264, + "path": ".github/workflows/ci.yml", + "event": "push", + "status": "completed", + "conclusion": "success", + "head_sha": "e357a441c6423798a673d4a39ecee4b31adacf79", + "head_branch": "main", + "run_started_at": "2026-10-12T04:17:44Z", + "created_at": "2026-10-12T04:17:44Z", + "run_attempt": 1 + }, + { + "id": 38202645321, + "path": ".github/workflows/ci.yml", + "event": "schedule", + "status": "completed", + "conclusion": "failure", + "head_sha": "1bd612bd68931e97798201a6049a08b1c872b999", + "head_branch": "main", + "run_started_at": "2026-10-12T04:09:35Z", + "created_at": "2026-10-12T04:09:35Z", + "run_attempt": 1 + } + ], + "lint.yml": [ + { + "id": 38226947179, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "48cca4ae009c13b1b15d1c01c7694b33ae334daa", + "head_branch": "main", + "run_started_at": "2026-10-12T08:59:03Z", + "created_at": "2026-10-12T08:59:03Z", + "run_attempt": 1 + }, + { + "id": 38224556111, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "6a40a09ed952f83b1f765d47cd76aee4a41f6672", + "head_branch": "main", + "run_started_at": "2026-10-12T08:36:03Z", + "created_at": "2026-10-12T08:36:03Z", + "run_attempt": 1 + }, + { + "id": 38224326415, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "eede467f3d0bce273b4e015242aafe93a97ea9db", + "head_branch": "main", + "run_started_at": "2026-10-12T08:33:52Z", + "created_at": "2026-10-12T08:33:52Z", + "run_attempt": 1 + }, + { + "id": 38223935346, + "path": ".github/workflows/lint.yml", + "event": "schedule", + "status": "completed", + "conclusion": "success", + "head_sha": "712361b7a8709744ccd8a4fe4f37628a90e9d191", + "head_branch": "main", + "run_started_at": "2026-10-12T08:30:04Z", + "created_at": "2026-10-12T08:30:04Z", + "run_attempt": 1 + }, + { + "id": 38220718949, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "success", + "head_sha": "712361b7a8709744ccd8a4fe4f37628a90e9d191", + "head_branch": "main", + "run_started_at": "2026-10-12T07:57:54Z", + "created_at": "2026-10-12T07:57:54Z", + "run_attempt": 1 + }, + { + "id": 38218779551, + "path": ".github/workflows/lint.yml", + "event": "schedule", + "status": "completed", + "conclusion": "success", + "head_sha": "a2240c4dcfc3a19f058cb0b3a4f15a351908acde", + "head_branch": "main", + "run_started_at": "2026-10-12T07:37:27Z", + "created_at": "2026-10-12T07:37:27Z", + "run_attempt": 1 + }, + { + "id": 38218487783, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "success", + "head_sha": "a2240c4dcfc3a19f058cb0b3a4f15a351908acde", + "head_branch": "main", + "run_started_at": "2026-10-12T07:34:23Z", + "created_at": "2026-10-12T07:34:23Z", + "run_attempt": 1 + }, + { + "id": 38217564804, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "19805d8956f6b6728b9f4945da377f83c40c2cf0", + "head_branch": "main", + "run_started_at": "2026-10-12T07:24:32Z", + "created_at": "2026-10-12T07:24:32Z", + "run_attempt": 1 + }, + { + "id": 38216265436, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "117817ec2854c768538580960a3916869c98e863", + "head_branch": "main", + "run_started_at": "2026-10-12T07:10:16Z", + "created_at": "2026-10-12T07:10:16Z", + "run_attempt": 1 + }, + { + "id": 38214595481, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "246edf5b398a937126bb310cddbef9d3669bd89d", + "head_branch": "main", + "run_started_at": "2026-10-12T06:51:17Z", + "created_at": "2026-10-12T06:51:17Z", + "run_attempt": 1 + }, + { + "id": 38212930411, + "path": ".github/workflows/lint.yml", + "event": "schedule", + "status": "completed", + "conclusion": "success", + "head_sha": "1f312fdfa680d210cb801acad543c1598b02a053", + "head_branch": "main", + "run_started_at": "2026-10-12T06:31:21Z", + "created_at": "2026-10-12T06:31:21Z", + "run_attempt": 1 + }, + { + "id": 38212629843, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "1f312fdfa680d210cb801acad543c1598b02a053", + "head_branch": "main", + "run_started_at": "2026-10-12T06:27:47Z", + "created_at": "2026-10-12T06:27:47Z", + "run_attempt": 1 + }, + { + "id": 38211546063, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "e23eccb164e2e5bd4acc5221b2d9f733818258fa", + "head_branch": "main", + "run_started_at": "2026-10-12T06:14:12Z", + "created_at": "2026-10-12T06:14:12Z", + "run_attempt": 1 + }, + { + "id": 38210639975, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "21914c3fb3bd0f286716069944946d6a34b6a991", + "head_branch": "main", + "run_started_at": "2026-10-12T06:02:36Z", + "created_at": "2026-10-12T06:02:36Z", + "run_attempt": 1 + }, + { + "id": 38210359525, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "e5687cb6b326983d6260d0f6fd7f1c73cd6ba5ba", + "head_branch": "main", + "run_started_at": "2026-10-12T05:59:07Z", + "created_at": "2026-10-12T05:59:07Z", + "run_attempt": 1 + }, + { + "id": 38208625556, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "da9c2bf2f25d61a9fec44afc300b657ae10399bc", + "head_branch": "main", + "run_started_at": "2026-10-12T05:35:51Z", + "created_at": "2026-10-12T05:35:51Z", + "run_attempt": 1 + }, + { + "id": 38207409835, + "path": ".github/workflows/lint.yml", + "event": "schedule", + "status": "completed", + "conclusion": "success", + "head_sha": "51b43a8dc45fef9d86229509ab6b2679ffa9d501", + "head_branch": "main", + "run_started_at": "2026-10-12T05:19:04Z", + "created_at": "2026-10-12T05:19:04Z", + "run_attempt": 1 + }, + { + "id": 38204522449, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "success", + "head_sha": "51b43a8dc45fef9d86229509ab6b2679ffa9d501", + "head_branch": "main", + "run_started_at": "2026-10-12T04:37:11Z", + "created_at": "2026-10-12T04:37:11Z", + "run_attempt": 1 + }, + { + "id": 38203454908, + "path": ".github/workflows/lint.yml", + "event": "schedule", + "status": "completed", + "conclusion": "success", + "head_sha": "e357a441c6423798a673d4a39ecee4b31adacf79", + "head_branch": "main", + "run_started_at": "2026-10-12T04:21:35Z", + "created_at": "2026-10-12T04:21:35Z", + "run_attempt": 1 + }, + { + "id": 38203196362, + "path": ".github/workflows/lint.yml", + "event": "push", + "status": "completed", + "conclusion": "cancelled", + "head_sha": "e357a441c6423798a673d4a39ecee4b31adacf79", + "head_branch": "main", + "run_started_at": "2026-10-12T04:17:44Z", + "created_at": "2026-10-12T04:17:44Z", + "run_attempt": 1 + } + ] + } +} From 430d04b01d66693d06e50e779e49ae40f7ea1bec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:27:20 +0000 Subject: [PATCH 3/5] docs(agents): the required set is five contexts; two speeds, main-red, timing gates and test budgets are rules AGENTS.md: the merge-queue paragraph names the five required contexts (`Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Build Core`, `Governed Surface Queue Guard`); the two-speed doctrine and the main-red rule sit beside it; the timing-gate and test-budget rules join the clocked-windows rule in Build & Test; Post-Task item 4 says the full battery's Console Pin Gate makes a sibling-breaking removal a main-red, no longer a red on every PR. Provenance: ruling record 6120820653. Net +7 lines, exactly the file's headroom under its 1116 ceiling, paid by the two dropped names and item 4's rationale clause. check-required-contexts: the `Dogfood Regression Gate` and `Temporal Conformance (live PG + MySQL)` rows leave REQUIRED_CONTEXTS with a prose tombstone (neither name is dead), the two set-stating surfaces' mustName arrays follow, the self-test fixtures that mutated those jobs re-point to still-registered ones, the 2026-08-18 snapshot's direction-B reading is derived instead of asserted empty, and the direction-B report names the tombstone case so nobody re-adds a row the two-step dropped on purpose. platform-readings.md's required-set line follows (seven to five). Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB Co-authored-by: Claude --- .../references/platform-readings.md | 6 +- AGENTS.md | 35 +++--- scripts/check-required-contexts.mjs | 107 ++++++++++-------- 3 files changed, 86 insertions(+), 62 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/platform-readings.md b/.claude/skills/pm-dispatch/references/platform-readings.md index cbd618c0625..1ac7a953909 100644 --- a/.claude/skills/pm-dispatch/references/platform-readings.md +++ b/.claude/skills/pm-dispatch/references/platform-readings.md @@ -387,9 +387,9 @@ - 本车道强制多是过宽的回忆:`dispatch-gates.mjs --tier PATH` 逐路径现推,路径线是下限非放行。 - 该脚本只答自己那棵树:姊妹仓路径回 absent from this tree,姊妹仓的档位与条款②只能手推。 - `os-verify-lock.sh` 只住 objectstack:objectui 无它,跑本仓副本读的是容器级锁,非该仓深度。 -- required checks 的名单是每仓事实,objectstack 七个: - `TypeScript Type Check` · `Lint & Repo Gates` · `Test Core` · `Dogfood Regression Gate` · - `Build Core` · `Temporal Conformance (live PG + MySQL)` · `Governed Surface Queue Guard`。 +- required checks 的名单是每仓事实,objectstack 五个: + `TypeScript Type Check` · `Lint & Repo Gates` · `Test Core` · + `Build Core` · `Governed Surface Queue Guard`。 - ⭐ 本表的 `mustName` 声明 `statesTheSet` ⇒ 必须逐字等于注册表,加注册行不跟改本表即红。 - ⇒ 门绿即名单逐字齐全的读数;⛔ 但数量词未钉:实测 七→六 照绿,只能手跟改。 - `in_progress` 不是过;advisory 门禁红进 main 是共享损伤,照样止血立单。 diff --git a/AGENTS.md b/AGENTS.md index c4f0d0a61c3..b3aca28c029 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,13 @@ climbs out and comes straight back in. What it does flag is judged on the **shal point a path reaches, not where it ends. Clocked windows measure behaviour, never loading — a test that boots a real plugin chain -pays its first load at module top; `pnpm check:test-source-alias` gates it. +pays its first load at module top; `pnpm check:test-source-alias` gates it. A gate grading a +duration reports and ⛔ never reds a required job, judges only on the run profile it measured, +and when new soaks report-only a week before it grades; any seat flips a gate that reds untouched +packages twice in an hour to report-only before any ruling. A suite-weight ceiling rises only by +ruling (`RULED_CEILING_RAISES`); a test file over 10 s of wall is its lane's card, fixed and ⛔ +never deleted; a test is deleted only when its PR names a duplicate pinning that assertion at a +layer at least as close to the code (ruling record 6120820653). ### Running the dev server @@ -516,12 +522,16 @@ 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), - `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 - 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. + set** — five contexts block: `Lint & Repo Gates` (all `check:*` gates), + `TypeScript Type Check`, `Test Core`, `Build Core` and `Governed Surface Queue Guard`. A + check outside those five 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 five names as contract. + **Two speeds** (ruling record 6120820653): PR and queue runs carry the fast set, 10 minutes of + wall per queue entry; `main`'s hourly and push runs carry the full battery — dogfood, e2e, + `Console Pin Gate`, `Temporal Conformance (live PG + MySQL)` — read by the publish lane before + any approval. **Main-red:** a full-battery red is its lane's first work — triage files it under + the main-red convention, the lane's next PR fixes it, and ⛔ no lane PR arms the queue meanwhile. **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 @@ -1089,13 +1099,10 @@ Both non-handshake shapes, and how to classify and probe your own: body, which also carries the PR's `Clause-②` line: `pnpm check:adr-0087-registration` reads the arm there. ⛔ The categories are NOT copied here — the gate prints the full set when it fails. 4. **A removal that breaks the pinned sibling checkout ships together with the sibling fix and the pin bump — or it - does not ship.** The `Console Pin Gate` job builds objectui at the pinned `.objectui-sha` against **current** `main`, - so a removal or rename the pinned sibling still imports turns `main` red for every PR in the repo the moment it - merges — "retire the surface" and "leave the sibling untouched" cannot both hold. A ruling that authorizes such a - removal therefore implicitly authorizes the objectui-side fix and the pin bump as part of the same landing (the - bump's manifest regeneration included — see the Frontend section). Pre-merge check for any removal or rename - of an exported surface: does the pinned sibling import what you are removing? `git grep` it in `../objectui` at the - pinned SHA before merging. + does not ship.** The full battery's `Console Pin Gate` builds objectui at the pinned `.objectui-sha` against + **current** `main`, so such a removal is a main-red the moment it merges; its ruling therefore authorizes the + objectui-side fix and the pin bump (manifest regeneration included — see the Frontend section) in the same + landing. Before merging, `git grep` what you remove or rename in `../objectui` at the pinned SHA. 5. **Touched `packages/spec`? Regenerate and commit its artifacts before pushing** — § *Touched `packages/spec`* above is the authority; note `OS_SKIP_DTS=1` greens `check:api-surface` locally and reds it in CI. 6. Update `ROADMAP.md` if user-facing or architectural. diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 90f399376ab..7fce6339960 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -364,6 +364,21 @@ const UNATTRIBUTED_BATTERY = '(no battery open)'; * reporting the staleness — sat open, every gate green throughout. The count * is a property of the workflow, so the workflow is where it is read from: the * job's own step list, one grep away and never stale (#9103). + * + * ⛔ TOMBSTONE — `Dogfood Regression Gate` (ci.yml:dogfood-gate) and `Temporal + * Conformance (live PG + MySQL)` (ci.yml:temporal-conformance), dropped by the + * #22998 ruling (two-speed CI; ruling record 6120820653 on #22991): their legs + * run in the FULL BATTERY only — `main`'s push, hourly and dispatch runs — so a + * PR or queue build carries the fast set and these two contexts leave the + * required set. The order is the #9325 two-step: the registry and the + * instruction surfaces drop the rows in the PR, and the maintainer removes the + * two contexts from the ruleset in the same sitting as the merge; until then + * `--verify-required-set` reports them in direction B (required, pinned by no + * row), report-only, which is the sitting's own reminder. Prose and not a + * RETIRED_CONTEXT_NAMES row, for the reason the `Build Docs` / `Console Pin + * Gate` tombstone above gives: neither name is dead — both jobs still publish + * these check-runs on every full-battery run, and the instruction files name + * them correctly as full-battery jobs. */ export const REQUIRED_CONTEXTS = [ { @@ -398,13 +413,6 @@ export const REQUIRED_CONTEXTS = [ authorized: '#5617 closing ruling 2026-08-09, confirmation item 2 (the bare aggregate name)', carries: 'the sharded Test Core matrix, counted via check:shard-attestation (#6082/#3622)', }, - { - workflow: 'ci.yml', - job: 'dogfood-gate', - context: 'Dogfood Regression Gate', - authorized: '#5617 closing ruling 2026-08-09, confirmation item 2 (the bare aggregate name)', - carries: 'the sharded dogfood matrix + dogfood-verify, counted via check:shard-attestation', - }, { workflow: 'ci.yml', job: 'build-core', @@ -414,15 +422,6 @@ export const REQUIRED_CONTEXTS = [ 'the only member of that batch the 2026-08-10 ruleset screenshot (#7022) shows applied, and live in the 2026-08-18 read', carries: 'the only compile-regression gate in the repo', }, - { - workflow: 'ci.yml', - job: 'temporal-conformance', - context: 'Temporal Conformance (live PG + MySQL)', - authorized: - '#5617 closing ruling 2026-08-09, second batch; absent from the 2026-08-10 screenshot and present in the ' + - '2026-08-18 read, so it was applied somewhere between the two — no attestation names the day', - carries: 'the live-server datetime conformance axis (#3912/#3942)', - }, { workflow: 'governed-surface-guard.yml', job: 'governed-surface-guard', @@ -551,17 +550,17 @@ export const INSTRUCTION_SURFACES = [ // misclassification that puts a PR into the queue to be ejected. Then // 6→7 by #17798 — the same misclassification, one enrolment later and // with every gate green, which is what put the exclusivity rule below in - // the judge. The full set is pinned here so the corrected sentence cannot - // rot back — a rename in ANY of the seven now reddens this gate instead. + // the judge. Then 7→5 by #22998 (two-speed CI: the dogfood and temporal + // contexts moved to the full battery). The full set is pinned here so the + // corrected sentence cannot rot back — a rename in ANY of the five + // reddens this gate instead. file: 'AGENTS.md', statesTheSet: true, mustName: [ 'Lint & Repo Gates', 'TypeScript Type Check', 'Test Core', - 'Dogfood Regression Gate', 'Build Core', - 'Temporal Conformance (live PG + MySQL)', 'Governed Surface Queue Guard', ], }, @@ -585,12 +584,10 @@ export const INSTRUCTION_SURFACES = [ 'Lint & Repo Gates', 'TypeScript Type Check', 'Test Core', - 'Dogfood Regression Gate', 'Build Core', - 'Temporal Conformance (live PG + MySQL)', - // The seventh, enrolled 2026-08-27 (#12427) and pinned here by #15233. - // The ledger's own count line is hand-followed prose, so it is this - // array — held equal to the registry by the exclusivity rule in + // Enrolled 2026-08-27 (#12427) and pinned here by #15233. The ledger's + // own count line is hand-followed prose, so it is this array — held + // equal to the registry by the exclusivity rule in // judgeInstructionSurfaces — that makes the seat's copy non-optional. 'Governed Surface Queue Guard', ], @@ -614,8 +611,8 @@ export const INSTRUCTION_SURFACES = [ * own source, and "looks like a path" there means "carries a separator". Five * of the six surfaces have one; `AGENTS.md` does not, because a repo-root FILE * has no separator to be found by — so an AGENTS.md card derived this gate not - * at all, while that surface is the one carrying `mustName` for all seven - * required contexts (widened 2→6 by #9677, 6→7 by #17798). Editing the merge-queue + * at all, while that surface is the one carrying `mustName` for every + * required context (widened 2→6 by #9677, 6→7 by #17798, 7→5 by #22998). Editing the merge-queue * paragraph there is precisely how this gate goes red, and it was reachable * only by judgment. * @@ -1587,7 +1584,9 @@ export function renderRequiredSetReport(verdict) { '', ` ⛔ direction B — required in the live set, pinned by NO registry row (${verdict.unpinned.length}). Renaming the job`, ' that publishes one of these detaches its gate silently — the defect this whole file exists for. The remedy', - ' is a REQUIRED_CONTEXTS row naming the workflow, job id and authorizing ruling; no settings change.', + ' is a REQUIRED_CONTEXTS row naming the workflow, job id and authorizing ruling; no settings change —', + ' unless a TOMBSTONE above REQUIRED_CONTEXTS records the row as dropped on purpose: then the settings half of', + ' that two-step (removing the context from the ruleset) is what is still owed, and re-adding the row is wrong.', ); for (const entry of verdict.unpinned) lines.push(` • ${entry.context} — from ${entry.from.join(', ')}`); } @@ -1854,24 +1853,30 @@ async function selfTest() { renamedBuildCore.problems.some((p) => p.includes("job 'build-core'") && p.includes("'Build Core'")), 'renaming ci.yml\'s Build Core job ⇒ red (the newly-approved batch is covered too)', ); - const renamedTemporal = fixture('rename Temporal Conformance', 'ci.yml', (s) => - s.replace(' name: Temporal Conformance (live PG + MySQL)\n', ' name: Temporal Conformance\n'), + // Re-pointed from `temporal-conformance` (whose "(live PG + MySQL)" suffix + // it used to drop) when #22998 moved that job to the full battery: a fixture + // must mutate a job the registry still REGISTERS. The aggregate `Test Core` + // is the live specimen for the same property — a suffix grown onto a + // registered name is a rename, and the bare name never reports again. + const renamedTestCore = fixture('rename Test Core', 'ci.yml', (s) => + s.replace(' name: Test Core\n', ' name: Test Core (aggregate)\n'), ); assert( - renamedTemporal.problems.some((p) => p.includes("job 'temporal-conformance'") && p.includes('Temporal Conformance (live PG + MySQL)')), - 'dropping the "(live PG + MySQL)" suffix ⇒ red — the parenthetical is part of the contract, not decoration', + renamedTestCore.problems.some((p) => p.includes("job 'test-gate'") && p.includes("'Test Core'")), + 'growing a suffix on the bare `Test Core` name ⇒ red — the exact literal is the contract, not decoration', ); // ── (2) the job disappearing entirely ───────────────────────────────────── // Re-pointed from `console-pin` to `temporal-conformance` when #9533 dropped - // the console-pin row: a fixture must mutate a job the registry still + // the console-pin row, and from `temporal-conformance` to `build-core` when + // #22998 dropped that one: a fixture must mutate a job the registry still // REGISTERS, or it asserts nothing while reading exactly as before. battery('(2) the job disappearing entirely'); - const droppedJob = fixture('drop the temporal-conformance job', 'ci.yml', (s) => - s.replace('\n temporal-conformance:\n', '\n temporal-conformance-disabled:\n'), + const droppedJob = fixture('drop the build-core job', 'ci.yml', (s) => + s.replace('\n build-core:\n', '\n build-core-disabled:\n'), ); assert( - droppedJob.problems.some((p) => p.includes("job 'temporal-conformance' no longer exists") && p.includes('Temporal Conformance (live PG + MySQL)')), + droppedJob.problems.some((p) => p.includes("job 'build-core' no longer exists") && p.includes('Build Core')), 'a required context whose job id is gone ⇒ red', ); @@ -2395,17 +2400,18 @@ async function selfTest() { REQUIRED_CONTEXTS.every((e) => ledgerEntry.mustName.includes(e.context)), `the readings ledger is a scanned surface naming all ${REQUIRED_CONTEXTS.length} required contexts — got ${JSON.stringify(ledgerEntry)}`, ); - // A rename reaches it. Read from the file as it ships, with ONE of the six - // replaced by a name that never existed: the baseline above is green, so - // the literal is present and this substitution cannot be a no-op. + // A rename reaches it. Read from the file as it ships, with ONE of the + // registered names replaced by a name that never existed: the baseline + // above is green, so the literal is present and this substitution cannot be + // a no-op. (`Build Core` since #22998 dropped `Dogfood Regression Gate`.) const ledgerRenamed = judgeSurfaces({ files: surfaceMap({ - [LEDGER_SURFACE]: surfaceSources[LEDGER_SURFACE].replaceAll('Dogfood Regression Gate', 'Nonexistent Job Name'), + [LEDGER_SURFACE]: surfaceSources[LEDGER_SURFACE].replaceAll('Build Core', 'Nonexistent Job Name'), }), }); assert( ledgerRenamed.problems.some( - (p) => p.includes(LEDGER_SURFACE) && p.includes("'Dogfood Regression Gate'") && p.includes('no longer names'), + (p) => p.includes(LEDGER_SURFACE) && p.includes("'Build Core'") && p.includes('no longer names'), ), 'renaming a required context in the readings ledger ⇒ red on the ledger, naming the dropped literal', ); @@ -2456,7 +2462,7 @@ async function selfTest() { ); // THE PIN: a set-stating surface one name short of the registry ⇒ red, // naming the surface and the literal it dropped. The FILES are the ones that - // ship — AGENTS.md still names all seven — so the floor half sees nothing + // ship — AGENTS.md still names all five — so the floor half sees nothing // and this red can only be the census. const shortOfTheSet = judgeSurfaces({ surfaces: INSTRUCTION_SURFACES.map((s) => @@ -2540,8 +2546,8 @@ async function selfTest() { // Enforcement cannot hold any of these: the declaration is read by another // tool entirely, so a wrong or missing entry runs perfectly green here and // shows up only as a dev dispatched on an AGENTS.md card with this gate - // absent from the brief — on the surface that carries `mustName` for all - // seven required contexts. + // absent from the brief — on the surface that carries `mustName` for every + // required context. battery('the dispatch-gates declaration (#9979)'); assert( INSTRUCTION_SURFACES.map((s) => s.file) @@ -2635,7 +2641,18 @@ async function selfTest() { assert(snapshot.live.length === 6, `the 2026-08-18 reading carries six required contexts — got ${snapshot.live.length}`); assert(snapshot.strict === false, 'strict_required_status_checks_policy is read, not assumed'); assert(snapshot.enforcing.length === 1 && !snapshot.noEnforcingRuleset, 'one active ruleset covers the default branch'); - assert(snapshot.unpinned.length === 0, `every context in the 2026-08-18 reading has a registry row — got ${JSON.stringify(snapshot.unpinned)}`); + // Direction B against the checked-in registry, derived for the same reason + // as direction A below: the 2026-08-18 reading still carries the two + // contexts #22998 dropped from the registry (`Dogfood Regression Gate`, + // `Temporal Conformance (live PG + MySQL)`), so the snapshot reports them + // as required-and-unpinned — exactly the reading the maintainer's + // settings half of that two-step clears. + const pinned = new Set(REQUIRED_CONTEXTS.map((e) => e.context)); + const unpinnedNames = snapshot.unpinned.map((e) => e.context ?? e).sort(); + assert( + JSON.stringify(unpinnedNames) === JSON.stringify(snapshot.live.filter((c) => !pinned.has(c)).sort()), + `direction B reports exactly the live contexts no registry row pins — got ${JSON.stringify(snapshot.unpinned)}`, + ); // Direction A against the checked-in registry. It was LIVE when this was // written (`Build Docs` / `Console Pin Gate`, #9533) and is EMPTY since // that card dropped the two rows — which is exactly why the assertion is From 5354b52334cdb3c33018ad496491c2516ed79578 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:27:20 +0000 Subject: [PATCH 4/5] docs(pm-dispatch): main-red convention 3 and the report-only flip exception landing-operations.md gains main-red convention 3 (a full-battery red is the owning lane's first work; while it stands, no PR of that lane arms the queue), paid by deleting the third pointer to contract-review.md that line 12 already carries. triage-duties.md gains exception 2 to the human floor (any seat flips a gate that reds untouched packages twice in an hour to report-only, the ruling following; the sole-exception wording becomes exception 1), paid by deleting the Bug-type restatement of line 33's pm:queue admission rule. Both files stay at their ceilings. Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB Co-authored-by: Claude --- .claude/skills/pm-dispatch/references/landing-operations.md | 2 +- .claude/skills/pm-dispatch/references/triage-duties.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/landing-operations.md b/.claude/skills/pm-dispatch/references/landing-operations.md index 1ef5de79cd2..71bc94ba0d1 100644 --- a/.claude/skills/pm-dispatch/references/landing-operations.md +++ b/.claude/skills/pm-dispatch/references/landing-operations.md @@ -16,7 +16,6 @@ - 记录须点名被改 note、逐句判改写句,缺一不算;算即按 SKILL.md 三条件带红入队,门禁不改。 - 真正设计分叉照旧进决策箱,席内复核 ⛔ 不替代维护者裁定。 - 外部评审链降为可选事后审计,非放行前提。 -- 契约复核的适用面、资格与归属见 `references/contract-review.md`。 - 默认分支 push 即触发对外部署的仓,只在验证层存在时才合并;无验证层 ⛔ 不合并。 - 验证层 = 部署以 CI 为闸、发布后探测线上面、失败自动回滚并立卡。 - 该类仓的落地判据是已发布且探测通过,⛔ 不是 MERGED;跟到发布为止。 @@ -81,6 +80,7 @@ - main-red 约定①:p0 fix-forward 允许跳队,限 p0、机械、根因已核实的止血 PR。 - 也可按既有 governed 例外由维护者人工直合一行修复;仅限 main-red 修复。 - main-red 约定②:一个失败 check 只锚一张卡,先立者赢,后见者评论到锚卡,⛔ 不另立。 +- main-red 约定③:main 全量电池红是所属车道首要工作;红未消,该车道 PR ⛔ 不挂队。 ## C. 依赖前棒才能转绿的 PR:draft 停放 + 签名级预期红清单 diff --git a/.claude/skills/pm-dispatch/references/triage-duties.md b/.claude/skills/pm-dispatch/references/triage-duties.md index 388e074ec64..4c7bbc5657d 100644 --- a/.claude/skills/pm-dispatch/references/triage-duties.md +++ b/.claude/skills/pm-dispatch/references/triage-duties.md @@ -53,7 +53,6 @@ - 线程有 `Ruling:`/RULED 的卡再入箱,该行逐 id 各一句变化;缺行 ⇒ 拒入,`pm:retriage` 回立卡者。 - `type`(Bug/Feature/Task)是分诊固定产出,分诊席唯一权威生产;立单者可预填,分诊校正。 - 判据:违背已声明契约 ⇒ `Bug`,扩大接受集/公开面越出已声明 ⇒ `Feature`,其余 ⇒ `Task`。 -- `Bug` 无具名落点或复现路径不入 `pm:queue`(标记补复现)。 - ⛔ 不回填存量 backlog 的 type;新卡即时打、存量卡下次碰到补。 - 定级按北极星「优先级」1–3 条;首行 `Path: <定义项> | <清单项 id/缺项> | P<路步> | none`。 - 定义项取北极星「做出来的是什么」或仪器卡的车队决定;无则关;`none` 非分诊缺口。 @@ -96,7 +95,8 @@ - 代裁人工地板(恒交维护者):功能新增;ADR;协议/公开契约变化;破坏性或难回滚动作。 - 人工地板同含:安全/权限边界;门禁削弱(降阈/删必查/抬上限/跳测);新增运行时第三方依赖; - 花费/配额/舰队形态/默认模型档位;新增必需门禁/hook/棘轮(卡具名所守契约与预期捕获)。 -- 唯一例外:`platform-readings.md` 增量抬上限到落地行数,免决策卡,记 `ruledRaises` 引常设裁决。 +- 例外一:`platform-readings.md` 增量抬上限到落地行数,免决策卡,记 `ruledRaises` 引常设裁决。 +- 例外二:一小时内两次红未触包的门禁,任一席位先翻 report-only,裁定随后。 - 条件:席位验收评论逐条核实、去重计数(候选/落地/已有/拒收)、一事一行、不计重排。 - 代裁车道只覆盖代码整理(行为不变的重构)与 bug 修复。 - 机械边界测试:改动扩大接受集或公开面 ⇒ 人工;拉回已声明契约 ⇒ 代裁车道。 From 476361e4ac451093e1457cd3d2dfc8100d059734 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:46:44 +0000 Subject: [PATCH 5/5] fix(pm): dispatch-gates still derives the full-battery jobs' leads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci.yml's moved jobs now spell `&& github.event_name != 'pull_request' && github.event_name != 'merge_group'` after their filter output, and the strict `if:` whitelist refused the whole expression: a `.objectui-sha` card derived no console gates and a dogfood card no dogfood suite, exactly when a PR no longer runs those jobs and this derivation is the only place the dev hears of them before a main-red. The clause is stripped like `!cancelled()` — exactly as spelled, only as the last conjunct; reordered, partial, positive or leading spellings still refuse (five new cases). The marker census follows check-expected-skips' header growing two lines. Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB Co-authored-by: Claude --- scripts/pm/dispatch-gates.mjs | 13 ++++++++++++ scripts/pm/dispatch-gates.self-test.mjs | 27 ++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 72aa08d0b2b..1a8d19a51fc 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -1178,8 +1178,20 @@ export function extractTriggerPaths(workflowText) { * is refused whole. `!cancelled()` is the one term stripped, because it is a * status function that discriminates no path — THE FILTER CONTRACT on ci.yml's * `filter` job is why every one of these `if:`s carries it. + * + * ⭐ The FULL-BATTERY clause is stripped too, exactly as spelled and only as the + * expression's LAST conjunct (#22998, ci.yml's THE TWO SPEEDS): `&& github. + * event_name != 'pull_request' && github.event_name != 'merge_group'`. It + * discriminates EVENTS, never paths — the job still judges exactly the paths its + * filter output names, on `main`'s push and hourly runs — and since a PR no + * longer runs such a job, this derivation is the only place a card's dev hears + * of it before the red lands on `main`. Refusing it would drop the console and + * dogfood leads this function exists to give. Any other event term, a reordered + * clause or one event alone is still refused whole. */ const JOB_IF_STATUS_TERM = /(?:^|\s)!\s*cancelled\(\)\s*&&\s*/g; +const JOB_IF_FULL_BATTERY_TERM = + /\s*&&\s*github\.event_name\s*!=\s*'pull_request'\s*&&\s*github\.event_name\s*!=\s*'merge_group'\s*$/; const JOB_FILTER_OUTPUT_TERM = /^needs\.([A-Za-z_][\w-]*)\.outputs\.([A-Za-z_][\w-]*)\s*(?:!=\s*(['"])false\3|==\s*(['"])true\4)$/; @@ -1195,6 +1207,7 @@ export function jobFilterOutputRefs(ifExpression) { const wrapped = /^\$\{\{([\s\S]*)\}\}$/.exec(expr); if (wrapped) expr = wrapped[1].trim(); expr = expr.replace(JOB_IF_STATUS_TERM, ' ').trim(); + expr = expr.replace(JOB_IF_FULL_BATTERY_TERM, '').trim(); // One balanced outer paren pair at a time — `(A || B)` is the live spelling. for (;;) { if (!expr.startsWith('(') || !expr.endsWith(')')) break; diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index a9bf2bb3db6..49a89d2c4fb 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -5484,6 +5484,30 @@ export function selfTest({ tier = 'full' } = {}) { t('a term this cannot read refuses the WHOLE expression', jobFilterOutputRefs("${{ github.event_name == 'push' || needs.f.outputs.a != 'false' }}") === null); t('an if: naming no filter output at all yields no population', jobFilterOutputRefs("${{ github.ref == 'refs/heads/main' }}") === null); t('an absent if: is not an expression', jobFilterOutputRefs(null) === null); + // #22998: the full-battery clause ci.yml's moved jobs carry discriminates + // EVENTS, not paths — stripped exactly as spelled, and only as the last + // conjunct; every near-miss still refuses the whole expression. + const FULL_BATTERY = "github.event_name != 'pull_request' && github.event_name != 'merge_group'"; + t( + 'the full-battery event clause is stripped like !cancelled(): the job still judges its filter output', + jobFilterOutputRefs(`\${{ !cancelled() && needs.filter.outputs.console != 'false' && ${FULL_BATTERY} }}`)?.map((r) => `${r.job}.${r.output}`).join() === 'filter.console', + ); + t( + '...but only as spelled: the two events reordered refuse', + jobFilterOutputRefs("${{ needs.f.outputs.a != 'false' && github.event_name != 'merge_group' && github.event_name != 'pull_request' }}") === null, + ); + t( + '...one excluded event alone refuses — it is not the full-battery clause', + jobFilterOutputRefs("${{ needs.f.outputs.a != 'false' && github.event_name != 'pull_request' }}") === null, + ); + t( + '...a positive event term refuses — it says when the job runs, not that a PR never does', + jobFilterOutputRefs("${{ needs.f.outputs.a != 'false' && github.event_name == 'push' }}") === null, + ); + t( + '...and the clause in front of the filter output is not the stripped spelling either', + jobFilterOutputRefs(`\${{ ${FULL_BATTERY} && needs.f.outputs.a != 'false' }}`) === null, + ); const fixturePops = jobPathPopulations(jobFilterWf, 'fixture.yml'); t( @@ -7289,7 +7313,8 @@ export function selfTest({ tier = 'full' } = {}) { // bare diff-scoped run declares the two step-`env:` values it does not need. 'scripts/check-issue-citations.mjs:204 local-env', 'scripts/cli-build-prerequisite.mjs:111 inherited-population', - 'scripts/pm/check-expected-skips.mjs:131 self-test-reads', + // 131 -> 133 (#22998): the header gained two lines naming the full-battery-only jobs. + 'scripts/pm/check-expected-skips.mjs:133 self-test-reads', 'scripts/pm/dispatch-gates.mjs:736 inherited-population', ].join(' · '), censusRows.join(' · '),