Skip to content

ci: two-speed CI — PR and queue runs carry the fast set, the full battery runs on main and gates the publish lane, required set 7 → 5 - #23020

Merged
os-zhuang merged 6 commits into
mainfrom
claude/issue-22998-two-speed-ci
Oct 12, 2026
Merged

os-zhuang merged 6 commits into
mainfrom
claude/issue-22998-two-speed-ci

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22998
Clause-②: no

Two-speed CI: a PR or queue build carries the fast set, main's push, hourly and dispatch runs carry the full battery, and the publish lane reads the full battery before the release approval exists. This PR implements items 1, 2, 3 and 5 of the card. Item 4, the ruleset change, is the maintainer's act; its exact before and after are below. The maintainer's words are in the card body and in ruling record 6120820653 on #22991.

Every governed path here is Tier H (AGENTS.md), so this PR stays a draft until a GOVERNED_APPROVERS member approves it. No seat flips it ready, queues it or arms auto-merge.

Item 4: the maintainer's settings act (no seat can do this)

Where: Settings, then Rules, then Rulesets, then the main ruleset (id 12119582), then "Require status checks to pass", then the list of required status checks. The merge queue enforces the same list.

required status checks
before (7) Lint & Repo Gates · TypeScript Type Check · Test Core · Dogfood Regression Gate · Build Core · Temporal Conformance (live PG + MySQL) · Governed Surface Queue Guard
after (5) Lint & Repo Gates · TypeScript Type Check · Test Core · Build Core · Governed Surface Queue Guard

Remove exactly two checks: Dogfood Regression Gate and Temporal Conformance (live PG + MySQL). Do it in the same sitting as the merge. This follows the two-step that scripts/check-required-contexts.mjs documents: this PR drops the two REQUIRED_CONTEXTS rows, and the ruleset follows. Until the ruleset changes, check-required-contexts --verify-required-set lists both checks in direction B (required, but pinned by no row). That report is report-only. This PR also changes the direction-B text so that a dropped row recorded in the TOMBSTONE is answered by the settings change, never by adding the row back.

Why the order does not risk a stuck queue (this refutes part of PM hypothesis H1, which said "a skipped required job counts as a pass only for a context that is no longer required"):

  • Dogfood Regression Gate: the aggregator job keeps if: always(). On a PR or queue run, both of its legs skip, and the aggregator counts zero expected attestations and concludes success. Measured locally: check-shard-attestation.mjs --verify with --leg dogfood/3:skipped --leg dogfood-verify/1:skipped and a successful filter prints "satisfied — no shard was expected to run, and none claimed to", exit 0.
  • Temporal Conformance (live PG + MySQL): on a PR or queue run, its job-level if: is false, so the check run concludes skipped. Branch protection counts skipped as passing. The repo's own record says so (the "Why if: is deliberately NOT asserted" section of check-required-contexts.mjs), and so does GitHub's documentation for a job skipped by its own condition.
  • Both checks are still worth removing from the ruleset. A required check that is always skipped or always passes on PRs blocks nothing, and leaving it there means the stored ruleset disagrees with the registry.

What changed (five commits plus a merge of main; read them one at a time)

  1. ab6fc3e5 ci.yml. Four jobs gain one event clause in their if:: temporal-conformance, dogfood (three shards), dogfood-verify and console-pin. The clause is github.event_name != 'pull_request' && github.event_name != 'merge_group'. The on: block is unchanged; it already had push: main, an hourly schedule and workflow_dispatch. A PR or queue run now carries Build Core, Test Core (affected-only, as before) and Build Docs. A new comment block above jobs:, "THE TWO SPEEDS", explains the split. Comments that called these contexts "required", or said they run "before it enqueues", now say what is true. check-expected-skips' roster reasons for the four rows now say why they skip on every PR (the event), instead of promising a run in the queue build.
  2. eba3a868 release.yml and its rule. A new full-battery job runs scripts/ci/scheduled-full-run.mjs --release-gate --release-commit VERSION_COMMIT. publish now needs it (needs: [release-integrity, full-battery]), and publish's if: opens with success(), so a refusal skips publish and no deployment is queued at the release environment. The gate job runs under exactly the same condition as publish. It has actions: read and checks out with fetch-depth: 0. The rule, pinned in the script's new HALF 3, applies to CI and to Lint & Type Check:
    • Candidates are schedule or push runs on main that are completed and whose head is the version commit or one of its first-parent ancestors.
    • A cancelled run carries no verdict and is skipped.
    • The nearest commit that has a candidate decides. On that commit, the newest run decides (by run_started_at, which a re-run updates).
    • Both workflows must have concluded success. Any other conclusion, or no candidate at all, refuses. An unreadable API or a shallow clone refuses with exit 3.
  3. 430d04b0 AGENTS.md and the pins that state the required set:
    • The merge-queue paragraph names the five required checks.
    • 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 now says a sibling-breaking removal becomes a main-red through the full battery's Console Pin Gate (no longer a red on every PR).
    • check-required-contexts.mjs drops the two rows and records them in a prose TOMBSTONE; neither name is dead. The two set-stating mustName arrays follow. Self-test fixtures that changed those jobs now change jobs that are still registered. The direction-B reading of the 2026-08-18 snapshot is derived from the registry instead of asserted empty.
    • platform-readings.md's required-set line goes from seven names to five.
  4. 5354b523 The two pm-dispatch references. Each gains one line, and each pays for it by deleting one line:
    • landing-operations.md gains main-red convention 3: a full-battery red is the owning lane's first work, and while it stands that lane's PRs do not arm the queue. It pays by deleting line 19, the third pointer to contract-review.md; line 12 already points there for the same subject.
    • 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, and the ruling follows. The old "sole exception" wording becomes exception 1. It pays by deleting the Bug-type line, which only restates line 33's pm:queue admission rule for one type.
  5. 476361e4 dispatch-gates. The tool's strict if: whitelist refused the new clause. As a result, a .objectui-sha card derived no console gates and a dogfood card derived no dogfood suite, at the moment a PR stopped running those jobs. The clause is now stripped like !cancelled(), but only when spelled exactly and only as the last condition. Five new cases show that a reordered, partial, positive or leading spelling still refuses. The marker census follows check-expected-skips' header, which grew by two lines.

Line ceilings: AGENTS.md grows 1109 to 1116, exactly its ceiling of 1116 (+7; the space comes from the two dropped names and from item 4's rationale clause). landing-operations.md stays 101/101, triage-duties.md 120/120 and platform-readings.md 460/460. No ceiling was raised.

PM mechanism hypotheses, as measured

  • H1 (ci.yml): confirmed, with four corrections.
    • The on: block already had schedule (hourly, '0 * * * *') and push: main; it is unchanged. The needs: chains and the filter job are unchanged (check-aggregator-roster green).
    • The dogfood matrix has three shards, not six.
    • The aggregator keeps if: always() instead of taking the event clause. check:shard-attestation pins if: always() on every attestation-counting gate, and keeping it makes the transition above safe under any reading of how a skipped job counts.
    • The clause is written as a deny-list of the two per-PR events, so workflow_dispatch also runs the full battery. ci.yml calls dispatch "the same full battery, on demand". The card's acceptance dry run "on a branch" also needs it: push is filtered to main and schedule runs only on the default branch.
    • The skipped-required claim is refuted above.
  • H2 (release.yml): confirmed, with one change of location. The rule lives in scripts/ci/scheduled-full-run.mjs (HALF 3), not in a new small file. check-self-test-wired requires that any script a workflow names that ships a --self-test has that self-test run by CI. Every self-test runs in lint.yml, which this PR must not touch (ci(lint): measure where Lint & Repo Gates' 198 serial steps spend 14–37 minutes, and bring the maintainer a split plan (maintainer-directed) #22991). The --self-test of scheduled-full-run.mjs already runs there. The new part of the rule is skipping cancelled runs. In the 2026-10-12 recording, 62 of 100 CI push runs and 81 of 100 Lint & Type Check push runs were cancelled, so counting them would refuse almost every release.
  • H3 (AGENTS.md): confirmed. "seven contexts" was at line 519. AGENTS.md has no section literally named "gates". The timing and test rules join the clocked-windows rule in Build & Test, which is the closest home that needed no new heading.
  • H4 (references): confirmed. One line in each file, as listed above.
  • H5 (pins): the pins that changed were check-required-contexts (registry, surfaces, fixtures), check-expected-skips (reasons) and dispatch-gates (recognizer and census). check-ci-filter-parity, scheduled-full-run --self-test, check-harness-current --self-test and release-verify-npm --self-test stayed green with no edit. No gate was loosened.

The release gate: proof without a release

A seat never runs the release workflow (Prime Directive #15), so the live path in release.yml is first verified by the maintainer's next release. Up to that point:

  • Recorded listing. scripts/fixtures/release-full-battery/runs-2026-10-12.json holds real runs read from the Actions jobs API and trimmed, plus 25 first-parent shas. With the release commit at 0524c1619b, the gate passes: CI on run 38222448090 (schedule, at 712361b7a8, newer than the push success on the same commit), Lint on run 38223935346. A commit two back holds only cancelled runs, and it is skipped. With the release commit at 19805d8956, the gate refuses CI alone, naming run 38217670691 (the 07:25 hourly run failed and its own push run was cancelled). Lint's nearest verdict there is green. Runs on commits after the release commit are never chosen.
  • Synthetic cases (14). A nearer red beats an older green. On one commit, a newer re-run decides. merge_group, pull_request and workflow_dispatch runs are ignored. Runs not on main, unfinished runs, and another workflow's runs are ignored. startup_failure refuses. An empty history refuses. Either workflow red refuses, and only that workflow is named.
  • Wiring (5). On the release.yml that ships, the gate job runs --release-gate, publish needs it, publish's if: opens with success(), and the gate job has no continue-on-error. Each of these was changed in a copy and observed red.
  • Ablations, each made through scripts/ablation-replace.mjs with the anchor confirmed hit (1 to 0) and restored to the HEAD blob bc87f666f6d2 (git diff HEAD empty):
    • Removing the cancelled filter turned the self-test red: "0524c1619b should pass, refused: CI: ... concluded cancelled -- run 38224326416".
    • Removing nearest-commit ordering turned it red: "an older green outvoted a nearer red". The recorded cases alone stayed green under this change, which is why the synthetic case exists.
  • Live read. The selection ran over the real Actions API: 600 runs per workflow (3 pages for each of 2 events), with the first-parent window at 48cca4ae00. Result: PASS. CI used push run 38224556104 at 6a40a09ed9. Lint used schedule run 38223935346 at 712361b7a8, 6 commits back, because the nearer push runs were cancelled. The CLI itself refuses a shallow clone (exit 3) and a missing token (exit 3), as the release job's checkout requires.

Gates, final union at f680be36

These ran after the last commit and the merge of origin/main. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 70 commands from this diff (12 paths, three-dot against 794177d9b).

  • All 70 exited 0. The --ran reconciliation with every exit code recorded reported: "Run reconciliation — 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN", "a DERIVED zero — all 70 recorded an exit code and none of them is 3".
  • pnpm check:pm-dispatch-gates (fast tier): 1833 cases pass. The full self-test (dispatch-gates.mjs --self-test, fast and slow tiers) reported "✓ dispatch-gates self-test: 2093 cases pass" (fast 1833 in 258.1s, slow 260 in 768.1s).
  • The four Build Core guards that read dist/ (check:dts-closure, check:dual-build-cjs-loads, check:lean-entry-closure, check:sourcemap-no-sources-content) ran over a fresh build of the merged tree: turbo run build --filter=!@objectstack/docs, 73/73 tasks, under os-verify-lock, "VERDICT command-exit 0".
  • NOT MEASURED, although they exited 0: the production halves of check:console-sha and check:console-injection. Both print "No console dist at packages/console/dist — skipping"; building that dist needs the objectui build. The check:console-injection self-test did run (67 assertions). On this PR, CI also skips Console Pin Gate because of its new full-battery clause, so this ci.yml first meets the console pin on main's push run after the merge.
  • The red this battery caught, and its fix. Before 476361e4, the fast tier was red: 8 of 1828 cases failed. Four were live console-job cases, three were live dogfood-job cases, and one was a marker-census line number. All eight came from this PR's ci.yml and check-expected-skips edits and are fixed in 476361e4 (see commit 5 above).
  • YAML. ci.yml and release.yml parse with the repository's yaml package.
  • check-governed-merges.mjs --branch claude/issue-22998-two-speed-ci at the final head: "governed-surface predicate: 4 of 12 path(s) hit the register (6 surfaces, repo-agnostic). ⛔ GOVERNED — a human merge is the review record for this PR (governance: human merge IS the review record for governed surfaces — retire the per-PR ADR approval check, add a report-only post-merge audit #9495 regime). ⚖️ landing tier: H(人合) — the maintainer's hand, or an authorized APPROVED review (GOVERNED_APPROVERS) and then the owning seat lands it." Also: "size: 1310 changed line(s) (+1207 / -103) ≤ 3000 — under the human-merge threshold".
  • Changeset. This diff publishes nothing. It changes only workflows, root scripts/, .claude/** and AGENTS.md, and no package's files[] ships any of them. So the PR carries skip-changeset.

Open question (for the lane, not decided here)

The release gate reads schedule or push runs, as ruled. A push run's Test Core is affected-only (#16467), so the nearest verdict can be a narrow push success sitting on top of an older red hourly run that nothing fixed. Options:

  • (A) Keep the ruled rule, and after the first releases measure how often the deciding run was a push run.
  • (B) Read schedule runs only. This is the real full battery, but the deciding run can then be up to an hour before the version commit.
  • (C) Also require the newest hourly verdict at or before the version commit. A flaky hourly red would then hold a release until someone re-runs it, because later hourly runs are on later commits.

Recommendation: A, measured after the first release.

Acceptance notes

  • e2e. AGENTS.md now places e2e in the full battery. This PR moved no e2e job, so where e2e runs on a PR today is unchanged and unmeasured here.
  • Pin-bump PRs. A .objectui-sha PR no longer runs Console Pin Gate before it lands; this PR's own ci.yml change is one such case. dispatch-gates again derives check:console-sha and check:console-injection for such a card, but both skip without a built console (packages/console/dist). In practice a pin bump is first measured on main, and the main-red rule then applies.
  • Dispatch runs. workflow_dispatch runs of CI are full battery, but the release gate does not count them, because the ruled text says schedule or push.
  • Stale comment, before and after this PR. A parenthetical in publish-smoke.yml says branch protection requires "TypeScript Type Check, Build Core, Test Core and Dogfood Regression Gate". That was already incomplete before this PR (no owner for the fix).
  • Wording. check-aggregator-roster still calls dogfood-gate a "required aggregator". Its roster discipline still holds and is stricter than needed.
  • Workflow-run count. The gate adds one job run per release (about 1 minute, reads only).

维护者速读(草稿)

改了什么

PR 与合并队列只跑快速集(Build Core、Test Core 受影响包、Build Docs 与 lint 门禁);Dogfood、Temporal Conformance、Console Pin Gate 改为只在 main 的 push、每小时定时与手动触发运行(全量电池)。发版流水线在等你点"批准"之前,先读 main 上版本提交及其之前最新一次全量电池结果,任一工作流不绿就不排发布审批。AGENTS.md 与两份 PM 细则写入两速、main 红即停车道、计时门禁只报不拦、测试只设预算不删除等规则。

为什么改

你的原则是"能让整个车队提速的就是好方案,发版前测好即可"。实测一次入队约 122 个 runner 分钟、14–36 分钟;把最慢的几条腿移出每个 PR,预计 PR 反馈约 5–6 分钟、入队约 8 分钟,测试强度不降,只是挪到 main 与发版前把关。

风险与代价(含回滚)

  • 破坏 dogfood、时区或控制台 pin 的改动会先进 main,由每小时全量电池发现(main 红),不再在 PR 上拦下;靠"main 红即停该车道"规则与发版闸兜底。
  • 发版闸首次真实运行要等你下一次发版才验证;若误拦,可重跑那次红的 CI 后用发布修复通道重新排。
  • 回滚:revert 本 PR,并在规则集里把两项必需检查加回即可。

席位意见

你要做的

  1. 审阅并批准本 PR(受管面,需要你的批准)。
  2. 合并后同一时间,在 Settings → Rules → Rulesets → main 的必需检查里删除 Dogfood Regression Gate 与 Temporal Conformance (live PG + MySQL) 两项(删前 7 项,删后 5 项,见上表)。

Generated by Claude Code

…ttery 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 <noreply@anthropic.com>
…val 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 <noreply@anthropic.com>
…, 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 <noreply@anthropic.com>
…eption

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd documentation Improvements or additions to documentation size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants