Skip to content

Commit 8ae0ab9

Browse files
ci(release): start the next snapshot preflight on a daily schedule, stopping at the maintainer approval (ADR-0125 amendment) (#30862)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiNjyvCdXYygFqntMNR7S2dW)_ 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`,卡上不再出现"没有记录原因"。 - **为什么改**:#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 #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 #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 #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 <details> <summary>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</summary> ```text 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). ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- signed: domain:devx#2 · marchtian · session_017ihL2ebbZkr2QAEG1x7UC8 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d8a835 commit 8ae0ab9

7 files changed

Lines changed: 2622 additions & 196 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 586 additions & 65 deletions
Large diffs are not rendered by default.

‎docs/adr/0125-release-approval-gate-replaces-the-typed-version.md‎

Lines changed: 86 additions & 1 deletion
Large diffs are not rendered by default.

‎docs/releases-maintenance.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ publishers and two version shapes:
197197
|---|---|---|
198198
| `latest` | the stable release (GA) | `release.yml`, outside pre mode — the Version Packages PR flow below. The only path that writes `latest` |
199199
| `next` | the pre-release (`X.Y.Z-next.N`) during a release-candidate cycle | `release.yml` in pre mode: `.changeset/pre.json` says `mode: pre`, `tag: next`, and `changeset publish` publishes under that tag |
200-
| `next` | a snapshot of `main` (`0.0.0-next-YYYYMMDDHHMMSS`) outside a cycle | `release.yml`'s snapshot lane: **Actions → Release → Run workflow** with `snapshot_next` checked, then a required reviewer approves `Snapshot next of main <commit> (awaiting approval)` at `environment: release`. It cuts the newest commit whose full battery is green on it, runs `changeset version --snapshot next` in the runner only (never committed), and runs `changeset publish --tag next --no-git-tag`: no tag, no GitHub Release, no Version Packages PR change, `latest` untouched. It refuses while `.changeset/pre.json` says `mode: pre` at the selected commit or at main's tip, read again after the approval (the cycle owns `next`) and while any fixed-group member has no version on npm (a first publish takes `latest`). ADR-0125, amendment of 2026-11-05 |
200+
| `next` | a snapshot of `main` (`0.0.0-next-YYYYMMDDHHMMSS`) outside a cycle | `release.yml`'s snapshot lane, **proposed daily by schedule and published on approval**: a daily cron (or **Actions → Release → Run workflow** with `snapshot_next` checked) starts the preflight, and a required reviewer then approves `Snapshot next of main <commit> (awaiting approval)` at `environment: release` — nothing publishes without that click, and a proposal nobody approves is superseded by the next day's. It cuts the newest commit whose full battery is green on it, runs `changeset version --snapshot next` in the runner only (never committed), and runs `changeset publish --tag next --no-git-tag`: no tag, no GitHub Release, no Version Packages PR change, `latest` untouched. It refuses while `.changeset/pre.json` says `mode: pre` at the selected commit or at main's tip, read again after the approval (the cycle owns `next`), while any fixed-group member has no version on npm (a first publish takes `latest`), when the candidate commit is the one the current `next` was published from (every snapshot records its commit as `gitHead`, read back from npm), when `main` has no fully green commit, and while a stable release prompt is waiting or a stable publish is running. On the daily cron each of those five is a **quiet skip** — a green run, nothing queued, no card — so the schedule is green and proposes nothing until every member has debuted; a human dispatch keeps the loud refusal. An unexpected refusal, or a publish that fails after the approval, files or refreshes one fixed-title card (`snapshot-next-card`); a partial publish is fixed by a fresh complete snapshot, never a republish into it. ADR-0125, amendments of 2026-11-05 and 2026-11-06 |
201201

202202
A snapshot version sorts below every stable version, so the next stable publish's
203203
dist-tag step moves `next` back up to it. An app that needs a fix the day it lands
@@ -218,8 +218,10 @@ held, **whole**, at the environment gate until a required reviewer approves it (
218218
The approval is the release act on that lane, so read ADR-0125 D3 before touching it:
219219
an environment with no required reviewers passes automatically and silently, which on
220220
a push-started lane is not a weakened gate but no gate at all. (`release.yml` also
221-
carries a 6-hourly `schedule`, added by #11233; it reaches only the bookkeeping
222-
`version-pr` job, which has no publish capability by construction.) That is the
221+
carries two `schedule` crons: the 6-hourly one, added by #11233, reaches only the
222+
bookkeeping `version-pr` job, which has no publish capability by construction; the
223+
daily one, added by the 2026-11-06 amendment, starts only the `next` snapshot
224+
preflight, whose publish waits at the same `release` gate.) That is the
223225
2026-08-07 ruling (「版本发布必须是人工的」) re-implemented, not relaxed, and it is not
224226
negotiable in either direction — see `release.yml`'s header for the two releases that
225227
were minted without a human and the lane split that ended it.

0 commit comments

Comments
 (0)