Skip to content

fix(release): 发布必须人工 —— push 车道结构性无发布能力,recover 只看 github.sha (#6170) - #6172

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-6170-release-lane-human-gate
Aug 7, 2026
Merged

fix(release): 发布必须人工 —— push 车道结构性无发布能力,recover 只看 github.sha (#6170)#6172
hotlong merged 1 commit into
mainfrom
claude/issue-6170-release-lane-human-gate

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6170


⚠️ ONE-TIME MAINTAINER STEP — required for the approval click to exist

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: release to a job creates the deployment gate but does not create an
approval. 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_dispatch trigger
itself: a dispatch event cannot be produced by a push, by the merge queue, by
github-merge-queue[bot], by GITHUB_TOKEN, or by a schedule. Someone with write access
has 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):

刚才我也没提出要求,是哪个ai自己替我发了 rc.4,版本发布必须是人工的。这个要写入规范。


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:

prepareBranch(versionBranch):
    git checkout changeset-release/main      (or: git checkout -b changeset-release/main)
    git reset --hard  ...github.context.sha
  then the version command:  pnpm run version
pushChanges(...):
    git add .
    git commit -m 'chore: version packages'
    git push origin HEAD:changeset-release/main --force

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, read
packages/cli/package.json from 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, actor
github-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):

github.sha              = 94e35cda   (main carries 17.0.0-rc.3)
workspace HEAD          = 1b647f53   (branch changeset-release/main)

OLD probe   node -p "require('./packages/cli/package.json').version"   ->  17.0.0-rc.4
            => the NEXT version. npm view misses it => repair branch => publish. Incident reproduced.

NEW probe   git show "${SHA}:packages/cli/package.json" | jq -r .version  ->  17.0.0-rc.3
            tripwire: workspace 17.0.0-rc.4 != github.sha 17.0.0-rc.3  =>  exit 1 (red)

What changed

One job became three lanes plus the unchanged docker job.

event job can publish?
push to main version-pr — keeps the chore: version packages PR (#4935) current no — the changesets action gets no publish: input
push to main release-integrity — audits the version at github.sha, backfills only for a version already on npm no — the job contains no publish command at all
workflow_dispatch publishenvironment: release yes — the only job in the repo that runs changeset publish or pushes a version tag

R2 — recover-publish may never see a version main does not carry

Rewritten as the release-integrity job, and the invariant is expressed structurally
three ways rather than by one careful line:

  1. The job does not contain the changesets action. Nothing in it can re-version its
    workspace, whatever the repo state.
  2. The version is read from the object database, not off disk:
    git show "${SHA}:packages/cli/package.json" | jq -r .version, with SHA bound to
    github.sha through the step env.
  3. A tripwire. If the on-disk version ever disagrees with the version at 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:

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 step
summary 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:

  • The push-lane changesets step is invoked without a publish: input. The action
    branches on hasPublishScript = !!publishScript (src/index.ts); with no publish script
    the switch can only reach runVersion, or the early return that logs "No changesets
    present or were removed by merging release PR. Not publishing because no publish script
    found."
    runPublish is unreachable from every input state the action can observe.
  • Neither push-lane job contains changeset publish, pnpm run release,
    git push --tags, npm publish, or NPM_TOKEN. I assert this mechanically in the
    verification below rather than by reading.
  • The publish job carries all of it, gated by workflow_dispatch + environment: release.

The publish job also deliberately does not use changesets/action. Handed a
workspace with pending changesets, the action would take the version path and mint a
commit — the very shape being removed. changeset publish can only publish the versions
the checked-out package.json files 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 later
release 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_REF is refs/heads/main and the
typed version equals packages/cli/package.json on 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 the
publish 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 = f995a452 and dropped the 7-changeset objectui window (#6159) out of the
release record. node scripts/check-changeset-fixed.mjs, the vendored console build,
pnpm check:console-sha and the live hotcrm smoke (#2035, with the #3600 pre-mode
amendment) 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 Freshness
has no merge_group: trigger, so requiring that context would deadlock every merge-queue
generation. 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 a
bypass flag.


Logic walkthrough

Ordinary main push, pending changesets. version-pr runs the version pass and force-
pushes changeset-release/main (#4935 keeps regenerating — the maintainer's decision).
release-integrity reads main's version (the last released one), finds it on npm, confirms
the Releases / D4 asset / image, and exits. No publish exists in either job.

The chore: version packages PR merges. version-pr finds no changesets and returns
with "Not publishing because no publish script found." release-integrity reads the new
version 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 typed
in. environment: release approval 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 atomic git 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-integrity sees the
version 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. concurrency is now keyed by github.event_name
as 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, so
same-lane runs serialise.

Docker. needs: [release-integrity, publish] with !cancelled() — mandatory here, not
stylistic: 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 release
was 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 run
one 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:

✗ check-workflow-status-functions --self-test -- 1 failure(s)
  • the repo's own workflows parse cleanly, got .github/workflows/release.yml:
    YAML parse error -- Nested mappings are not allowed in compact mappings at line 166
        - name: Create or update the "chore: version packages" PR

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:

✓ check-workflow-status-functions --self-test: 34 assertions over temp fixture roots (real scan() path)
check-workflow-status-functions: OK (scanned 22 workflow file(s), 41 job(s), 24 job-level
  if: expression(s); 9 read needs.*.outputs.*, all naming a status function).
check-node-version: OK (25 setup-node step(s) across 22 workflow(s), all on Node 22).
check-nul-bytes: OK (scanned 5911 tracked text file(s); no raw ASCII control bytes).
✓ check-doc-authoring: 364 files clean    ✓ check:published-files: 69 publishable package(s)
✓ check:release-body: 49 assertions       ✓ check:release-notes: OK
✓ check:objectui-changeset: OK            ✓ check:merge-driver: OK
   ... (all 27 ESLint-job steps pass; `pnpm lint` included)

The three gates that actually read .github/workflows/** are check:nul-bytes,
check:node-version and check:workflow-status-functions — all three run and pass. The
TypeScript Type Check job's gates read packages/spec and TS sources; the only one that
touches a workflow is check:type-check-coverage, and it reads lint.yml, which this PR
does not modify.

Structural walkthrough of the new file (job graph, every needs.*.outputs.* and
steps.*.outputs.* reference resolved, the reusable-workflow target, and the #6170
invariants asserted mechanically):

✓ docker needs release-integrity / publish (both exist)
✓ all 4 needs.*.outputs.* references resolve to a declared job output
✓ all 16 steps.*.outputs.* references resolve to a step id in their own job
✓ version-pr:         no publish capability at all
✓ release-integrity:  no publish capability at all
✓ publish:            publish capability present, gated by workflow_dispatch + environment:release
✓ version-pr changesets/action inputs = [version, commit, title, createGithubReleases]
    -> no `publish:` input — runPublish unreachable by construction
✓ release-integrity reads packages/cli/package.json out of the object DB at github.sha
✓ release-integrity carries the workspace-vs-github.sha tripwire
✓ release-integrity contains no changesets action — its workspace cannot be re-versioned
✓ pin gate at step 10 precedes the publish at step 16, enforcing, no if:
✓ publish job: environment: release, if: github.event_name == 'workflow_dispatch'
✓ workflow_dispatch input `version` is required and consumed

Reverse verification — direction predicted before running, and it was the ordinary red.
I re-armed the deleted limb (put publish: pnpm run release back on the push-lane
changesets step) and re-ran the same walkthrough. Predicted red; got red, naming both
halves:

✗ version-pr: carries publish capability /pnpm run release\b/ but is NOT the workflow_dispatch lane
✗ version-pr: changesets/action carries a `publish:` input — publish branch is reachable
exit=1

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

Notes for review

  • version-pr no longer runs pnpm run build, the vendored console build or the hotcrm
    smoke. 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 version is changeset version plus two pure-fs sync scripts, and the changelog
    generator is @changesets/cli/changelog (a published package), so nothing on that lane
    needs a build.
  • The publish lane is strict by design. If the pin gate proves impractical in practice, the
    fix is to bump .objectui-sha immediately before dispatching, not to add a bypass input.
  • One-time cost: the turbo cache key is namespaced by github.job, which changed from
    release to publish, so the first release after this merges builds cold once.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BickTBKm2JYSNnrtPT8ysa


Generated by Claude Code

维护者 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
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 6:57am

Request Review

@hotlong hotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 7, 2026 — with Claude
@hotlong
hotlong marked this pull request as ready for review August 7, 2026 07:07
@hotlong
hotlong added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit b9ebb92 Aug 7, 2026
21 of 22 checks passed
@hotlong
hotlong deleted the claude/issue-6170-release-lane-human-gate branch August 7, 2026 07:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants