Skip to content

ci(release): start the next snapshot preflight on a daily schedule, stopping at the maintainer approval (ADR-0125 amendment) - #30862

Draft
objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/issue-30832-snapshot-daily-schedule
Draft

objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/issue-30832-snapshot-daily-schedule

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread
Fixes #30832
Ruling-ref: 6677910954
Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:next 快照通道每天 01:17Z(北京 09:17)自动跑一遍与手动触发完全相同的预检;通过后快照任务停在 release 环境等你批准,不批准就不发布;昨天没人批准的定时提示被今天的替换。按你 11:32Z 「同意」的五点落地:定时跑到五种预期内的"没得发"(候选 commit 就是当前 next 已发的那个、处于 RC 周期、有包还没首发、main 没有全绿 commit、稳定发布的提示在等批准或正在发布)时静默跳过——绿色、不排队、不开卡,只留一条 notice 和摘要;手动触发仍是响亮的红,也不开卡。真正的异常(读不到、组不对、干跑失败)或批准后发布失败(含发了一半)只开/刷新一张固定标题的卡(复用 scheduled-full-run-card 的开卡器),卡里写明:半截发布只能用新时间戳重发一份完整快照,⛔ 不往半截版本里补发,⛔ 不碰 latest。每份快照把源 commit 写进 gitHead(TypeScript nightly 的做法),预检从 npm 读回来做"同一 commit"判断。第三轮按完整评审改了五处:① 手动触发遇到"main 没有全绿 commit"时,选 commit 的步骤原来只写 reason 不写 refusal,开卡任务会把这次预期内的红误判为异常并开一张假卡——现已两条分支都写 refusal=no-green-commit,自测对每条分支各钉一例;② 预检和快照任务原来在切到选中的旧 commit 之后,用那棵旧树里的 snapshot-next.mjs 跑新模式,合并后头几天必红并开假卡——现在两个 job 都先从本 workflow 的 commit(GITHUB_SHA)把 scripts/ 取到 $RUNNER_TEMP,所有 snapshot-next.mjs 调用都从那份副本跑,SNAPSHOT_NEXT_ROOT 指向工作区;③④ ADR 两处措辞(见下);⑤ 干跑的三个提前返回现在都写 reason,卡上不再出现"没有记录原因"。
  • 为什么改:[Decision] Release cadence: a week of merges is about 1,700 PRs, so each QA-gated release carries a very large delta and app repos wait days to install fixes #29466 裁决(6677910954)选了 A「每天定时预检,停在批准」;五个落地细节你在 2026-11-06 11:32Z 的五点清单上「同意」。按原先"拒绝就红"的做法,在 @objectstack/service-push 首发之前每天都会红一次,而没人会看定时任务的失败邮件(GitHub 只发给 cron 作者);静默跳过加一张卡,让"每天没什么可发"安静、"真出问题"有人看到。第三轮的改动都是让树真正做到第二轮文字所说的:预期内的拒绝在手动触发下是红但不开卡;合并后的第一天不会因为旧脚本误开卡。
  • 风险与回滚:定时仍只能启动预检并排队提示,schedule 不能发布(方案 B 没选);稳定通道的每个 job 在这个 cron 上都不运行,stale-prompts 原样不动,⛔ 第 15 条 Prime Directive 不变。静默跳过只在 schedule 事件生效,靠一处表达式开关;预检任务把 refusal/reason 写成输出,快照任务和替换任务都要求 refusal 为空,开卡任务要求预检失败且 refusal 为空,所以静默跳过不排队、不取消昨天的提示,预期内的手动拒绝不开卡。工具从 workflow 自己的 commit 取:构建、发布脚本和 npm 回读仍用选中 commit 那棵树自己的。自测钉住以上每一处(snapshotWiring 的两条分支各一例、取工具的先后、软链、环境变量、不许从旧树调用,外加一例真的把副本放到树外加载并从环境变量读树),改错任何一处都过不了。开卡任务只有 issues: write,不碰 actions。回滚就是 revert 本 PR。
  • 先看:① .github/workflows/release.yml 快照通道头注释"THE FIVE LANDING DETAILS";② snapshot-next-preflight 的 env/outputs、新步骤 Take the lane's tooling from the workflow's own commit、Select the commit 步骤两条分支的 refusal=no-green-commit、新步骤 stable-lane,以及 snapshot-next/snapshot-next-supersede if: 里的 refusal == '';③ 文件末尾新 job snapshot-next-card;④ scripts/ci/snapshot-next.mjs 头注释"Where the tooling runs from"与"The quiet skip"、proposalVerdict、laneRoot;⑤ docs/adr/0125-…md 末尾修订的"The lane's checks run from the workflow's own commit"/"The quiet skip"/"The card"/"Trial readings"各段。
  • 两分钟验证:node scripts/ci/snapshot-next.mjs --self-test 预期最后一行 snapshot-next: self-test OK -- 191 case(s) across 23 batteries;node scripts/ci/scheduled-full-run-card.mjs --self-test 预期 13 cases over the two card steps' API plumbing;node scripts/pm/fleet-write/validate.mjs --self-test 预期 340 cases pass across 17 batteries。
  • 需要你做的:现在不需要。本 PR 仍是草稿;席位会再请 Fable 复审第三轮,之后再请你审阅合并(release.yml 和 docs/adr/** 是受管面)。

What this PR states (the ruled behaviour)

Ruling record 6677910954 on #29466 (option A on the maintainer's decision card, 2026-11-06 05:42Z) binds four lines; the maintainer confirmed the five landing details as one list on 2026-11-06 11:32Z (「同意」). Both are built and stated here; nothing below re-argues either.

The four bound lines.

  1. A schedule starts the snapshot preflight once a day, exactly the dispatch preflight. release.yml fires 17 1 * * * beside the 6-hourly bookkeeping cron on one schedule:, told apart by github.event.schedule; snapshot-next-preflight runs the same steps on either start. Two checks were added to the preflight on both starts by the landing details (the same-commit check and the stable lane, below).
  2. On a pass the snapshot job waits at environment: release and publishes only on the maintainer's approval. snapshot-next's if: is success() && needs.snapshot-next-preflight.outputs.refusal == '' && (<daily cron> || <snapshot dispatch>). No schedule can publish; no seat dispatches the lane or approves it (PD15).
  3. An unapproved run is the system working; the next day's run supersedes it; the stable sweep is untouched. snapshot-next-supersede cancels only an older schedule-started run whose snapshot job is still waiting at release — never a human's dispatch, never a job that has started, never a stable prompt — and only once today's preflight passed (a quiet skip leaves yesterday's prompt standing). stale-prompts and scripts/release-pending-publish.mjs are untouched.
  4. Everything else in the 2026-11-05 amendment stands; option B was not chosen.

The five landing details (confirmed 2026-11-06 11:32Z).

  1. Time — 17 1 * * * (09:17 Beijing), every day of the week, as built; the manual dispatch path is unchanged.

  2. Supersede — as built (above).

  3. Quiet skip (schedule only). Five refusals are expected of a daily proposal and each now concludes as a GREEN run with nothing queued and no card — a ::notice:: and a step-summary section only:

    • (a) the candidate commit equals the commit the current next was published from. New: snapshot-next.mjs record-commit --commit "$COMMIT" writes gitHead into every manifest after the version step (the TypeScript nightly way), guard --commit reads it back from every packed manifest, and preflight --commit reads the anchor's next version document on npm (readPublishedCommit) and refuses same-commit when it matches. Before the first snapshot nothing of snapshot shape is on next, so the check passes through; a next snapshot that records no commit was not cut by this lane and is refused as the incident it is (same-commit-unrecorded, NOT an expected kind);
    • (b) an RC cycle (rc-cycle, read at the commit and at main's tip);
    • (c) a fixed-group member not yet on npm (debut — daily until @objectstack/service-push debuts);
    • (d) main has no fully green commit (no-green-commit): the select step classifies --snapshot-commit's exit 1 with commit: null by event, and BOTH branches record refusal=no-green-commit — the schedule branch exits 0 (the quiet skip), the dispatch branch exits 1 (the loud refusal) — so neither files the card; exit 3 (NOT MEASURED) stays red with reason alone;
    • (e) a stable release prompt waiting at approval, or a stable publish running (stable-lane). New mode snapshot-next.mjs stable-lane --workflow release.yml, reading release.yml's runs with release-pending-publish.mjs's own readers (collectWaitingRuns — the waiting filter PLUS each version commit's push run — and collectPublishRuns), judged by PUBLISH_JOB_NAME and PUBLISH_SETTLED_JOB_STATUSES, through a client that refuses every non-GET; an unread answer is NOT MEASURED, never "clear".

    The switch is ONE expression on the preflight job's three refusing calls — ${{ github.event_name == 'schedule' && ' --quiet-skip' || '' }} (QUIET_SKIP_EXPRESSION) — and the select step's own EVENT_NAME test. proposalVerdict is the one place the kinds, the flag and the exit code meet: an expected refusal with --quiet-skip exits 0 and records refusal=<kind> + reason=; the same reading on a dispatch exits 1 with the same outputs (today's loud refusal, no card); an unexpected refusal, or an expected one beside anything unread, exits 1 with reason alone; nothing refused but something unread exits 3. The select step's shell does the same for no-green-commit on both of its branches. The preflight job exposes refusal/reason outputs (|| chains over commit, stable-lane, preflight, dry-run); its later steps and snapshot-next/snapshot-next-supersede read them. ⛔ No --quiet-skip after the approval: the post-approval preflight is loud. SNAPSHOT_EXPECTED_REFUSALS is spelled once, in scheduled-full-run.mjs, and both the preflight and the card read it. Every early return of dry-run (a dirty tree, a missing changeset binary, the version step failing) records reason= as the catch path does.

  4. Notification. A pass needs nothing. New job snapshot-next-card (needs: [snapshot-next-preflight, snapshot-next], always() && (<arms>) && ((preflight failed && refusal == '') || snapshot failed), contents: read + issues: write, no actions:) files or refreshes in place ONE fixed-title card — next snapshot lane: unexpected refusal or publish failure, marker os-next-snapshot-lane-card, labels bug, domain:devx, priority:p1 on create only — through the full-run card's own filer and de-dup (fileOrRefreshCard; snapshotCardStep in scheduled-full-run-card.mjs, snapshotCardIdentity/snapshotCardCase/renderSnapshotCardBody in scheduled-full-run.mjs; no second filer). Two cases and no other: an unexpected refusal (the preflight failed with no refusal kind — NOT MEASURED, the group, a dry run that failed, a step that died) and a publish failure after the approval, a half-finished publish included (the card names the snapshot version the version step reached, or says nothing reached npm). The body gives the reason, the run link and the candidate commit, and says: ⛔ a partial publish is fixed by a fresh, complete snapshot with a new timestamp — never a republish into it, never a touch of latest. A success files nothing; a quiet skip files nothing; an expected refusal on a dispatch files nothing (every expected kind records refusal on either start, the select step's included). GitHub mails a scheduled run's failure to the cron's author alone, so nothing relies on that mail.

  5. Text. ADR-0125's ## Amendment (2026-11-06, #30832) states all of the above as ruled: the schedule can start the preflight and queue at approval, publishing stays the approval, the old proposal is replaced by the new, "no schedule … can start it" becomes "no schedule can publish", the five skip rules, the card, ⛔ PD15 unchanged; the "Landing details being confirmed" section and every [#30832 landing detail N of 5] marker are removed; the trial readings for the [Decision] Release cadence: a week of merges is about 1,700 PRs, so each QA-gated release carries a very large delta and app repos wait days to install fixes #29466 report are recorded as this record's reading, not the ruling's (proposals, approvals, refusals by reason, merge-to-installable latency, which app repositories pin next). release.yml's header keeps "the stable publish is never reachable from schedule" and adds the preflight-exception sentence; fleet-write validate.mjs's jobsStartedBy pins read the fourth snapshot job; the docs/releases-maintenance.md row commit is kept and the row names the five quiet skips and the card.

Where the lane's tooling runs from. The preflight selects a commit older than main's tip by construction and moves the tree to it; the snapshot job checks that commit out. That tree's scripts/ci/snapshot-next.mjs need not know the modes this PR adds, so until a commit at or after this PR's merge is the selected one, running the lane's checks off the selected tree would be a red run and a false card every day. Both jobs therefore take scripts/ off GITHUB_SHA with git archive into ${RUNNER_TEMP}/lane-tooling before the tree moves (the preflight before its detach, the snapshot job before its install), link the tree's node_modules beside it (the script imports semver; a dangling link until the install, read by nothing before), set SNAPSHOT_NEXT_ROOT: ${{ github.workspace }} in the job env (read by laneRoot, so the copy reads and versions the checkout, not its own directory), and run every snapshot-next.mjs call as node "${RUNNER_TEMP}/lane-tooling/scripts/ci/snapshot-next.mjs" <mode>. The build, scripts/release-snapshot-publish.sh, release-spec-changes.sh and release-verify-npm.mjs stay the selected tree's own: they are what it ships. The supersede job reads main's own checkout and takes nothing. The copy lives outside the tree, so the dry run's clean-tree check is untouched.

Pins. snapshotWiring refuses: a preflight job without refusal/reason outputs or whose chains miss a refusing step; a refusing call without the schedule-only expression, a bare --quiet-skip, or --quiet-skip after the approval; a stable-lane read missing, doubled or placed after the approval; a select step that does not classify the no-green-commit record by event, or whose SCHEDULE branch, or whose DISPATCH branch, does not record refusal=no-green-commit (two findings, one per branch — the firing cases mutate one branch's echo and assert that only that branch's finding fires); a preflight (either job) without --commit; a snapshot job that never records the commit, records it after the guard, or guards without --commit; a snapshot job or supersede whose if: does not require an empty refusal; the card job's whole shape (presence, always(), the two cases, needs, permissions, the step module and step, the five env keys, cron and dispatch arm); and, for the tooling, a job that takes scripts/ off GITHUB_SHA other than exactly once, a take after the detach (preflight) or after the install (snapshot job), a missing node_modules link, a missing SNAPSHOT_NEXT_ROOT in the job env, a call before the take, and any node scripts/ci/snapshot-next.mjs call off the selected tree. fleet-write validate.mjs reads ten jobs; the snapshot cron starts the preflight alone with snapshot-next, snapshot-next-card and snapshot-next-supersede behind it; the relay's dispatch skips all four snapshot jobs.

Round 3 (after the complete review of 5fe2149)

The review's must-fix and should-fixes 2 to 5, taken as the seat's round-3 list; the docs/releases-maintenance.md row is kept.

  • M1 — the select step's dispatch branch. It wrote reason alone and exited 1, so a maintainer dispatching snapshot_next while main had no fully green commit got a priority:p1 card for an expected refusal (a failed preflight with an empty refusal is the card's first case). The branch now records refusal=no-green-commit too; snapshotWiring pins the classification by event and each branch's echo on its own, with a firing case per branch that asserts the OTHER branch's finding stays silent (not a string-presence check over the job).
  • S2 — the lane's tooling runs from the workflow's commit. As stated above ("Where the lane's tooling runs from"), the smallest shape the seat offered: the copy to $RUNNER_TEMP before the detach, pinned. The tree forced no decision: the same shape serves both jobs, and the selected tree's build and publish are left its own.
  • S3 — ADR. "The quiet skip replaces this record's earlier reading of a scheduled refusal as a red run" now names what it replaces — the lane's first draft on ci(release): start the next snapshot preflight on a daily schedule, stopping at the maintainer approval (ADR-0125 amendment + release.yml) #30832 and the card's acceptance line — and says the merged 2026-11-05 amendment, dispatch-only, carried no reading of a scheduled refusal.
  • S4 — ADR. "Trial readings for the [Decision] Release cadence: a week of merges is about 1,700 PRs, so each QA-gated release carries a very large delta and app repos wait days to install fixes #29466 report" is marked in the heading and the opening sentence as this record's reading, not the ruling's (the 2026-11-05 amendment's phrase): no ruling record binds a metric, the five confirmed details say nothing of the report, and the director may count otherwise.
  • S5 — dryRun(). The early returns (a dirty tree, a missing node_modules/.bin/changeset, and the version step failing) record reason= the way the catch path does; a self-test battery drives each on a scratch repository with the preflight stubbed and reads the output file back.

Tests

  • node scripts/ci/snapshot-next.mjs --self-test: 191 cases across 23 batteries (round 2: 178 / 21). New in round 3: "dry-run: every early return records its reason" (3), "release.yml: the lane's tooling runs from the workflow's own commit" (6: laneRoot; the checked-in jobs' take, link, env and calls; a call off the selected tree in either job; a take moved after the detach and a job that never takes; a missing link and a missing env; and the copy itself loaded from a temp dir through a linked node_modules, reading the tree from SNAPSHOT_NEXT_ROOT and writing nothing into it), and four cases in "release.yml: the quiet skip and the card" (now 20: both branches record; the dispatch branch alone; the schedule branch alone; the classification removed). Round 2's batteries stand: "the same-commit check" (10), "the stable lane" (10), "the quiet skip" (14), "record-commit" (4).
  • node scripts/ci/scheduled-full-run.mjs --self-test: OK, "the next snapshot card: identity, case and body" 13 cases (unexpected refusal and publish failure file; success, quiet skips and an expected dispatch refusal do not; the body carries the reason, run link, candidate commit, version reached, and the partial-publish rule).
  • node scripts/ci/scheduled-full-run-card.mjs --self-test: 13 cases over the two card steps' API plumbing.
  • node scripts/pm/fleet-write/validate.mjs --self-test: 340 cases across 17 batteries. node scripts/release-pending-publish.mjs --self-test: 146 cases across 35 batteries, untouched.
  • release.yml parses (ten jobs); check-workflow-status-functions, check-workflow-context-availability, check-workflow-step-name-quoting, check-self-test-workflow-commands: OK; eslint on scripts/ci/snapshot-next.mjs: clean.

Acceptance notes

  • Prime Directive 15: nothing here dispatches a workflow, approves a deployment or publishes; stable-lane and supersede read through clients that refuse every write but the one cancel. The branch's writes are the push, this PR's body through the relay and the os-dev-report comments through post-stamped.mjs.
  • open_questions is empty: the tree forced no decision the ruled list did not make. Two readings the list left to the lane are stated, not decided: a snapshot job that fails after the approval but BEFORE the publish files the publish-failure card too (the card says nothing reached npm), and a next snapshot recording no commit is an unexpected refusal rather than a pass-through. The review read both as inside the list.
  • One edge the review states, not flagged, stands: a FAILED job's outputs being readable by an always() dependant (needs.snapshot-next-preflight.outputs.refusal, needs.snapshot-next.outputs.version) is documented by GitHub and not yet measured in this repository; the first dispatch refusal measures it, and the failure mode is one false card, never a publish.
  • skip-changeset: the diff touches .github/workflows/**, scripts/** and docs/** only.
  • Tier H: draft, no reviewers, no assignees; the seat orders the Fable review and does the hand-off.

Verification

Derived gates on the final diff (after the third origin/main merge, e4c7785, merge base 616311c): 68 derived (the same 68 as round 2), 68 run, every one exit 0 at first run (no exit 3, no rebuild); --ran reconciled: 68 run, 0 NOT-MEASURED, 0 UNRUN. origin/main moved again (6ddf827) while the gates ran; not re-merged a third time, the landing merge resolves it
node scripts/check-adr-links.mjs :: exit 0
node scripts/check-adr-links.mjs --self-test :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs --self-test :: exit 0
node scripts/check-self-test-wired.mjs :: exit 0
node scripts/check-self-test-wired.mjs --self-test :: exit 0
node scripts/check-self-test-workflow-commands.mjs :: exit 0
node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0
node scripts/check-step-collectors.mjs :: exit 0
node scripts/check-step-collectors.mjs --self-test :: exit 0
node scripts/check-whole-set-label-write.mjs :: exit 0
node scripts/check-whole-set-label-write.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/scheduled-full-run-card.mjs --self-test :: exit 0
node scripts/ci/scheduled-full-run.mjs --self-test :: exit 0
node scripts/ci/snapshot-next.mjs --self-test :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0
node scripts/pm/ci-failure.mjs --self-test :: exit 0
node scripts/release-dist-tags.mjs --self-test :: exit 0
node scripts/release-verify-npm.mjs --self-test :: exit 0
pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0
pnpm check:adr-anchors :: exit 0
pnpm check:adr-index :: exit 0
pnpm check:adr-symbol-anchors :: exit 0
pnpm check:agent-test-spelling :: exit 0
pnpm check:aggregator-roster :: exit 0
pnpm check:bash32-floor :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:cli-command-ids :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:declared-population-live :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:entry-guard :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:node-version :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:parse-guard :: exit 0
pnpm check:pm-close-cards :: exit 0
pnpm check:pm-dispatch-gates :: exit 0
pnpm check:pm-expected-skips :: exit 0
pnpm check:pm-fleet-write-dispatch :: exit 0
pnpm check:pm-fleet-write-execute :: exit 0
pnpm check:pm-fleet-write-validate :: exit 0
pnpm check:pm-governed-merges :: exit 0
pnpm check:pm-issue-create :: exit 0
pnpm check:pm-issue-transfer :: exit 0
pnpm check:pm-label-write :: exit 0
pnpm check:pm-post-stamped :: exit 0
pnpm check:pm-prior-rulings :: exit 0
pnpm check:pm-record-recognisers :: exit 0
pnpm check:pnpm-acquisition :: exit 0
pnpm check:pnpm-filter-targets :: exit 0
pnpm check:pnpm-flag-spellings :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:required-contexts :: exit 0
pnpm check:scripts-symbol-anchors :: exit 0
pnpm check:shard-attestation :: exit 0
pnpm check:stall-guard-budget :: exit 0
pnpm check:stall-guard-headroom :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:workflow-status-functions :: exit 0
pnpm check:workflow-step-name-quoting :: exit 0

node scripts/pm/dispatch-gates.mjs --ran gates-exit.txt --repo objectstack-ai/objectstack :: exit 0
✓ dispatch-gates --ran: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3).

🤖 Generated with Claude Code

…topping at the maintainer approval

release.yml fires a second cron on its one `schedule:` (ADR-0125, amendment of
2026-11-06; ruling record 6677910954). The daily cron starts
`snapshot-next-preflight` alone -- exactly the dispatch preflight -- and on a
pass `snapshot-next` waits at `environment: release` as before. The two clocks
are told apart by `github.event.schedule`, as fleet-patrols.yml tells its four
apart: `version-pr` now names its 6-hourly cron (strictly subtractive), and the
three snapshot jobs name the daily one. Every stable job stays skipped on
either cron; `stale-prompts` is untouched.

A new `snapshot-next-supersede` job (ruling line 3, "the next day's run
supersedes it") cancels only an OLDER `schedule` run whose snapshot job is
still waiting at the environment -- never a dispatch, never a job that has
started, never a stable prompt, never the calling run; a read the API will not
answer defers to tomorrow with a warning. `cancel-in-progress` stays false.

snapshot-next.mjs gains the `supersede` mode, the clock readers
(`declaredCrons`, `guardClock`), clock and supersede pins in `snapshotWiring`,
and three self-test batteries (floor 13 -> 16). fleet-write's `jobsStartedBy`
reads `github.event.schedule` from a new `schedule` argument, and its
release.yml pins now evaluate each cron.

The five landing details the maintainer is still confirming (the hour, the
supersede, red run vs quiet skip, a card on a real failure, the docs scope)
are each marked `[#30832 landing detail N of 5 -- UNCONFIRMED]` at the one
place that changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8
…heduled start of the preflight

A dated amendment in the house shape (ruling record 6677910954 on #29466,
drafted on #30832): it quotes the three 2026-11-05 sentences it replaces ("no
`schedule` and no `push` can start it", the dispatch-only requirement, the
never-from-schedule prohibition), states the four bound lines, says how the
2026-11-05 text and D1 to D6 read with it, what the lane must satisfy, what
stays forbidden and what does not change. Dated pointer callouts sit at the D1,
D4 and D5 callouts and at the three replaced sentences; D1 to D6 and the
earlier amendments are not rewritten. A closing section records the draft's
reading of the five landing details still being confirmed, in one place.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8
…, published on approval

The channels table row and the one sentence on release.yml's schedule, and
nothing else (landing detail 5 of #30832, in its own commit so the scope can
be changed in one place).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Nov 6, 2026
@github-actions github-actions Bot added size/l ci/cd documentation Improvements or additions to documentation labels Nov 6, 2026
…lane and its own next, and files one card

The five landing details the maintainer confirmed on 2026-11-06 11:32Z
(ruling 6677910954, #30832 round 2), on top of round 1's scheduled start:

- Quiet skip (detail 3), schedule only. Five refusals are expected of a
  daily proposal and now conclude as a green run with nothing queued and
  no card -- a ::notice and a step-summary section only: the candidate
  already on `next` (same-commit), an RC cycle, a member not yet on npm
  (debut), no fully green commit on main, and a stable prompt waiting or
  a stable publish running. The switch is one schedule-only expression on
  the preflight job's refusing steps (`--quiet-skip`); a dispatch keeps
  today's loud refusal. The preflight job records `refusal` and `reason`
  outputs; `snapshot-next` and `snapshot-next-supersede` require an empty
  `refusal`, so a quiet skip queues nothing and cancels nothing.
- The same-commit check. `record-commit` writes `gitHead` into every
  manifest after the version step (the TypeScript nightly way), the guard
  reads it back from every packed manifest (`--commit`), and the
  preflight reads the anchor's `next` version document on npm for the
  commit it records. Before the first snapshot nothing of that shape is
  on `next`, so the check passes through; a snapshot recording no commit
  is refused as the incident it is, not as an expected kind.
- The stable lane (`stable-lane`), read with release-pending-publish.mjs's
  own readers (collectWaitingRuns, collectPublishRuns), through a client
  that refuses every non-GET; an unread answer is NOT MEASURED.
- The card (detail 4): `snapshot-next-card`, under always(), files or
  refreshes ONE fixed-title card through the full-run card's own filer
  (snapshotCardStep) on an unexpected refusal or a publish failure after
  the approval; the body gives the reason, the run link, the candidate
  commit and the snapshot version reached, and says a partial publish is
  fixed by a fresh complete snapshot, never a republish, never `latest`.
- snapshotWiring pins every piece of the above; fleet-write validate.mjs
  reads the fourth snapshot job. Self-tests: snapshot-next 178 cases
  across 21 batteries (five new), card modules, validate -- all green.
- The hour and the supersede stand as built (details 1 and 2); the
  landing-detail markers in release.yml are removed and the header is
  re-pinned: the stable publish is never reachable from schedule, the
  preflight is the one exception, and no schedule can publish.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8
…ing details

The amendment now says what the maintainer confirmed on 2026-11-06 11:32Z
(ruling 6677910954, the five-point list): the hour, the supersede as
built, the five quiet skips on the schedule (nothing queued, no card, a
green run; a dispatch refuses loudly), the one card for an unexpected
refusal or a publish failure after the approval (a partial publish is
fixed by a fresh complete snapshot, never a republish, never `latest`),
and the text. "no `schedule` ... can start it" becomes "no `schedule`
can publish"; the old proposal is replaced by the new; Prime Directive
15 is unchanged. The "Landing details being confirmed" section is
removed, the "red run" requirement is replaced by the quiet skip, and
the trial readings for the #29466 report are recorded: proposals,
approvals, refusals by reason, merge-to-installable latency, and which
app repositories pin `next`.

docs/releases-maintenance.md: the snapshot row names the five quiet
skips and the card (the row commit the list keeps).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8
@github-actions github-actions Bot added size/xl and removed size/l labels Nov 6, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#2 · marchtian · session_017ihL2ebbZkr2QAEG1x7UC8

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5fe2149385301e15afcde5fe15d33f4d6434ff49
Local-runs: none
Scope: docs/adr/0125-release-approval-gate-replaces-the-typed-version.md, .github/workflows/release.yml + scripts/ci/snapshot-next.mjs, scripts/ci/scheduled-full-run.mjs, scripts/ci/scheduled-full-run-card.mjs, scripts/pm/fleet-write/validate.mjs, docs/releases-maintenance.md, scripts/release-pending-publish.mjs (main, untouched), scripts/release-snapshot-publish.sh (main, untouched), .github/workflows/scheduled-full-run-card.yml (main, untouched), AGENTS.md Prime Directive 15 (main, untouched)

Read against: PR #30862 head 5fe2149385 (7 files, +2347/-177, merge base 26c965cb8f), fetched read-only into refs/review/pr-30862; card #30832 body and all four comments (6678236253, claim 6678280375, dev reports 6679395057 and 6685251006 — read as claims); rulings #29466 6663894403 and 6677910954; the maintainer's five confirmed landing details of 2026-11-06 11:32Z, verbatim as agreed. The dev reports' conclusions were not inputs to the verdict; every claim below was checked in the tree.

① Derived judgments

The safety line — holds.

  • A schedule event cannot publish. The only job with a publish command on either cron is snapshot-next (release.yml 2523–2732), held whole at environment: release, needing snapshot-next-preflight with success() && needs.snapshot-next-preflight.outputs.refusal == '' && (cron '17 1 * * *' || snapshot dispatch). The stable publish (1723–1727) needs release-integrity + full-battery under success(); release-integrity (1065–1067) and stale-prompts (1565–1567) run on push or a non-refresh dispatch only; full-battery needs release-integrity; docker (2212) gates on two outputs that are unset on a schedule; version-pr (224–226) now names its own cron '0 */6 * * *' — strictly subtractive against main, where it ran on any schedule. No stable job's if: was widened; the stable lane's diff is the version-pr conjunct alone.
  • A stable dispatch or push cannot start a snapshot job: all four snapshot jobs' if: carry the same two arms (the daily cron, or workflow_dispatch && inputs.snapshot_next && !inputs.refresh_version_pr); push satisfies neither; the fleet-write reader (validate.mjs jobsStartedBy, now cron-aware) pins push → no snapshot job, bookkeeping cron → version-pr alone, repair/force/refresh dispatch → no snapshot job, snapshot dispatch → preflight alone with the three dependants mayRun.
  • The supersede (snapshot-next.mjs judgeSnapshotPrompts, 1244–1290) cancels only a run that is: not the calling run, holding no job matching PUBLISH_JOB_NAME, event === 'schedule', run status waiting, with a Snapshot next of main SHA12 (awaiting approval) job waiting, pending on environment release, and created before the calling run — in that order, each other case untouched with its reason. The job runs on the daily cron alone (2768–2771), never on a dispatch, needs the preflight alone, holds actions: write + contents: read, and skips on a quiet skip (refusal == '' conjunct) so yesterday's prompt stands when today proposes nothing. cancel-in-progress stays false on release-snapshot-next-REF. A transient read failure defers (exit 0, warning, no POST); any other read failure or a non-202/409 cancel answer is red.
  • stable-lane reads through a client constructed with dryRun: true, which throws on every non-GET before fetch — structural, not a flag. stale-prompts and scripts/release-pending-publish.mjs are byte-identical to main (diff empty).
  • PD15: AGENTS.md is not in the diff (0 changed lines). The snapshot job's contents: read, persist-credentials: false, one call of scripts/release-snapshot-publish.sh (unchanged, one changeset publish --tag next --no-git-tag), no tag push, no Release, no dist-tag write — all re-pinned by snapshotWiring.

The five confirmed details, in the tree.

  1. 时间 — - cron: '17 1 * * *' (release.yml 274), named identically in the four snapshot jobs' if:; DAILY_CRON pin refuses anything but one M H * * * tick; the hour is argued at 243–273 from measured nightly finishes. Holds.
  2. 旧提示替换 — as above: an older schedule-started run still waiting at release is cancelled once today's preflight passed with no refusal; a human dispatch is never cancelled (run.event !== 'schedule' → untouched, pinned). Holds.
  3. 静默跳过 — on schedule each of the five kinds concludes exit 0 with refusal=KIND + reason= recorded, a ::notice:: and a summary section; later preflight steps are gated steps.X.outputs.refusal == '', snapshot-next and snapshot-next-supersede require the job output empty, the card job requires the preflight failure. Traced: (a) same-commit and (b) rc-cycle and (c) debut through preflight --commit … --quiet-skip → proposalVerdict skipped; (e) stable-lane --quiet-skip the same way; (d) no-green-commit in the select step's shell (2415–2429) on --snapshot-commit exit 1 with .ok == false and .commit == null (runSnapshotCommit emits exactly that record on "no commit qualifies", and a different record + exit 3 on NOT MEASURED). The switch is one expression, QUIET_SKIP_EXPRESSION, on the three refusing calls plus the select step's EVENT_NAME test; the post-approval preflight carries no --quiet-skip (pinned). NOT MEASURED stays loud on either start: proposalVerdict returns exit 3 with reason alone whenever anything is unread, even beside an expected refusal (then exit 1, reason alone); the select step re-exits with status 3. A human dispatch refuses loudly for four of the five kinds — and for the fifth it files a card it must not: see must-fix 1.
  4. 通知 — snapshot-next-card (2832–2899): needs: [snapshot-next-preflight, snapshot-next], always() && ARMS && ((preflight failure && refusal == '') || snapshot failure), permissions: contents: read, issues: write, no actions:; one actions/github-script step importing snapshotCardStep from scripts/ci/scheduled-full-run-card.mjs, which re-judges the case from the same five env keys and calls the full-run card's own fileOrRefreshCard with the same callbacks (cardCallbacks, now shared by both steps — no second filer). Identity: fixed title next snapshot lane: unexpected refusal or publish failure, plain-text marker os-next-snapshot-lane-card, labels bug, domain:devx, priority:p1 on create only, own footer. The publish-failure body says a partial publish is fixed by a fresh complete snapshot with a new timestamp, never a republish into it, never a touch of latest; it names the reason, run link, candidate commit and the version reached (or "nothing reached npm"). Success and quiet skips file nothing at both the job if: and the step. Holds, except as the must-fix says.
  5. 要改的文本 — one new ## Amendment (2026-11-06, #30832) section plus dated pointer callouts in the house shape under D1, D4, D5, 2026-11-05 bound line 2, the "Dispatch-only, and exclusive" requirement, the ⛔ list and "Not decided here"; the Status line gains one · **Amended** (2026-11-06 …) clause. D1–D6's decision text, the 2026-09-29 amendment and the 2026-11-05 bound lines are otherwise byte-unchanged in the diff. The amendment states "no schedule can publish" (line 310 and 267), "⛔ Prime Directive Add missing Field.phone() helper and factory methods for Action/Dashboard/Report #15 is unchanged", "Option B … was not chosen". No UNCONFIRMED, landing detail N of 5 or "being confirmed" text remains anywhere in the seven files. Holds, with two text should-fixes below.

The two readings the dev took (③ of the brief).

  • The card also filing when the snapshot job fails after the approval but before the publish: inside detail 4 — the maintainer approved a publish that then did not happen; the body says "the version step did not run: nothing reached npm" (renderSnapshotCardBody, pinned). Silence there would leave an approved failure unreported, which detail 4 exists to prevent. Not a decision beyond the list.
  • A next snapshot on npm recording no gitHead read as an unexpected refusal (same-commit-unrecorded), not a pass-through: detail 3's "没有新提交" is defined by the recorded commit, so an unreadable record cannot satisfy it; PD15 names an unordered publish an incident. A mechanism reading the list left to the lane, consistent with it and stated in the PR body and the script header; not a reversal of anything confirmed. Acceptable.

The new pieces (④ of the brief).

  • record-commit writes gitHead: COMMIT into every publishable package.json after the version step (never committed); guard --commit reads it back from every PACKED manifest (packedManifestProblems, refuses a missing or other gitHead); the job's checkout is the selected commit, so npm's own HEAD-derived gitHead would equal it too; readPublishedCommit GETs the anchor (@objectstack/cli) version document at REGISTRY/name/version and reads top-level gitHead (40-hex or null), refusing a document for another version or a non-200 as unreadable. Written and read back the same way.
  • readStableLane calls collectWaitingRuns (the waiting filter PLUS each version commit's push run by head_sha) and collectPublishRuns (in_progress + queued + the version-commit push run), merged by id, judged with PUBLISH_JOB_NAME / PUBLISH_SETTLED_JOB_STATUSES — the stable sweep's own readers and constants, imported, not copied. An in-flight run whose jobs are null is unread (NOT MEASURED). Note: collectWaitingRuns leaves jobs: [] (not null) on a run it listed but did not read past its status; such a run is judged clear unless collectPublishRuns also saw it — covered today because every in-flight run is in the in-flight filters or the version-commit push, and collectPublishRuns throws when a filter's total_count exceeds its listing.
  • Card permissions minimal; the arms keep it off push, the bookkeeping cron and every non-snapshot dispatch (pinned by jobsStartedBy).
  • Step-output gating traced end to end: a failure writes reason only and exits non-zero → job red → snapshot-next skipped (success()), supersede skipped, card files; a quiet skip writes refusal + reason, exits 0 → later steps skipped by their if:, job green, snapshot-next/supersede skipped by the refusal == '' conjunct, card skipped (result == 'success'). Neither is mistaken for the other. Exit 3 is never converted to 0 (proposalVerdict, the select step's exit "$status", the CLI's catch blocks).

Pins and self-tests (⑤). Read, not run: snapshotWiring gains the clock pins (declaredCrons, guardClock: two crons, each named by exactly one scheduled job and no !=), the supersede job's shape, the refusal/reason output chains over the four refusing steps, the QUIET_SKIP_EXPRESSION on every refusing call and nowhere bare, no quiet-skip after the approval, stable-lane exactly once and never after the approval, --commit on both preflights, record-commit between version and guard, guard --commit, the refusal == '' conjunct on snapshot-next and the supersede, and the card job's presence, always(), two cases, needs, permissions, step module/step, five env keys and arms. Each firing case in the batteries "two clocks" (13), "the daily supersede job" (9), "the quiet skip and the card" (16) and "the snapshot wiring" (21) mutates the checked-in text at the named site and asserts the named finding substring; I confirmed each regex matches the head text it means to mutate (indentation and spelling). validate.mjs jobsStartedBy gains schedule and answers UNKNOWN when the cron is not named — the "does not guess which clock" control pins it. The gate-carrying check-runs that execute these self-tests on the head (Lint & Repo Gates, Lint · scoped · tooling self-tests, Lint · PM dispatch-gates) are success. One pin gap is what lets must-fix 1 through: the refusal=no-green-commit pin tests presence of the string in the job, not the dispatch branch.

Must-fix

  1. The select step misclassifies a human dispatch's no-green-commit refusal as unexpected, and the card files for it. release.yml 2430–2432: on a dispatch with .commit == null the step writes reason=… only — no refusal=no-green-commit — and exits 1. snapshot-next-card's first case is needs.snapshot-next-preflight.result == 'failure' && needs.snapshot-next-preflight.outputs.refusal == '', and snapshotCardCase judges the same inputs the same way, so a maintainer who dispatches snapshot_next while main has no fully green commit gets a priority:p1 bug card whose body says "The preflight refused for a reason that is none of the five expected ones" while its reason line says main has no green commit. This contradicts the ADR text landed in this PR (line 293–294: an expected refusal on a dispatch is a loud red run; "a quiet skip files nothing" and the card's two cases are an UNEXPECTED refusal or a publish failure), the lane header ("a DISPATCH keeps today's loud refusal … no card"), scheduled-full-run.mjs's card comment ("files NOTHING either way") and the PR body ("the same reading on a dispatch exits 1 with the same outputs"). The four script-driven kinds get it right because proposalVerdict's refused-expected records refusal on exit 1; the shell branch does not. Fix: write refusal=no-green-commit on the dispatch branch too, and give snapshotWiring a firing case for that branch (mutate the dispatch-branch echo and assert), since the current pin passes on the string's presence anywhere in the job. A governed text stating behaviour the tree does not deliver is PD10's "declared ≠ enforced"; FAIL until fixed.

Should-fix

  1. The preflight runs this PR's new modes from the SELECTED commit's tree. The select step detaches to the chosen commit before Setup workspace, and every later step runs node scripts/ci/snapshot-next.mjs … from that tree. Until the newest commit with the full battery green ON it is at or after this PR's merge, that tree's snapshot-next.mjs has no stable-lane mode (prints usage, exit 1, writes no refusal) and its preflight/dry-run ignore --quiet-skip and --commit — so the first daily run(s) go red with refusal == '' and the card files a false "unexpected refusal". Transient (clears with the first green nightly after the merge) and inherited from the lane's design (the 2026-09-29 amendment's "Known edges" names the same split), but it is the first thing the maintainer will see from the lane. Either run the lane's own tooling from the workflow's commit (take the scripts from GITHUB_SHA before the detach, or run stable-lane before it), or state the edge in the lane header and the ADR and accept one card.
  2. ADR line 293 claims to replace a reading the record never carried. "The quiet skip replaces this record's earlier reading of a scheduled refusal as a red run" — the merged 2026-11-05 amendment is dispatch-only and has no scheduled-refusal reading; the red-run reading was this PR's own first draft (dev report 6679395057) and the card's acceptance line. Reword to what it replaces (the card's acceptance criterion / the draft), or drop the sentence.
  3. ADR "Trial readings for the [Decision] Release cadence: a week of merges is about 1,700 PRs, so each QA-gated release carries a very large delta and app repos wait days to install fixes #29466 report" defines the director's report metrics, which is neither of the four bound lines nor one of the five confirmed details. The section is useful and non-normative in intent; mark it as this record's reading, not the ruling's (the house phrase the 2026-11-05 amendment uses), so it is not read later as a decision the maintainer confirmed.
  4. dryRun()'s early returns write no reason output. A dirty tree or a missing node_modules/.bin/changeset returns exit 3 without writeStepOutputs, so the card's reason line reads "no reason was recorded by the run". Record the reason as the catch path does.

② Semver level

None. The diff touches .github/workflows/**, docs/** and scripts/** only; no published package moves, so skip-changeset is correct and Clause-②: no / Metadata-protocol: none are right.

③ Boundary flags

  • Tier H: .github/workflows/release.yml and docs/adr/0125-*.md are Tier H register rows (one path makes the whole PR Tier H); PR is a draft on the base repository (not a fork), no reviewers, no assignees. Lands only by the maintainer's hand or an authorized approval; this record is the complete review the card and the 2026-10-25 rule require, not a landing record.
  • PR body: Fixes #30832, Ruling-ref: 6677910954, Clause-②: no, Metadata-protocol: none present; labels documentation, ci/cd, size/xl, skip-changeset. The body matches the tree except for the sentence must-fix 1 falsifies ("the same reading on a dispatch exits 1 with the same outputs") — judged on the tree.
  • Check-runs on 5fe2149385: all completed; every gate-carrying required context is success (Lint & Repo Gates, TypeScript Type Check, Test Core, Governed Surface Queue Guard, Lint UI, Type Check UI, Test UI, Build & E2E UI, Live E2E UI); Build Core is skipped by ci.yml's filter contract (needs.filter.outputs.core != 'false') on a diff with no core path — a skipped required context, not a red one. Check Changeset skipped by the label. Nothing in progress.
  • The card job's "no card on a dispatch's expected refusal" and the publish-failure body's version line both rely on a FAILED upstream job's outputs being readable by an always() dependant (needs.snapshot-next-preflight.outputs.refusal, needs.snapshot-next.outputs.version). GitHub documents job outputs as available to downstream jobs; nothing in this repo has measured it on a failed job. The failure mode would be one false card on a human dispatch (the step re-judges on the same env), never a publish. Measure on the first dispatch refusal and record it in platform-readings.
  • docs/releases-maintenance.md is edited beyond detail 5's "只加一段 ADR-0125 增补": the release-channels row and the two-crons sentence. The row is the consumer table the 2026-11-05 amendment itself names as the lane's consumer and the card's acceptance requires; it states no rule. Noted, not a fix.
  • stale-prompts still runs on a snapshot DISPATCH (its if: excludes refresh_version_pr only) — unchanged from main, and the ruling leaves the sweep untouched; it never runs on either cron.
  • The merge commits a4bb83b2a4 and 5fe2149385 carry git's default message without the Claude-Session / Co-authored-by trailer pair (disclosed in dev report 6685251006); the landing's merge method decides whether they survive.
  • Edge, stated not flagged: a prompt approved minutes before the 01:17Z tick is in_progress and untouched by the supersede; today's snapshot-next then pends behind it in the group and, once approved, its post-approval preflight refuses same-commit loudly if the candidate is the same commit, filing the card as a publish failure with "nothing reached npm". Rare on a main that moves daily, and loud rather than silent.

④ Design judgment

  • release.yml against ruling 6677910954 and the five details: the mechanism is right — one schedule: with two crons told apart by github.event.schedule, every scheduled job naming its own, every stable job unreachable from either cron, the snapshot job held whole at environment: release, and a supersede that is a judged cancel of exactly one shape rather than cancel-in-progress. The verdict plumbing (refusal/reason as job outputs, || chains in step order, refusal == '' as the queue/cancel/card gate) is the simplest shape that lets a green run mean "nothing to propose" without continue-on-error. The one defect is the shell branch in must-fix 1, which is the only refusing path not routed through proposalVerdict; routing the select step's verdict through the script (a --quiet-skip-aware select mode) would remove the duplicated classification.
  • ADR-0125's 2026-11-06 amendment against its first consumer (release.yml) and the ruling: it restates the four bound lines as sentences, names the three replaced sentences with dated pointers in the shape the 2026-09-29 and 2026-11-05 amendments set, keeps D1–D6 and both earlier amendments intact, and states the five details as confirmed. Two sentences overreach (should-fix 3 and 4). Against its consumer it is accurate except where must-fix 1 makes the tree fall short of line 293–294.
  • snapshot-next.mjs: proposalVerdict is the right single meeting point of kinds, flag and exit code; SNAPSHOT_EXPECTED_REFUSALS spelled once in the card's module and imported here keeps the two readers agreeing; stable-lane reusing the stable sweep's readers (not re-reading the API its own way) is the correct ownership call. judgeSnapshotPrompts refuses in the safe order (stable prompt, event, status, job, environment, age).
  • scheduled-full-run{,-card}.mjs: extending the existing filer with a second identity and step instead of a second filer honours "one route, one owner"; withBlockNote taking the identity's footer is the minimal change to keep the block note above the right footer.
  • validate.mjs: a cron-aware jobsStartedBy that answers UNKNOWN when the cron is not named is the honest reading; the pins read the crons off the file rather than spelling them, so the hour can move in one place.

Implemented-by: claude/issue-30832-snapshot-daily-schedule
Reviewed-by: session_017ihL2ebbZkr2QAEG1x7UC8

VERDICT: FAIL

…efusal, the lane's tooling runs from the workflow's commit, and the dry run's early returns record a reason

Round 3 of #30832, after the complete review of 5fe2149 (its must-fix and
should-fixes 2 and 5):

- M1: on a dispatch with no fully green commit the select step wrote `reason`
  alone and exited 1, so `snapshot-next-card` read the loud refusal as an
  UNEXPECTED one (a failed preflight with an empty `refusal`) and filed a
  false card. The dispatch branch now records `refusal=no-green-commit` like
  the schedule branch does, and `snapshotWiring` pins each branch on its own
  (the classification by event, the schedule branch's echo, the dispatch
  branch's echo), with a firing case for each.
- S2: the preflight detached to the selected commit and ran every
  snapshot-next.mjs mode from THAT tree, whose copy predates this change and
  knows no `stable-lane`, `--quiet-skip`, `--commit` or `record-commit`; the
  snapshot job checked that commit out and did the same. Both jobs now take
  `scripts/` off `GITHUB_SHA` into the runner's temp dir before the tree
  moves (`git archive`), link the tree's node_modules beside it (the script
  imports `semver`), name the tree in the job env (`SNAPSHOT_NEXT_ROOT`,
  read by `laneRoot`), and call the copy alone; the build, the publish script
  and the registry read-back stay the selected tree's own. Pinned: the take
  before the detach / the install, the link, the env, no call off the tree;
  a self-test case loads the copy from outside the tree and reads the tree
  from the env, writing nothing into it.
- S5: `dryRun()`'s early returns (a dirty tree, a missing changeset binary,
  and the version step failing) now record `reason=` the way the catch path
  does, so the card never says no reason was recorded.

Self-test: 191 cases across 23 batteries (two new batteries, four new cases
in the quiet-skip battery).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8
…replaces, marks the trial readings as this record's, and states where the lane's checks run from

Round 3 of #30832, the review's should-fixes 3 and 4 and the record of S2:

- "The quiet skip replaces this record's earlier reading of a scheduled
  refusal as a red run" claimed to replace a reading the merged 2026-11-05
  amendment never carried (it was dispatch-only). The sentence now names what
  it replaces: the lane's first draft on #30832 and the card's acceptance
  line.
- "Trial readings for the #29466 report" is marked as this record's reading,
  not the ruling's, in the house phrase: no ruling record binds a metric and
  the five confirmed details say nothing of the report.
- One requirement bullet states that the lane's checks run from the
  workflow's own commit while the selected commit's tree is built and
  published, and that the stable lane's "Known edges" reading is untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#2 · marchtian · session_017ihL2ebbZkr2QAEG1x7UC8

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e4c7785c5aa732d361ccb580635af7d0ae603087
Local-runs: none
Scope: docs/adr/0125-release-approval-gate-replaces-the-typed-version.md, .github/workflows/release.yml + scripts/ci/snapshot-next.mjs (delta); scripts/ci/scheduled-full-run.mjs, scripts/ci/scheduled-full-run-card.mjs, scripts/pm/fleet-write/validate.mjs, docs/releases-maintenance.md (unchanged since round 1); scripts/release-pending-publish.mjs, scripts/release-github-releases.mjs, scripts/release-verify-npm.mjs, scripts/release-snapshot-publish.sh, AGENTS.md Prime Directive 15 (main, untouched)

Delta review of the round-1 FAIL record (comment 6685551244 on #30832). Read against: git diff 5fe2149385..e4c7785c5a with the branch fetched read-only into refs/review/pr-30862-r2; the dev's delta report 6686655794 and the live PR body, read as claims and checked in the tree; the round-1 record's five findings. Not inputs to the verdict: the dev's or the seat's conclusions.

Separating the merges from the dev's commits. The range holds four commits: a15e462dd6 (ci), 04d2ff1b6d (adr) and two merges of origin/main, 943fc6da72 and e4c7785c5a. Between the two merge bases (26c965cb8f to 616311c126) main touched none of the seven files in the PR, nor AGENTS.md, release-pending-publish.mjs or release-snapshot-publish.sh; the two dev commits account for every changed line in release.yml, ADR-0125 and snapshot-next.mjs (the dev-only diff and the whole-range diff of those three files are line-identical). The net diff against main is still the same seven files (+2622/-196). Nothing else moved.

① Derived judgments

M1 (must-fix 1) — closed. release.yml's select step now writes refusal=no-green-commit on the dispatch branch as well: after fi, echo "refusal=no-green-commit", echo "reason=...", an ::error:: naming the kind as "an expected refusal, loud on a dispatch; no card", exit 1. With refusal non-empty the card job's first case (result == 'failure' && outputs.refusal == '') is false and snapshotCardCase judges the same inputs the same way, so a maintainer dispatching while main has no green commit gets a red run and no p1 card — the behaviour the ADR, the lane header and the PR body state. The pin now fires on that branch specifically: snapshotWiring locates the branch by anchors (the jq -e '.ok == false and .commit == null' line, then if [ "${EVENT_NAME}" = 'schedule' ]; then, then fi, then exit 1), slices the if-to-fi text as the schedule branch and the fi-to-exit text as the dispatch branch, and requires the refusal=no-green-commit echo in each, with distinct findings ("on the SCHEDULE branch" / "on the DISPATCH branch"). The battery "the quiet skip and the card" (16 to 20 cases) adds: both present and silent; removing the dispatch echo alone (regex anchored on the 14-space fi line) fires the dispatch pin and not the schedule one; removing the 16-space schedule echo alone fires the schedule pin; replacing the schedule test with if false fires "does not classify". I confirmed each mutation regex matches the head text (indentation and spelling). The round-1 pin gap (presence anywhere in the job) is gone.

S2 (should-fix 2) — closed; sound and complete.

  • Mechanism: both snapshot-next-preflight and snapshot-next carry job env SNAPSHOT_NEXT_ROOT: ${{ github.workspace }} and one step "Take the lane's tooling from the workflow's own commit": mkdir -p "${RUNNER_TEMP}/lane-tooling", git archive --format=tar "${GITHUB_SHA}" scripts | tar -x -C ..., ln -s "${GITHUB_WORKSPACE}/node_modules" .../lane-tooling/node_modules. In the preflight it sits after Setup Node.js and before the select step's git checkout --quiet --detach; in the snapshot job after the checkout of the selected commit (fetch-depth: 0, so GITHUB_SHA — main's tip at trigger — is fetched) and before uses: ./.github/actions/setup-workspace. laneRoot(env) returns resolve(env.SNAPSHOT_NEXT_ROOT) when set, else the file's own ../..; REPO_ROOT = laneRoot().
  • Completeness: all seven snapshot-next.mjs calls in the two jobs are node "${RUNNER_TEMP}/lane-tooling/scripts/ci/snapshot-next.mjs" MODE — preflight job: stable-lane, preflight --commit, dry-run; snapshot job: preflight --commit --record, record-commit, guard --pack-dir --commit, verify. Zero node scripts/ci/snapshot-next.mjs lines remain in either job (OFF_TREE_CALL pin, plus pins for exactly one take per job, the take before the detach / before the install, the link line, the env line at job indent, and no call before the take — battery "the lane's tooling runs from the workflow's own commit", 6 cases). The supersede job's node scripts/ci/snapshot-next.mjs supersede --workflow release.yml runs from main's own checkout at github.sha with no detach, so it is the workflow commit's script already; the card job imports scheduled-full-run-card.mjs from the same checkout. The select step's scheduled-full-run.mjs --snapshot-commit and --release-gate run before the detach, from the workflow commit. No path remains where the selected tree's old script runs the lane's checks.
  • Every CLI mode reads the tree through REPO_ROOT: preflight (readOptional(join(root, PRE_JSON)), readPreJsonAt(root, mainRef), listWorkspacePackages(root)), record-commit (workspaceState(root)), guard (changesetBin(root), run(..., { cwd: root })), dry-run (treeState/restoreTree(root), changesetBin(root)), stable-lane (versionCommitsOf({ cwd: root }) plus the Actions API). --workflow release.yml is an Actions API workflow id, not a file path. The two paths resolved against the copy's own directory (RELEASE_WORKFLOW, PUBLISH_SCRIPT) are read in --self-test only, which the lane never runs. release-github-releases.mjs's own REPO_ROOT is the default of listWorkspacePackages(root = REPO_ROOT) and snapshot-next passes root at both call sites; cli-build-prerequisite.mjs's atRepoRoot is not on the lane's path. Verified usage text: "SNAPSHOT_NEXT_ROOT=DIR names the tree every mode reads, when this file does not sit in it".
  • Import closure of the copy: semver (the one bare import on the lane's path) and relative modules all under scripts/ (board-read, import-prerequisite, invoked-as, release-github-releases, release-pending-publish, release-verify-npm, ci/scheduled-full-run, and their transitive workspace-enumerator, js-comment-mask, pm/clause2-line, cli-build-prerequisite, check-issue-citations, git-env, glob-match, symbol-anchors, pm/check-half-states, pm/seat-record). git archive ... scripts carries all of them (no export-ignore in .gitattributes). @changesets/cli/changelog is reached only by a dynamic import in changelog functions not on the lane's path. semver ^7.8.5 is in root package.json on main at least 30 days back, so the selected tree's install provides it. The link is dangling until the install and nothing reads it before (first use is the first snapshot-next call, after Setup workspace in both jobs).
  • Security through the link: the copy executes the SELECTED tree's installed node_modules (semver and whatever Node resolves there) inside the workflow commit's tooling. That is the same trust the lane already extends — the selected commit is a main commit with the full battery green on it, and its own build, release-snapshot-publish.sh, release-spec-changes.sh and release-verify-npm.mjs run from that tree by design (they are what it ships). The link adds no token or privilege: stable-lane keeps the job's GITHUB_TOKEN at actions: read through a dryRun: true GET-only client, the publish step and its contents: read / persist-credentials: false are untouched, and the selected tree's code cannot reach or rewrite the copy (RUNNER_TEMP is outside the workspace; the dry run's clean-tree check is therefore also untouched). The one new surface is the copy's manifest reads and changeset invocations now being the workflow commit's logic over the selected tree's data — a narrowing, not a widening. Acceptable.
  • Live self-test case (read, not run): copies scripts/ to a temp box, links node_modules, runs record-commit with SNAPSHOT_NEXT_ROOT pointing at an empty tree, expects exit 3 with an error naming tree/.changeset/config.json and the tree left empty — pins that the copy reads the env root, not its own directory.

S3 — closed. The ADR sentence now reads: the quiet skip replaces the red-run reading of the lane's first draft on #30832 and of that card's acceptance line; "the merged 2026-11-05 amendment was dispatch-only and carried no reading of a scheduled refusal, so nothing in the record above is replaced by it." Accurate to the record's history.

S4 — closed. The heading is now "Trial readings for the #29466 report — this record's reading, not the ruling's", opening with a sentence disclaiming any binding metric — the house phrase the 2026-11-05 amendment uses.

S5 — closed. dryRun gains a stop(reason, exit) helper that calls writeStepOutputs({ reason }, env) on the dirty-tree return (exit 3), the missing-changeset-binary return (exit 3) and the version-step failure (exit 1); env, pre, tree, hasChangeset are injectable and the new battery "dry-run: every early return records its reason" (3 cases) drives each path and asserts the recorded reason= text and exit code. The card's reason line no longer falls back to "no reason was recorded by the run" for these.

The new ADR requirement bullet — inside scope. "The lane's checks run from the workflow's own commit" states the mechanism this PR's own tree needs so that landing detail 3 (quiet skip, five kinds) and detail 4 (card on an unexpected refusal only) hold on the first scheduled runs after merge; without it the lane contradicts both details for days (the round-1 should-fix 2). It restricts itself to "the snapshot lane's pre-queue checks alone", says explicitly that the stable lane's 2026-09-29 "Known edges" reading is untouched, and decides nothing about publish, approval, the stable lane, or the five details' content. It is the ADR recording how its consumer satisfies what was ruled — the same relation the amendment's other requirement bullets have to release.yml — not a new decision in the maintainer's domain. One note, stated not flagged: like the two readings in round 1 it is the lane's reading of the ruled list, so if the maintainer wants the five details to be the only text with the status "confirmed", the bullet could carry the "this record's reading" marker S4 now uses. Not a fix.

Safety line — intact. The stable jobs (version-pr, release-integrity, full-battery, publish, docker, stale-prompts) have no diff in the range; their if: lines and the environment: release hold on snapshot-next are as in round 1. snapshot-next-supersede and snapshot-next-card are byte-unchanged. The take step runs git archive and ln -s only — no network, no token, no write into the tree. scripts/release-snapshot-publish.sh and scripts/release-pending-publish.mjs remain identical to main; AGENTS.md is not in the diff. A schedule event still cannot publish and a push or stable dispatch still starts no snapshot job (the jobsStartedBy pins are unchanged).

Pins and self-tests (read, not run). SELF_TEST_BATTERIES gains two batteries (floor 21 to 23); the three mutation sets described above target the head text at the named sites. The gate-carrying check-runs that execute snapshot-next.mjs --self-test on this head — Lint & Repo Gates, Lint · scoped · tooling self-tests, Lint · PM dispatch-gates, Lint · CI, release & engine gates — are success.

Must-fix

None.

Should-fix

None new. Round 1's five are closed as above.

② Semver level

None. The delta touches .github/workflows/**, docs/** and scripts/** only; skip-changeset, Clause-②: no, Metadata-protocol: none remain right.

③ Boundary flags

  • Tier H: unchanged — release.yml and ADR-0125 are register rows; the PR is still a draft on the base repository with no reviewers or assignees. Lands only by the maintainer's hand; this record is the complete delta review, not a landing record.
  • PR body: Fixes #30832, Ruling-ref: 6677910954, Clause-②: no, Metadata-protocol: none present; labels documentation, ci/cd, size/xl, skip-changeset. The body's new "Where the lane's tooling runs from" paragraph and its S2 line match the tree; the round-1 falsified sentence is now true.
  • Check-runs on e4c7785c5a at the time of this read (2026-11-06, after 14:01Z): 44 completed/success including every gate-carrying aggregate seen complete — Lint & Repo Gates, TypeScript Type Check, Test UI, Governed Surface Queue Guard, Build & E2E UI, Live E2E UI, Dogfood Regression Gate, all seven Lint · contexts, all four Type Check · contexts, Test Core (2/8), (3/8), all sixteen Test UI (n/16) shards; 3 still in_progress — Lint UI, Test Core (1/8), Type Check UI (so the Test Core aggregate is not yet listed); 13 skipped by ci.yml's filter or labels (Build Core, Build Docs, Check Changeset, Dogfood Verify CLI, Temporal Conformance, UI Published Artifacts, the two opt-in harnesses, Schedule gate); one older PR-body guards run cancelled and superseded by a success. No red check. The verdict below is on the tree; the three open contexts cover UI lint, one core test shard and UI type-check, none of which executes the lane's self-tests.
  • The two merge commits 943fc6da72 and e4c7785c5a carry git's default message without the Claude-Session / Co-Authored-By trailer pair (the two dev commits carry both); disclosed in the dev's delta report; the landing's merge method decides whether they survive. Unchanged from round 1.
  • docs/releases-maintenance.md still carries the release-channels row and the two-crons sentence beyond detail 5's "只加一段"; round 1's reading (consumer table, no rule stated) stands. Noted, not a fix.
  • The card job's "no card on a dispatch's expected refusal" still relies on a FAILED upstream job's outputs.refusal being readable by an always() dependant; the step re-judges on the same env, so the failure mode remains one false card on a human dispatch, never a publish. Still unmeasured in this repo; measure on the first dispatch refusal and record it in platform-readings.
  • Edge, stated not flagged: git archive "${GITHUB_SHA}" in the snapshot job reads a commit the fetch-depth: 0 checkout fetched; on a schedule GITHUB_SHA is main's tip at trigger time and stays an ancestor of origin/main unless main is force-moved, in which case the take fails and the job is red and the card files — loud, not silent. The release.yml comment at the take states this.

④ Design judgment

  • The take-from-GITHUB_SHA shape is the smallest that makes "the checks are the workflow's, the ship is the selected commit's" true in both jobs with one step and one env line each; routing the root through laneRoot(env) rather than a flag keeps every mode's default the same and lets the self-test drive it with a fake tree. Pinning the ordering (take before detach / before install) and the absence of any off-tree call is the right level: a future call added as node scripts/ci/snapshot-next.mjs fails the self-test rather than the lane.
  • The dispatch-branch fix routes the shell's one hand-written classification through the same two outputs the script's proposalVerdict writes, and the per-branch pins make the two branches independently falsifiable. The round-1 remark stands as a later simplification, not a finding: a --quiet-skip-aware select mode would remove the duplicated classification from the shell entirely.
  • ADR-0125's amendment, after the two text fixes, states only what the tree delivers and what the ruling confirmed, marks its own readings as its own, and leaves D1–D6 and both earlier amendments intact.

Implemented-by: claude/issue-30832-snapshot-daily-schedule
Reviewed-by: session_017ihL2ebbZkr2QAEG1x7UC8

VERDICT: PASS

This branch has not been deployed

No deployments
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

Development

Successfully merging this pull request may close these issues.

ci(release): start the next snapshot preflight on a daily schedule, stopping at the maintainer approval (ADR-0125 amendment + release.yml)

1 participant