fix(release): 发布必须人工 —— push 车道结构性无发布能力,recover 只看 github.sha (#6170) - #6172
Merged
Conversation
维护者 2026-08-07 裁决:「刚才我也没提出要求,是哪个ai自己替我发了 rc.4, 版本发布必须是人工的。这个要写入规范。」 rc.3 (c6a52d3) 与 rc.4 (a10cbc7) 两次「无人发布」是同一个机制: changesets/action 的 version-PR 分支执行完后把工作区留在 `changeset-release/main` 的新版本树上(其 src/git.ts:checkout -b + reset --hard <sha> + 改版本 + commit,从不还原),紧接着的 recover-publish 步骤 从这个工作区读 packages/cli/package.json,于是永远读到「下一个版本」, npm 上永远没有,于是它的修复分支每次都真的跑 `pnpm run release`。 本 PR 把一个 job 拆成三条车道: - version-pr(仅 push):changesets/action 不再传 `publish:` 入参。 按 action 自身的 `hasPublishScript` 分支,runPublish 在任何输入状态下 都不可达——这是结构性的,不是 `if:` 能写错的条件。 - release-integrity(仅 push):版本一律用 `git show ${github.sha}:packages/cli/package.json` 从对象库读,并对 「工作区版本 ≠ github.sha 版本」设红线绊索。npm 已有该版本时才做 GitHub Releases / ADR-0087 D4 / 运行时镜像的补齐(补齐铸不出版本); npm 没有时只告警并写 step summary,绝不发布。#4898 / #4900 两个 合法修复场景(main 自己的版本不完整)照旧覆盖。 - publish(仅 workflow_dispatch + environment: release):全仓库唯一会跑 `changeset publish` / 推版本 tag 的地方。必须在 main 上、必须手输与 main 一致的版本号;发布前强制跑 check:objectui-pin-fresh(#3340, rc.4 正是绕过它才丢了 objectui 窗口 #6159)。此处直接调 `pnpm run release` 而不用 changesets/action——后者遇到待处理 changeset 会走 version 路径再铸一个版本。⚠️ 需维护者一次性配置:Settings → Environments → release → Required reviewers。未配置的 environment 是自动放行的,当下的保证来自 workflow_dispatch 触发器本身。 Fixes #6170 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BickTBKm2JYSNnrtPT8ysa
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
hotlong
marked this pull request as ready for review
August 7, 2026 07:07
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6170
Settings → Environments →
release→ Required reviewers → add reviewer(s).Stated honestly, because this is the one thing the YAML cannot do for itself: a GitHub
Environment with no protection rules passes automatically and silently. Adding
environment: releaseto a job creates the deployment gate but does not create anapproval. Until a maintainer configures required reviewers, the environment is a label,
not a gate.
So the load-bearing, YAML-only guarantee in this PR is the
workflow_dispatchtriggeritself: a dispatch event cannot be produced by a push, by the merge queue, by
github-merge-queue[bot], byGITHUB_TOKEN, or by a schedule. Someone with write accesshas to open Actions → Release → Run workflow and type the version. The environment adds a
second, explicit approval click on top of that — once, and only once, it is configured.
The environment itself is auto-created on first reference, so nothing here blocks before
that setting exists.
The ruling this implements
Maintainer, 2026-08-07 (verbatim, untranslated):
The mechanism, re-verified against the action's own source
The issue body's original hypothesis (branch protection swallowing the bot's push) is
superseded by its confirmation comment. I re-verified that comment's inference — "the
changesets action leaves the workspace on the freshly versioned tree" — rather than
relying on step timing, and it holds.
changesets/action@v1,src/git.ts+src/index.ts:There is no restore. The job therefore continues on the versioned tree, on branch
changeset-release/main, not on main's state. The next step,recover-publish, readpackages/cli/package.jsonfrom that workspace — so it always read the next version,which is always absent from npm, so its repair branch always fired and ran the real
pnpm run release.That single mechanism explains all three observations: 08-03 fired (computed rc.3 absent),
four quiet days (computed rc.3 present), 08-07 fired (after #6149 aligned main to rc.3,
computed rc.4 absent). Run 31146224227 is the receipt — event
push, actorgithub-merge-queue[bot], no human anywhere.I replayed exactly that state offline (the action's own git command sequence, then both
probes against the result):
What changed
One job became three lanes plus the unchanged docker job.
pushto mainversion-pr— keeps thechore: version packagesPR (#4935) currentpublish:inputpushto mainrelease-integrity— audits the version atgithub.sha, backfills only for a version already on npmworkflow_dispatchpublish—environment: releasechangeset publishor pushes a version tagR2 —
recover-publishmay never see a version main does not carryRewritten as the
release-integrityjob, and the invariant is expressed structurallythree ways rather than by one careful line:
workspace, whatever the repo state.
git show "${SHA}:packages/cli/package.json" | jq -r .version, withSHAbound togithub.shathrough the step env.github.sha,the step exits 1 with a named error instead of probing. This is the exact assertion the
old step lacked; had it existed on 2026-08-03 that run would have gone red instead of
publishing rc.3. In the new layout it is dormant by construction — that is the point of
keeping it.
Both legitimate repair cases still work, because both are "main's own version is
incomplete" and therefore have workspace ==
github.sha:absent from npm. Still detected. What changes is the action: the lane reports it and
writes a step summary naming the exact click, and does not publish.
existed): npm already has the version, so the backfill runs automatically, exactly as
the ruling permits — it cannot mint a version.
The npm-absent case stays green, deliberately. After the version PR merges and before a
maintainer publishes, main legitimately carries an unpublished version; that is now the
normal intermediate state of the repo. A red run on every push for the hours or days that
state lasts is how a signal gets trained into noise. It emits a
::warning::and a stepsummary with the three-step instruction instead.
R3 — a push-triggered run must be structurally unable to publish
Not an
if:, which is a condition someone can get wrong — the capability is simply absent:publish:input. The actionbranches on
hasPublishScript = !!publishScript(src/index.ts); with no publish scriptthe switch can only reach
runVersion, or the early return that logs "No changesetspresent or were removed by merging release PR. Not publishing because no publish script
found."
runPublishis unreachable from every input state the action can observe.changeset publish,pnpm run release,git push --tags,npm publish, orNPM_TOKEN. I assert this mechanically in theverification below rather than by reading.
publishjob carries all of it, gated byworkflow_dispatch+environment: release.The
publishjob also deliberately does not usechangesets/action. Handed aworkspace with pending changesets, the action would take the version path and mint a
commit — the very shape being removed.
changeset publishcan only publish the versionsthe checked-out
package.jsonfiles already declare.That is also how the issue's headline defect is closed. rc.3 and rc.4 tagged commits that
existed only on
changeset-release/main, so main kept stale versions and every laterrelease recomputed an npm-occupied number. The fix is not to push the version commit onto
main after the fact — it is that the publish lane can only ever ship a commit that is
already on main. The guard step refuses unless
GITHUB_REFisrefs/heads/mainand thetyped version equals
packages/cli/package.jsonon that ref.R4 — the gates the old lane bypassed now run on the publish path
pnpm check:objectui-pin-fresh(#3340) runs enforcing, unconditionally, before thepublish step. It was required on the Version Packages PR only, so a lane that published
without going through that PR never ran it — which is how rc.4 shipped
.objectui-sha = f995a452and dropped the 7-changeset objectui window (#6159) out of therelease record.
node scripts/check-changeset-fixed.mjs, the vendored console build,pnpm check:console-shaand the live hotcrm smoke (#2035, with the #3600 pre-modeamendment) moved onto the publish path with it — they were pre-publish gates, and the
publish they gate now lives there.
Per #6121 this runs the script, directly, as a step. It is
deliberately not added to any branch-protection required set:
Console Pin Freshnesshas no
merge_group:trigger, so requiring that context would deadlock every merge-queuegeneration. Nothing in this PR touches a required set.
There is no override input for the pin gate, on purpose. A stale pin at publish time means
the release record is about to be wrong; the remedy is
pnpm objectui:refresh, not abypass flag.
Logic walkthrough
Ordinary main push, pending changesets.
version-prruns the version pass and force-pushes
changeset-release/main(#4935 keeps regenerating — the maintainer's decision).release-integrityreads main's version (the last released one), finds it on npm, confirmsthe Releases / D4 asset / image, and exits. No publish exists in either job.
The
chore: version packagesPR merges.version-prfinds no changesets and returnswith "Not publishing because no publish script found."
release-integrityreads the newversion at
github.sha, finds it absent from npm, warns, writes the step summary, exits 0.This is the run that used to publish.
A maintainer publishes. Actions → Release → Run workflow, branch
main, version typedin.
environment: releaseapproval if configured. Guards check the ref and the version,gates run (pin freshness enforcing, fixed group, console stamp, hotcrm smoke), then
pnpm run release=changeset publish+ one atomicgit push origin --tags(#2191).Then GitHub Releases and the D4 attachment, then the docker job.
A publish reached npm and then died. The next main push's
release-integritysees theversion on npm, sees the Releases / D4 / image gap, and backfills — the #4900 repair,
unchanged in effect and now provably unable to mint anything.
Two lanes never displace each other.
concurrencyis now keyed bygithub.event_nameas well. GitHub keeps at most one pending run per group and cancels the older one, so
under a single shared group a burst of main pushes could silently evict a maintainer's
queued publish — the click would just evaporate. Still no
cancel-in-progress, sosame-lane runs serialise.
Docker.
needs: [release-integrity, publish]with!cancelled()— mandatory here, notstylistic: exactly one of the two upstream jobs runs on any event, so the other is always
skipped, and under GitHub's implicit
success()wrapper the job would never run at all.It also preserves the #4900 property that a job which reached npm and then failed still
gets its image.
Verification
No workflow was dispatched, nothing was published, no tag was pushed,
pnpm run releasewas never run — the ruling applies to this PR most of all. Validation was static.
Every gate in
.github/workflows/lint.yml's ESLint job, enumerated from the file and runone by one (27 steps). All pass. One found a real bug in my first draft and is worth
recording, because it is exactly the class this repo builds gates for:
An unquoted step name containing
:. Fixed by quoting; the name is now'Create or update the "chore: version packages" PR'with a comment saying why.After the fix:
The three gates that actually read
.github/workflows/**arecheck:nul-bytes,check:node-versionandcheck:workflow-status-functions— all three run and pass. TheTypeScript Type Checkjob's gates readpackages/specand TS sources; the only one thattouches a workflow is
check:type-check-coverage, and it readslint.yml, which this PRdoes not modify.
Structural walkthrough of the new file (job graph, every
needs.*.outputs.*andsteps.*.outputs.*reference resolved, the reusable-workflow target, and the #6170invariants asserted mechanically):
Reverse verification — direction predicted before running, and it was the ordinary red.
I re-armed the deleted limb (put
publish: pnpm run releaseback on the push-lanechangesets step) and re-ran the same walkthrough. Predicted red; got red, naming both
halves:
Plus the offline replay quoted further up, which is the more interesting direction: it
reproduces the incident under the old probe and shows the new probe reading main's own
version while the tripwire turns that same state red.
Deliberately not changed
must keep regenerating (maintainer decision: chore: version packages (rc) #4935 stays open and becomes the rc.5
proposal after release: 17.0.0-rc.4 published from a10cbc77 but the version commit never landed on main — 2026-08-03 shape repeated; merge it back before ANY further release action #6169's merge-back).
already fully on npm — it cannot mint anything.
Console Pin Freshness加进 required 集,而它没有merge_group触发器——照做即合并队列全死锁(observation) #6121).packages/,.changeset/andcontent/docs/releases/are untouched. Workflow-onlyPR, so no changeset —
skip-changesetapplies.Notes for review
version-prno longer runspnpm run build, the vendored console build or the hotcrmsmoke. Those were pre-publish gates; with no publish on that lane they gated nothing and
cost roughly nine minutes of every main push. They all moved to
publish.pnpm run versionischangeset versionplus two pure-fs sync scripts, and the changeloggenerator is
@changesets/cli/changelog(a published package), so nothing on that laneneeds a build.
fix is to bump
.objectui-shaimmediately before dispatching, not to add a bypass input.github.job, which changed fromreleasetopublish, so the first release after this merges builds cold once.🤖 Generated with Claude Code
https://claude.ai/code/session_01BickTBKm2JYSNnrtPT8ysa
Generated by Claude Code