Skip to content

Commit 73213a5

Browse files
fix(release): queue the publish only on the push that carries the version commit, and publish that commit (ADR-0125 D1 amended) (#20625)
Fixes #20613 Clause-②: no (a CI workflow and an ADR; no contract accept or reject behaviour moves) ## What this changes `release.yml`'s publish lane, per triage `5886053015`'s direction, with ADR-0125 D1 amended in the same PR (Tier H: the maintainer merges). - **Only the push that carries the version commit queues a publish.** `release-integrity` now names the *version commit*: the newest first-parent commit at which `packages/cli/package.json`'s version differs from its parent's, i.e. the landing that brought main to its version. On a push, `publish-pending` is true exactly when that commit is **in this push** (not an ancestor of `github.event.before`) **and** the version is absent from npm. A later landing queues nothing and evicts nothing. The repair dispatch (D4) keeps "absent from npm" alone, since it is one human act and not one per landing. - **The publish job builds the version commit, never `github.sha`.** It checks out `needs.release-integrity.outputs.version-commit` with `fetch-depth: 0`. The guard refuses an empty or mismatched ref (an empty `ref:` would silently fall back to `github.sha`), a commit that is not on `origin/main` as fetched after the approval, and a commit whose parent carries the same version. The #6170 tripwire now runs against the version commit. The Turbo cache key and the run summary name it too. - **A stale prompt does not stay waiting.** The new `stale-prompts` job (`actions: write`, `contents: read`) cancels a **push-lane** run whose `Publish VERSION to npm (awaiting approval)` job is waiting at `release` for a version that is already on npm. It never touches a dispatch run, the calling run, or a job that has started. As a second line, the publish guard refuses a stale prompt that gets approved anyway, outside `force`. - **`scripts/release-pending-publish.mjs` (new)** holds the three decisions (`select`, `npm-state`, `sweep`), with a battery-floored, handshake-guarded `--self-test` wired in `lint.yml`. - **ADR-0125**: an `Amended` status line, short notes under D1 and D5, and `## Amendment (2026-09-29, #20613)` at the end, written in the ADR's own amendment style. It corrects the record and does not reverse it: merging the Version Packages PR is still the decision, and approving `release` is still the authorisation. ## The dispatch's hypotheses, measured 1. **Predicate.** Confirmed: it was inline bash reading `github.sha`, now replaced as described above. Edge cases are pinned by the self-test and replayed on the real history: - A merge-queue batch that carries the version commit (run 36533007563, `7a1faf1a..3a89d45`) selects `8c87d26a` and is pending. The five pushes after it are not pending. - `before` all zeros, `before` missing from the clone, or `before` not an ancestor (a force-push): not pending, with a notice or warning. - A non-linear landing selects the merge commit on the first-parent chain. - A dispatch is pending exactly while the version is off npm. - A shallow clone is refused. At the graft boundary the oldest commit has no parent, so it would read as the commit that changed the version. 2. **Every `github.sha` read in `publish`, with the sha each one now takes:** - guard and summary: the version commit (the summary also names the head of the push that queued the run); - Turbo key: the version commit; - `release-spec-changes.sh`, `release-verify-npm.mjs`: they read the workspace, which is now the version commit; - `release-github-releases.mjs` reads `GITHUB_SHA`: left unchanged, see Acceptance notes. 3. **Concurrency.** Measured from the Actions API, and the mechanics differ from the card's wording. The job that takes `release-publish-REF` then waits at the environment **while holding the group**. Each later publish job pends behind it and evicts the pending job before it, in the same second it is created. On 17.5.0 that happened at 06:48:38, 06:52:42/43, 06:58:40/41, 07:12:44/45 and 07:21:13. So a stale prompt does more than look like a real one: it **hides the real one**. The 17.4.0 prompt held the group while all of 17.5.0's publish jobs pended behind it. `cancel-in-progress: false` is untouched. 4. **Stale prompt.** Three options were available: - **Reject**: not available to a workflow. The review endpoint answers only a required reviewer, and the `release` environment's reviewers are a single user. - **Report**: needs `actions: read`, but the prompt stays waiting and keeps holding the group. - **Cancel**: needs `actions: write`. I chose cancel, limited to push-lane runs, plus the guard refusal described above. **This is live right now:** run 36539819278's `Publish 17.5.0 to npm` job has been waiting since 08:11:15Z, while cli@17.5.0 has been on npm since 07:58:57Z. A GET-only replay of the sweep against the live repository judges it `cancel`. The author cancelled nothing. 5. **Pins.** `node scripts/release-pending-publish.mjs --self-test` runs 32 cases across 11 batteries. `actionlint` is not used in this repo; the workflow was parsed with `yaml@2.9.0` and gives 5 jobs with no errors. ## Verification record (head `ad8134464d`) - **Self-test:** exit 0, `32 cases across 11 batteries pass`. - **Ablations.** The fix was committed first, each ablation went through `scripts/ablation-replace.mjs`, and every restore was checked as blob equal to HEAD with `git diff HEAD` empty. | Mutation | Result | |:--|:--| | `earlier` answers pending | 4 red | | shallow refusal removed | 1 red | | dispatch exclusion removed from the sweep | 1 red | | version commit := head | 6 red | | `return` above the verdict | dispatch refuses (`returned without reaching its verdict`) | | battery renamed | floor red twice (`not declared`, `DID NOT RUN`) | The first handshake attempt was an empty operation: the tool refused it because the replacement contained the anchor. It was redone with an anchor that drops out. - **Wiring ablation:** with `lint.yml` at HEAD~1, `check-self-test-wired` exits 1 with `[self-test-not-run] scripts/release-pending-publish.mjs`. Restored, and the hash matches. - **Step scripts run as written.** The `audit` and `guards` steps were extracted from the YAML and run under `bash -e`: - `audit`, with npm/gh shims: the version push is pending on `8c87d26a`; a later landing gets a notice and is not pending; a zero `before` gets a warning and is not pending; a dispatch is pending; a published version takes the backfill path. - `guards`, in a throwaway `--shared` clone: - live approval: passes; - stale approval: refused; - `force`: passes; - empty ref: refused; - HEAD mismatch: refused; - head that is not the version commit: refused; - off-main version-shaped commit: refused; - shallow clone: refused. - **Gates.** `dispatch-gates --commands` derived 65 families at `ad8134464d`. 64 exited 0 and 1 is NOT MEASURED. `--ran` reconciled them as `65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN`. - NOT MEASURED: `check:type-check-debt`, PREREQUISITE NOT MET, because it needs the whole `packages/*` build. This diff touches no file under `packages/`. Declared to CI. - `check:doc-formula-expressions` needed `formula` and `lint` built first (through the verify lock, 4/4 cached); it then exited 0. - `check:pm-dispatch-gates`: 1976 self-test cases in 972.6s, and both halves exited 0. - **Lint, as a declared narrowing.** - Population: `eslint.config.mjs` applies 2 rules to `scripts/*.mjs` (from `--print-config`). - `--format json`: 1 file, 0 errors, 0 warnings. - The config is not type-aware (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. The other three changed files are YAML and Markdown, which the config does not lint. - The full `pnpm lint` is left to CI. - **Changeset:** `skip-changeset`. All four paths sit in the private root package (`.github/`, root `scripts/`) or `docs/adr/`. No non-private package's `files[]` names any of them, so nothing ships to npm. ## Acceptance notes - **Live stale prompt, run 36539819278.** After this merges, the first push to main cancels it (`stale-prompts`). Until then it holds `release-publish-refs/heads/main` and would hide the next version's prompt, the same way 17.4.0's prompt hid 17.5.0's. The maintainer can cancel it by hand before then; nothing in this PR touches it. - **`release-github-releases.mjs` reads `GITHUB_SHA`** for the CHANGELOG permalink and `target_commitish`. `GITHUB_*` variables cannot be overridden from a step. When that step runs, the tags already exist at the version commit (`release-publish.sh` pushed them), so `target_commitish` is unused, and the permalink points at the queuing head's CHANGELOG, which carries the version's section. Not changed, because the script is outside this card's file surface. Carrier: none. - **The `release-integrity` backfill** (Releases, `spec-changes.json --prepare/--attach` for an already-published version) still runs on `github.sha`'s workspace, not the version commit's. That is the same class (acting on `github.sha`) but it is the no-mint repair lane and outside this card's surface. Carrier: none. - **Run 36540562567** (push `7510663c87`, 08:05Z) is an observation, not a filed defect. Its audit found cli@17.5.0 on npm while 36536081716 was still mid-publish (the CLI is the canary; the publish finished at 08:11:15). It then took the backfill branch, and its Docker job failed at 08:09. The cause is NOT MEASURED: the job logs answer 403 through this container's proxy. - **Stale comment:** `release-verify-npm.mjs`'s header says the tree was "proved [to match] `github.sha`"; it is now the version commit. The file is outside the surface. - **ADR-0125 has no `scripts/adr-anchors/` entry**, before or after this PR. The implementing files name it in comments. - **From the checkout on, `publish` mixes two commits:** its steps come from the workflow at `github.sha`, and the scripts they run come from the version commit. This is documented in the YAML and in the ADR amendment. - **Residual race in `release-integrity`'s own group:** if the version push's audit is evicted, recovery is now the repair dispatch only. Every later push says so in a notice. - **The card's operational workaround** (pause the queue between merging the version PR and approving) is no longer needed once this lands. 17.5.0's aftercare is **not** in this PR; it is the maintainer's call. ## 维护者速读(草稿) ### 改了什么 发版流程改成只由「版本提交」(合并 Version Packages PR 的那次落地)排一个待批准的发布,批准后发布的就是这个提交,而不是批准那一刻 main 的最新代码。此外,如果某个等待批准的发布,其版本已经在 npm 上,下一次 main 落地时会自动取消它。 ### 为什么改 17.5.0 这次,版本 PR 合并之后又落地了 8 个 PR。每次落地都重新排了一个发布,并把前一个挤掉,最后被批准的那个发布的是 main 的最新代码,其中有一个破坏性变更,它的变更说明也不在 17.5.0 的 CHANGELOG 里。另外,17.4.0 留下的等待批准提示挂了 20 天,挡在 17.5.0 前面,结果被误批准。 ### 风险与代价(含回滚) - 新增一个持有 `actions: write` 的任务,只用来取消「版本已在 npm 上」的 push 触发的等待提示。它不取消手动 dispatch,也不取消已经开始的任务。 - 如果版本推送那一次的审计被并发挤掉,后面的落地不会再"顺带"补排发布,需要手动 dispatch 修复通道。原来的补排本身就是这次的缺陷。 - 合并后的第一次落地会自动取消当前挂着的 17.5.0 陈旧提示(run 36539819278)。 - 17.5.0 已发布内容的善后不在本 PR 里。 - 回滚方式:revert 本 PR 即可,没有数据迁移。 ### 席位意见 ### 你要做的(一个动作) 审阅 ADR-0125 的修订,然后合并本 PR。 --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6b37cd commit 73213a5

4 files changed

Lines changed: 1133 additions & 54 deletions

File tree

‎.github/workflows/lint.yml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3682,6 +3682,22 @@ jobs:
36823682
- name: Post-publish npm verification self-test
36833683
run: node scripts/release-verify-npm.mjs --self-test
36843684

3685+
# Release version-commit selection (ADR-0125 D1 as amended 2026-09-29).
3686+
# release.yml's publish lane queues a deployment only on the push that
3687+
# CARRIES the version commit, publishes that commit rather than a later
3688+
# push's head, and cancels a push-lane prompt still waiting for a version
3689+
# already on npm. scripts/release-pending-publish.mjs holds all three
3690+
# decisions. Its production path runs a few times a year, on a runner,
3691+
# against a history nobody can rewind, so its --self-test is the only
3692+
# instrument on that logic: throwaway repositories for a version commit
3693+
# then two landings, a merge-queue batch, a published version, a
3694+
# force-push and a shallow clone, each asserting the selected sha.
3695+
#
3696+
# Invoked as `node` rather than through a `pnpm check:*` alias: see the
3697+
# GATE INVOCATION IDIOM note at the top of this file.
3698+
- name: Release version-commit selection self-test
3699+
run: node scripts/release-pending-publish.mjs --self-test
3700+
36853701
# #3825 Node-version drift guard: a runtime pin is 18 separate string
36863702
# literals across .github/workflows, so a split is invisible until someone
36873703
# greps for it. One did open — every PR gate sat on Node 20 (EOL

0 commit comments

Comments
 (0)