Skip to content

ci(release): npm-first 发布顺序,桌面签名资产异步补挂 - #646

Merged
deepcoldy merged 4 commits into
masterfrom
fix/release-npm-first
Jul 28, 2026
Merged

ci(release): npm-first 发布顺序,桌面签名资产异步补挂#646
deepcoldy merged 4 commits into
masterfrom
fix/release-npm-first

Conversation

@deepcoldy

@deepcoldy deepcoldy commented Jul 28, 2026

Copy link
Copy Markdown
Owner

背景

此前 Release workflow 的 npm 发布强依赖 macOS 签名:release job needs: desktop,且有一道「Require signed macOS assets for stable releases」硬闸。而 macOS 签名挂在 macos-signing 环境的人工审批门后。结果:签名慢 / 待批时,代码明明就绪,npm i -g botmux 也拿不到新版——正是 v3.7.0 发版时的体感。

申晗拍板走 Option B:解耦 npm 与桌面签名,npm 先发、桌面签名资产异步补挂。

改动

  • release job 去掉 needs: desktop(及 always() 门控),改为 push 即跑:npm publish + 创建 GitHub Release 不再等 macOS 签名。
  • 三道既有安全闸原样保留,且仍在 npm publish 之前执行
    1. 防降级 npm latest(semver 比较,拒绝比当前 latest 小的版本覆盖 latest)
    2. 正式版必须包含最新 origin/master(merge-base --is-ancestor,防从落后分支发 latest)
    3. dist-tag 路由(canary/beta/rc/next 走旁路 dist-tag,不碰 latest)
  • 新增显式「deepcoldy 才能发 latest」闸:原先「非 deepcoldy 不能发 stable」是needs: desktop 隐式传递的(desktop 只对 deepcoldy 跑 → 旧「Require signed macOS assets」步骤据此拦非 deepcoldy 的 stable tag)。解耦后这条隐式链断了,故显式重申,否则任何人推 stable tag 都能发 latest。canary/beta/rc/next 仍对所有人开放(灰度用)。
  • 新增 attach-desktop-assets jobneeds: [desktop, release]):签名完成 + Release 建好后,把 .dmg/.zip --clobber 补挂到该 Release 并下载校验(与 workflow_dispatch 的 refresh-desktop-assets 同法,只是走 tag-push 路径)。
  • 合并原「with assets / without assets」两个 Release 创建分支为一个无条件创建(不带 assets),assets 一律异步补。

权衡

正式版会有一个时间窗:npm 已上、GitHub Release 已建、但 .dmg/.zip 还没挂。正常情况下签名在同一次 run 内几分钟补齐;但 macOS 签名挂人工审批门(可等最多 30 天,或被拒/失败/取消),那时 Release 会保持 npm-only,直到手动 workflow_dispatchrelease_tag=vX.Y.Z)恢复 re-run 补挂。换取 npm 不再被签名审批门阻塞。

影响面

  • 只改 .github/workflows/release.yml;不碰产品代码。
  • 三道发版安全闸逐字保留、顺序仍在 publish 前;新增的作者权限闸补回解耦断掉的隐式保护。
  • prerelease(canary 等)路径:desktop 本就对非 deepcoldy skip;现在无条件创建 Release(带正确 prerelease 标记),比旧的「skipped && prerelease」分支更简单,行为等价或更好。

验证

  • YAML 解析通过。
  • 人工核对 release job 步序:Resolve dist-tag → 作者权限闸防降级闸必须含 master 闸 → build → npm publish → changelog → 创建 Release。三道 guard + 权限闸全部在 npm publish 之前。
  • ⚠️ 属发布基础设施改动,@codex 重点复审安全性:三道 guard 是否真被保留且未被 npm-first 绕过、新增作者权限闸是否等价替代原隐式保护、attach-desktop-assets 的 needs/if 是否正确、有无「Release 建了但 assets 永久缺失」的失败模式。

🤖 下次发版生效;本次 v3.7.0 已按旧流程发成功,不受影响。


复审后追加(codex review)

  • 9005f9dd:显式化签名失败运维窗口——attach-desktop-assets 注释写清失败模式+恢复方式,release job 加一步 run-log 打印「npm/Release 已上、资产异步补、失败如何恢复」。
  • 58cd65f3(blocker 修复):desktop job 的 actions/checkout 原先没钉 refworkflow_dispatch 恢复路径会检出默认 ref(master)而非 release_tag → 会把「当前 master 代码+旧版本号」签名挂到旧 Release,资产与 npm/tag 不同源。加「Verify HEAD is the tagged commit」fail-closed 校验。
  • 908206b0TOCTOU blocker 修复,当前 head):58cd65f3 的 push 分支用了 ref: github.ref(ref 名),checkout 会在运行时重解析 tag。desktop 挂 30 天审批门 + 仓库 ruleset 不保护 canary/beta/rc 的 tag update → tag 在 release 已按事件 SHA 发 npm(commit A)后、desktop 获批前被移到 B → desktop 会构建 B,且原 Verify 取「当前 tag」=B → B==B 放行 → npm(A) 与桌面资产(B) 不同源。改为:push 分支 checkout 用 github.sha(不可变事件 commit,不会移动),workflow_dispatch 分支仍 refs/tags/${release_tag};Verify 步骤据此真正生效(HEAD=A,tag 若移到 B → 当前 tag^{commit}=B≠A → fail-closed)。release job 不受影响(本就无 ref = 默认事件 SHA)。

背景:此前 Release workflow 的 npm 发布强依赖 macOS 签名(`release` job
`needs: desktop`,且「Require signed macOS assets」对 stable 硬闸),而
macOS 签名挂在 macos-signing 环境的人工审批门后。结果:签名慢/待批时,代码
明明就绪,`npm i -g botmux` 也拿不到新版——正是 v3.7.0 发版时的体感。

改动(Option B,申晗拍板):解耦 npm 与桌面签名,让 npm 先发、桌面异步补。
- `release` job 去掉 `needs: desktop` 与 always() 门控,改为 push 即跑;npm
  publish + 创建 GitHub Release 不再等 macOS 签名。
- 三道既有安全闸**原样保留、且仍在 npm publish 之前执行**:防降级 latest、
  正式版必须包含最新 master、dist-tag 路由(canary/beta/rc/next 走旁路)。
- 新增显式「deepcoldy 才能发 latest」闸,替代原先经 `needs: desktop` 传递的
  隐式作者权限(desktop 只对 deepcoldy 跑 → 旧「Require signed macOS assets」
  步骤据此拦非 deepcoldy 的 stable tag)。解耦后必须显式重申,否则任何人推
  stable tag 都能发 latest。
- 新增 `attach-desktop-assets` job(`needs: [desktop, release]`):签名完成 +
  Release 建好后,把 .dmg/.zip --clobber 补挂到该 Release 并校验(与
  workflow_dispatch 的 refresh-desktop-assets 同法,只是走 tag-push 路径)。
- 合并原「with assets / without assets」两个 Release 创建分支为一个无条件创建
  (不带 assets),assets 一律异步补。

权衡:正式版会有一个短时间窗——npm 已上、GitHub Release 已建、但 .dmg/.zip
还没挂(几分钟内同一次 run 补上)。换取 npm 不再被签名审批门阻塞。

验证:YAML 解析通过;人工核对步序确认三道 guard 全在 npm publish 之前、
作者权限闸对 latest 生效。因属发布基础设施改动,交 codex 复审安全性后再合。

Co-Authored-By: Riff <noreply@riff.dev>
@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner Author

codex 独立复审:✅ 代码层面通过(无 blocker)

固定基线:base 07dfad9e / head 1719c9aa,只改 .github/workflows/release.yml。我按 stable、canary/beta/rc/next、首次运行/重跑、desktop 成功/跳过/失败、workflow_dispatch 逐路径核了一遍。

1. 发布 guard 与 latest 权限:通过

  • Resolve npm dist-tag、防降级 latest、stable 必须包含最新 master 三段原逻辑未改;静态解析确认顺序仍为:dist-tag → 作者权限 → 防降级 → master ancestry → build → npm publish。没有 npm-first 绕过。
  • 新权限闸 latest && (actor != deepcoldy || triggering_actor != deepcoldy) 对 stable 的四种身份组合全部 fail-closed:只有两者都为 deepcoldy 才通过;他人首次 push、他人重跑 deepcoldy 的 run、deepcoldy 重跑他人的 run 都不能发 latest。它在“谁可发 latest”上等价且比旧的间接链条在部分重跑场景更严格。
  • GitHub 官方说明:重跑时 github.actor 仍是初始触发者,github.triggering_actor 是发起重跑者,且权限沿用初始 actor;同时要求二者为 deepcoldy 是安全方向:https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#github-context
  • 仓库现有 Protect v* tags ruleset 仍是外层保护;本 workflow 闸是必要的纵深防御。

2. attach-desktop-assets:成功路径正确;失败窗口是 Option B 的真实运维代价

  • needs: [desktop, release] + 两者 result == success 保证同一 run 的签名 artifact 已存在、GitHub Release 已创建后才上传;--clobber 可重跑,随后逐文件 cmp 校验内容。
  • 这里不需要 always():该 job 本就只应在两个依赖均成功时执行。GitHub 的 needs 语义也会在依赖失败/跳过时安全跳过:https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax-for-github-actions#jobsjob_idneeds
  • 无死锁,但会有预期的等待:desktopmacos-signing 环境审批前保持 Waiting,release 已独立完成,attach 等它。官方上限不是“几分钟”,而是审批最多可等 30 天;未获批会自动失败:https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/control-deployments#using-required-reviews-in-workflows
  • 因此若审批被拒、签名失败、run 被取消或 30 天未批,npm 与 Release 会存在而 .dmg/.zip 不会自动出现;需现有 workflow_dispatch → refresh-desktop-assets 手动修复。这个恢复口存在且路径未改,所以我不把它列为代码 blocker,但建议把代码注释/PR 描述里的“几分钟内、同一次 run 补上”改成“审批与签名成功后补;失败时手动 refresh”,避免运维预期过强。
  • 若 attach 自身失败,run 会红;artifact 保留 7 天,重跑该 job 可用 --clobber 恢复。超过保留期则同样走手动 refresh。
  • 另一个既有非原子窗口仍在:npm publish 成功后若 changelog/Release 创建失败,整 job 重跑会遇到版本已发布。这是 PR 前就存在的顺序,不是本次回归,可另列发布幂等性后续。

3. prerelease / 手动刷新:通过

  • 非 deepcoldy 的 canary/beta/rc/next:desktop skip,release 仍发布旁路 dist-tag并创建 prerelease: true,attach skip;与旧行为等价。
  • deepcoldy 的 prerelease:npm/Release 先完成,desktop 成功后再补资产;符合 Option B。
  • 非 deepcoldy stable:作者闸在 publish 前失败,不创建 npm latest 或 Release。
  • workflow_dispatch:release/attach 均 skip,既有 desktop → refresh 路径保持不变。

验证

  • pnpm build:通过。
  • YAML BaseLoader 解析 + job/step 顺序断言:通过。
  • git diff --check:通过。
  • base 与当前 origin/master 同为 07dfad9egit merge-tree 无冲突。
  • PR checks:CI build、CodeQL actions/javascript/python 全绿。
  • 影响面:仅 GitHub Release workflow;不触及 Linux/macOS 产品运行时、20+ CLI、PTY/Tmux、IM 或会话类型。macOS 影响仅发版编排与资产可用时序。

结论:代码可以合;当前 GitHub 凭据对应 PR 作者,平台不允许自 approve,所以以本评论记录独立复审结论。我没有执行合并,仍由申晗拍板。

codex 复审指出:macOS 人工审批可等待最多 30 天,若拒绝/签名失败/运行取消,
Release 会保留但桌面资产不会自动补挂。这是 Option B 的既定运维窗口(不做
自动重试——签名需人工审批,重试只会再次阻塞)。补充:
- attach-desktop-assets job 注释写清该失败模式 + 恢复方式(workflow_dispatch
  用 release_tag=vX.Y.Z 重跑,重建+签名后 refresh-desktop-assets --clobber 补挂,
  幂等;fresh artifact 故 7 天保留期无关)。
- release job 新增一步在 run log 里显式打印「npm/Release 已上、桌面资产异步补、
  失败如何恢复」,让运维一眼看到窗口与补救步骤。

Co-Authored-By: Riff <noreply@riff.dev>
@deepcoldy

Copy link
Copy Markdown
Owner Author

codex 增量复审 9005f9dd:🔴 仍有 1 个恢复路径 blocker

先确认新增的 Note — desktop assets pending 时点正确:它位于 Create GitHub Release 后、没有 if,因此默认只在前序步骤全部成功后执行,不会误报 Release 已创建。

但新提交把 workflow_dispatch + release_tag=vX.Y.Z 正式写成恢复手册后,暴露了既有 refresh 路径的同源性漏洞:

P1:只填 release_tag 不会 checkout 该 tag,可能把 master 的代码挂到旧 Release

当前 desktop 开头仍是:

- uses: actions/checkout@v6

没有 refworkflow_dispatchrelease_tag 只是普通 input,不会改变事件的 GITHUB_REF/GITHUB_SHA;GitHub 官方契约是 GITHUB_SHA 指向本次 dispatch 所选 branch/tag 的末端 commit,checkout 默认检出触发事件的 ref/SHA:

而 UI/gh workflow run 默认通常从 default branch 发起;仓库历史也印证:现有 6 次 workflow_dispatchheadBranch 全是 master。因此若发布 tag 后 master 已前进,再按当前新说明“只填 release_tag=vX.Y.Z”恢复,会执行:

  1. checkout 当前 master;
  2. npm version 强行改成旧 tag 版本;
  3. 构建/签名当前 master 代码;
  4. --clobber 挂到旧 tag 的 Release。

结果是 npm 包/tag 与桌面资产不再同一 commit,且校验步骤只比较上传前后字节,发现不了源码错配。这比“缺资产”等级更严重。

建议修法:在 workflow 内 fail-closed 固定源码,不把正确性留给操作员选择 dispatch ref。例如 desktop 的 checkout 在 workflow_dispatch 时显式使用 refs/tags/${{ inputs.release_tag }},push 时继续使用事件 SHA;随后打印并校验 HEAD 等于该 tag peeled commit。这样 UI 默认从 master 启动也只会使用 master 上的新 workflow 定义,但实际构建 tag 对应源码。

P2 文案仍自相矛盾(小项)

新增 30 天/失败恢复说明是对的,但同文件 release action 上方仍写:

desktop bundle lands within the same run (minutes)

PR body 也仍写“几分钟内、同一次 run 补上”。请一起改成“审批与签名成功后补;失败/取消需手动 refresh”,否则两套运维承诺冲突。

结论:9005 的可见性补充方向对,run-log 时点也对;但恢复路径必须先钉住 tag commit,修后我再做最终增量复核。未合并。

codex 复审 9005f9d 抓到恢复路径 blocker:desktop job 的 actions/checkout 没
指定 ref,workflow_dispatch 时默认检出所选 ref(通常 master)而非 release_tag。
「Sync version」只改版本号、不改代码,于是恢复 re-run 会把「当前 master 代码 +
旧版本号」签名后 --clobber 挂到旧 Release,桌面资产与 npm 包/git tag 不同源
(官方契约:workflow_dispatch 的 GITHUB_SHA=所选 ref 末端,非 tag)。

修复:
- desktop checkout 显式钉 ref:workflow_dispatch → refs/tags/${release_tag};
  push → github.ref(本就是 tag)。两条路径都从「被发布的确切 commit」构建。
- 新增「Verify HEAD is the tagged commit」步骤:校验 HEAD==tag commit,mis-resolve
  时 fail-closed(绝不把错源签进 Release)。
- 改掉自相矛盾的注释(原「几分钟/同一次 run」与新增 30 天审批失败说明冲突):
  Create Release 注释改为「正常几分钟内补,但签名挂人工审批门可等 30 天/被拒/
  失败/取消,那时 Release 保持 npm-only 直到手动恢复 re-run」。

注:这其实是 refresh-desktop-assets 的预存隐患,但本 PR 把它当恢复路径依赖,
故一并修。push 正常发版路径不受影响(本就检出 tag)。

验证:YAML 解析通过;desktop job 步序完整(checkout 钉 ref→verify HEAD→
setup-node→...→sign→upload);push 路径 ref 解析仍= github.ref。

Co-Authored-By: Riff <noreply@riff.dev>
@deepcoldy

Copy link
Copy Markdown
Owner Author

codex 增量复审 58cd65f3:🔴 push 路径仍有 TOCTOU blocker

先确认已经通过的部分:

  • workflow_dispatch → refs/tags/${release_tag} 修掉了 default-master 错源。
  • ^{commit} 对 annotated/lightweight tag 都正确;缺失 tag 在默认 bash -e 下 exit 128,fail-closed。
  • HEAD/tag 不同的显式 mismatch 会 exit 1。
  • PR body 与注释现在已把 30 天/失败恢复窗口讲一致。

但 checkout 表达式的 push 分支从“默认 checkout”改成了显式 github.ref,两者不等价

ref: ${{ workflow_dispatch ? refs/tags/input : github.ref }}

actions/checkout@v6ref input 时会同时保存 github.context.ref github.context.sha,从而钉住触发事件的 commit;显式传一个 tag ref 时,commit 为空,等 desktop 过审批门真正启动时会解析该 tag 的当前目标。源码见:https://github.com/actions/checkout/blob/v6/src/input-helper.ts

这在 stable 上有 ruleset 保护,但仓库的 Protect v* tags ruleset明确排除了 v*-canary.* / beta / rc,这些 tag 可在人工审批等待期间更新。于是可出现:

  1. tag push 事件 SHA=A,release job 立即从 A 发布 npm/Release;
  2. desktop 仍等待人工审批;
  3. prerelease tag 被移动到 B;
  4. desktop 获批后用显式 github.ref checkout B;
  5. Verify 再 fetch 当前 tag=B,HEAD == tag校验通过
  6. B 的桌面资产被挂到 A 发布的 npm 版本上,再次失去同源性。

我用本地 bare remote execution 复现了这个时序:

desktop verify passes: B
release(event SHA): A
desktop(github.ref after move): B
DIVERGENCE REPRODUCED while current Verify passes

最小修法:表达式 push 分支改成 github.sha,dispatch 保持 refs/tags/${release_tag}。checkout@v6 会把裸 SHA 识别为 commit 并钉死事件版本;随后 Verify fetch 当前 tag:未移动时 tag^{commit} == HEAD,移动后 mismatch fail-closed。这也覆盖 stable tag 被管理员意外重指的极端情况。

结论:恢复路径主 blocker 已修,但 push 分支需从 github.ref 改为 github.sha 后才能最终收敛。未合并。

…CTOU 不同源

codex 复审 58cd65f 抓到 push 路径 TOCTOU blocker:显式 `ref: github.ref`
不等价于 checkout 默认行为。checkout@v6 无 ref 时用 context.sha 钉住事件
commit;显式传 github.ref(一个 ref 名)会让 checkout 在**运行时重新解析**
该 tag。而 desktop 挂人工审批门(可等 30 天),且仓库 ruleset 明确不保护
canary/beta/rc 的 tag update——若 tag 在 release 已按事件 SHA 发布 npm(commit
A)之后、desktop 获批之前被移动到 B,desktop 会构建 B;且原 Verify 取「当前
tag」也是 B,B==B 通过,最终 npm(A)与桌面资产(B)不同源。codex 已 execution
复现(release=A、tag 移 B、Verify 放行 B)。

修复:
- push 分支 checkout 改用 `github.sha`(事件触发时 tag 指向的**不可变 commit**),
  不用 `github.ref`(ref 名会被重新解析)。SHA 不会移动,无论 tag 后续如何变,
  desktop 都构建 A。
- workflow_dispatch 分支仍用 `refs/tags/${input}`(dispatch 没有 tag 的事件 SHA)。
- Verify 步骤语义随之变实:HEAD=github.sha=A;若 tag 被移到 B,当前
  tag^{commit}=B ≠ HEAD=A → fail-closed,拒绝把与 tag 现状不符的资产挂上去。
- release job 不受影响:它本就无 ref(默认 = 事件 SHA A),npm 从 A 发布。

依据 checkout@v6 input-helper:显式 SHA 归入 commit 路径、不再按 ref 重解析。

验证:YAML 解析通过;push→github.sha、dispatch→refs/tags/input 两路径正确;
release job 仍 = 事件 SHA;annotated/lightweight/缺失 tag 的 Verify 行为不变。

Co-Authored-By: Riff <noreply@riff.dev>
@deepcoldy

Copy link
Copy Markdown
Owner Author

codex 最终增量复审 908206b0:✅ 代码层面通过,无剩余 blocker

58cd65f3 的 push TOCTOU 已按预期关闭:

  • push checkout 使用不可变的 github.sha,不再在审批后重解析可移动 tag ref;release 与 desktop 都固定在事件 commit A。
  • workflow_dispatch 继续显式 checkout refs/tags/${release_tag},不受默认 master dispatch ref 影响。
  • Verify 再 fetch 当前 tag^{commit} 与 HEAD 比较:tag 未动则通过,审批期间 A→B 则 HEAD 仍为 A、tag 为 B,fail-closed 拒绝签名/挂载。

我独立执行重放结果:

push/tag unchanged:      HEAD=A, tag=A → pass
push/tag moves A→B:      HEAD=A, tag=B → reject (exit 1)
dispatch pinned tag=B:   HEAD=B, tag=B → pass

上一轮的 annotated tag、lightweight tag、缺失/无效 tag、显式 HEAD mismatch 也均已执行通过预期;缺失 tag 在 bash -e 下由 rev-parse exit 128 直接 fail-closed。

最终验证:

  • head 固定 908206b0ff9b8c3c629b3243bd03479c10e4432a,base/当前 master 均为 07dfad9e,merge-tree 无冲突。
  • YAML ref 表达式/desktop 步序断言通过;git diff --check 通过。
  • 本地 pnpm build 通过。
  • GitHub CI build、CodeQL actions/javascript/python 全绿。
  • 原四项审查结论保持:三道 guard + latest 作者权限均在 publish 前;attach needs/if 正确;prerelease 路由正确;失败窗口与手动恢复已显式化且恢复构建与 tag 同源。

非阻塞文档小项:PR body 的“复审后追加”里 58cd65f3 那条仍写 push 用 github.ref,建议补一条 908206b0 或改为 github.sha,让过程摘要与最终代码一致;不影响代码安全结论。

结论:PR #646 代码可以合。当前 GitHub 凭据对应 PR 作者,平台不允许自 approve;以本评论记录最终独立复审。未执行合并,仍由申晗拍板。

@deepcoldy
deepcoldy merged commit 37d0fc4 into master Jul 28, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants