Repository navigation
Commit 73213a5
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
- docs/adr
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3682 | 3682 | | |
3683 | 3683 | | |
3684 | 3684 | | |
| 3685 | + | |
| 3686 | + | |
| 3687 | + | |
| 3688 | + | |
| 3689 | + | |
| 3690 | + | |
| 3691 | + | |
| 3692 | + | |
| 3693 | + | |
| 3694 | + | |
| 3695 | + | |
| 3696 | + | |
| 3697 | + | |
| 3698 | + | |
| 3699 | + | |
| 3700 | + | |
3685 | 3701 | | |
3686 | 3702 | | |
3687 | 3703 | | |
| |||
0 commit comments