From 1719c9aa0d7d76767e144a5eb3c1dec1d75a41e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 11:39:00 -0700 Subject: [PATCH 1/4] =?UTF-8?q?ci(release):=20npm-first=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E9=A1=BA=E5=BA=8F=EF=BC=8C=E6=A1=8C=E9=9D=A2=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E8=B5=84=E4=BA=A7=E5=BC=82=E6=AD=A5=E8=A1=A5=E6=8C=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:此前 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 --- .github/workflows/release.yml | 83 ++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 753e6bbb0..a51d195c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,8 +86,12 @@ jobs: retention-days: 7 release: - if: always() && github.event_name == 'push' && (needs.desktop.result == 'success' || needs.desktop.result == 'skipped') - needs: desktop + # npm-first: no longer `needs: desktop`. npm publish + GitHub Release run + # immediately on the tag push, WITHOUT waiting for the manually-gated + # (macos-signing environment) macOS build. The signed desktop assets are + # attached asynchronously afterwards by `attach-desktop-assets`. This keeps + # `npm i -g botmux` unblocked when macOS signing is slow / awaiting approval. + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: write @@ -123,10 +127,18 @@ jobs: echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" echo "Publishing $VERSION → npm dist-tag=$NPM_TAG, prerelease=$PRERELEASE" - - name: Require signed macOS assets for stable releases - if: steps.dist_tag.outputs.tag == 'latest' && needs.desktop.result != 'success' + - name: Require deepcoldy authority for stable (latest) releases + # npm-first removed the transitive authority gate that used to come via + # `needs: desktop` (desktop only runs for deepcoldy; the old "Require + # signed macOS assets" step then failed non-deepcoldy stable tags). With + # release decoupled from desktop, re-assert it explicitly: only deepcoldy + # may publish the `latest` dist-tag that every `npm i botmux` user gets. + # canary/beta/rc/next go to side dist-tags and stay open for branch + # gray-release by anyone. + if: steps.dist_tag.outputs.tag == 'latest' && (github.actor != 'deepcoldy' || github.triggering_actor != 'deepcoldy') run: | - echo "REFUSING: stable releases require macOS signing by deepcoldy." + echo "REFUSING: stable (latest) releases may only be published by deepcoldy." + echo "actor=${{ github.actor }} triggering_actor=${{ github.triggering_actor }}" exit 1 - name: Guard against downgrading npm latest @@ -253,28 +265,57 @@ jobs: echo 'CHANGELOG_EOF' } >> "$GITHUB_OUTPUT" - - name: Download macOS release assets - if: needs.desktop.result == 'success' - uses: actions/download-artifact@v4 - with: - name: botmux-macos - path: release-assets - - - name: Create GitHub Release with macOS assets - if: needs.desktop.result == 'success' + - name: Create GitHub Release (desktop assets attached asynchronously) + # npm-first: the Release is created right after npm publish, WITHOUT the + # macOS assets — those are uploaded later by `attach-desktop-assets` once + # signing/notarization finishes. So there is a short window where a + # stable Release exists with npm already live but no .dmg/.zip yet; the + # desktop bundle lands within the same run (minutes). softprops keeps the + # release editable so the later --clobber upload just adds the files. uses: softprops/action-gh-release@v3 with: body: ${{ steps.changelog.outputs.body }} prerelease: ${{ steps.dist_tag.outputs.prerelease == 'true' }} - files: release-assets/* - fail_on_unmatched_files: true - - name: Create GitHub prerelease without macOS assets - if: needs.desktop.result == 'skipped' && steps.dist_tag.outputs.prerelease == 'true' - uses: softprops/action-gh-release@v3 + attach-desktop-assets: + # npm-first tail: once the parallel `desktop` job has signed+notarized the + # macOS build AND the `release` job has created the GitHub Release, upload + # the .dmg/.zip onto that Release. Runs on the tag-push path (the + # workflow_dispatch refresh path is handled by `refresh-desktop-assets`). + # `needs: [desktop, release]` both must succeed: desktop for the artifact, + # release so the GitHub Release exists to upload onto. + if: github.event_name == 'push' && needs.desktop.result == 'success' && needs.release.result == 'success' + needs: [desktop, release] + runs-on: ubuntu-latest + permissions: + contents: write + env: + RELEASE_TAG: ${{ github.ref_name }} + steps: + - name: Download macOS build artifact + uses: actions/download-artifact@v4 with: - body: ${{ steps.changelog.outputs.body }} - prerelease: true + name: botmux-macos + path: release-assets + + - name: Attach macOS assets to the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "$RELEASE_TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" + + - name: Verify uploaded release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir downloaded-assets + gh release download "$RELEASE_TAG" \ + --pattern 'Botmux-*.dmg' \ + --pattern 'Botmux-*.zip' \ + --dir downloaded-assets \ + --repo "$GITHUB_REPOSITORY" + for file in release-assets/*; do + cmp "$file" "downloaded-assets/$(basename "$file")" + done refresh-desktop-assets: if: github.event_name == 'workflow_dispatch' From 9005f9dd520d95959877c82c4477533d2481c761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 12:03:10 -0700 Subject: [PATCH 2/4] =?UTF-8?q?ci(release):=20=E8=AE=B0=E5=BD=95=20npm-fir?= =?UTF-8?q?st=20=E7=9A=84=E7=AD=BE=E5=90=8D=E5=A4=B1=E8=B4=A5=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E8=B7=AF=E5=BE=84=EF=BC=88codex=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a51d195c4..127effc1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -277,6 +277,15 @@ jobs: body: ${{ steps.changelog.outputs.body }} prerelease: ${{ steps.dist_tag.outputs.prerelease == 'true' }} + - name: Note — desktop assets pending + # Make the npm-first window visible in the run log: npm + Release are + # live now; the macOS .dmg/.zip attach in the parallel attach-desktop-assets + # job after signing. If signing is rejected/fails/cancelled, recover by + # re-running this workflow via workflow_dispatch with this release_tag. + run: | + echo "npm ${GITHUB_REF_NAME#v} published and GitHub Release ${GITHUB_REF_NAME} created." + echo "macOS desktop assets (.dmg/.zip) attach asynchronously in attach-desktop-assets after signing." + echo "If signing is rejected/fails/cancelled, re-run this workflow via workflow_dispatch with release_tag=${GITHUB_REF_NAME} to rebuild+sign and attach." attach-desktop-assets: # npm-first tail: once the parallel `desktop` job has signed+notarized the # macOS build AND the `release` job has created the GitHub Release, upload @@ -284,6 +293,17 @@ jobs: # workflow_dispatch refresh path is handled by `refresh-desktop-assets`). # `needs: [desktop, release]` both must succeed: desktop for the artifact, # release so the GitHub Release exists to upload onto. + # + # FAILURE MODE (accepted trade-off of npm-first): the `desktop` job sits + # behind the macos-signing environment approval, which can wait up to 30 + # days and may be rejected / the build may fail / the run may be cancelled. + # In any of those cases npm + the GitHub Release are already live but this + # job never runs, so the Release keeps npm-only (no .dmg/.zip). There is no + # auto-retry here on purpose — signing needs a human approval, so retrying + # would just re-block. Recovery is manual and idempotent: re-run this + # workflow via workflow_dispatch with release_tag=vX.Y.Z; that rebuilds+signs + # macOS (fresh artifact, so the 7-day retention is irrelevant) and + # `refresh-desktop-assets` --clobber-attaches to the existing Release. if: github.event_name == 'push' && needs.desktop.result == 'success' && needs.release.result == 'success' needs: [desktop, release] runs-on: ubuntu-latest From 58cd65f39f21949720e61ec701500ff84af40dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 12:12:14 -0700 Subject: [PATCH 3/4] =?UTF-8?q?ci(release):=20=E6=81=A2=E5=A4=8D=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=20checkout=20=E9=92=89=E5=88=B0=20tag=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E7=AD=BE=E5=90=8D=E8=B5=84=E4=BA=A7=E4=B8=8E=20tag=20?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=E6=BA=90=EF=BC=88codex=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=20blocker=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex 复审 9005f9dd 抓到恢复路径 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 --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 127effc1b..1cbe06435 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,29 @@ jobs: RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} steps: - uses: actions/checkout@v6 + # Push builds the tagged commit (github.ref = refs/tags/vX.Y.Z). But + # workflow_dispatch's default checkout is the dispatch's selected ref + # (typically master), NOT release_tag — so the recovery path would sign + # "current master code + old version number" and --clobber it onto the + # old Release, making the desktop assets diverge from the npm tarball / + # git tag. Pin the checkout to the tag ref in both cases so the signed + # app is always built from exactly the released commit. + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.ref }} + + - name: Verify HEAD is the tagged commit + # Defensive: prove the checkout landed on the tag's commit (not master), + # so a mis-resolved ref can never sign the wrong source into a Release. + run: | + git fetch origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" --force --quiet 2>/dev/null || true + TAG_SHA=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}") + HEAD_SHA=$(git rev-parse HEAD) + echo "tag ${RELEASE_TAG} → $TAG_SHA ; HEAD → $HEAD_SHA" + if [ "$TAG_SHA" != "$HEAD_SHA" ]; then + echo "REFUSING: desktop build checked out $HEAD_SHA but tag ${RELEASE_TAG} is $TAG_SHA." + echo "Signed assets must be built from the exact released commit. Aborting to avoid attaching mismatched assets." + exit 1 + fi - uses: actions/setup-node@v6 with: @@ -268,10 +291,14 @@ jobs: - name: Create GitHub Release (desktop assets attached asynchronously) # npm-first: the Release is created right after npm publish, WITHOUT the # macOS assets — those are uploaded later by `attach-desktop-assets` once - # signing/notarization finishes. So there is a short window where a - # stable Release exists with npm already live but no .dmg/.zip yet; the - # desktop bundle lands within the same run (minutes). softprops keeps the - # release editable so the later --clobber upload just adds the files. + # signing/notarization finishes. So the Release exists with npm already + # live but no .dmg/.zip yet. Normally the desktop bundle lands within the + # same run (a few minutes) once macOS signing completes — but signing is + # behind a human approval gate that can wait up to 30 days, or be + # rejected / fail / be cancelled; in those cases the Release stays + # npm-only until the manual recovery re-run (see attach-desktop-assets). + # softprops keeps the release editable so the later --clobber upload just + # adds the files. uses: softprops/action-gh-release@v3 with: body: ${{ steps.changelog.outputs.body }} From 908206b0ff9b8c3c629b3243bd03479c10e4432a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 12:22:33 -0700 Subject: [PATCH 4/4] =?UTF-8?q?ci(release):=20desktop=20push=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=20checkout=20=E7=94=A8=20github.sha=20=E8=80=8C?= =?UTF-8?q?=E9=9D=9E=20github.ref=EF=BC=8C=E4=BF=AE=20TOCTOU=20=E4=B8=8D?= =?UTF-8?q?=E5=90=8C=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex 复审 58cd65f3 抓到 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 --- .github/workflows/release.yml | 36 ++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1cbe06435..211ad2b65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,27 +22,37 @@ jobs: RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} steps: - uses: actions/checkout@v6 - # Push builds the tagged commit (github.ref = refs/tags/vX.Y.Z). But - # workflow_dispatch's default checkout is the dispatch's selected ref - # (typically master), NOT release_tag — so the recovery path would sign - # "current master code + old version number" and --clobber it onto the - # old Release, making the desktop assets diverge from the npm tarball / - # git tag. Pin the checkout to the tag ref in both cases so the signed - # app is always built from exactly the released commit. + # Build from the EXACT released commit in both trigger paths: + # - push: use github.sha (the immutable commit the tag pointed at when + # the event fired), NOT github.ref. Passing the ref name would make + # checkout re-resolve the tag at checkout time — and desktop runs + # behind a manual approval that can lag up to 30 days, during which + # the tag could be moved (repo ruleset excludes canary/beta/rc from + # tag-update protection). If tag moves A→B after `release` already + # published npm from A, a ref-name checkout would sign B → assets + # diverge from the npm tarball. A pinned SHA cannot move. + # - workflow_dispatch: no event SHA for the tag, so resolve the tag ref + # explicitly (default checkout would be the dispatch ref = master). + # The Verify step below then fail-closes if the tag no longer points at + # the commit we built (tag moved), so mismatched assets never attach. with: - ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.ref }} + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.sha }} - name: Verify HEAD is the tagged commit - # Defensive: prove the checkout landed on the tag's commit (not master), - # so a mis-resolved ref can never sign the wrong source into a Release. + # Prove the built commit still equals what the tag points at. On push, + # HEAD is github.sha; if the tag was moved during the approval wait, the + # current tag commit won't match HEAD → fail-closed (never attach assets + # built from a commit the tag no longer names). On workflow_dispatch this + # also catches a mis-resolved ref (e.g. accidental master checkout). run: | git fetch origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" --force --quiet 2>/dev/null || true TAG_SHA=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}") HEAD_SHA=$(git rev-parse HEAD) - echo "tag ${RELEASE_TAG} → $TAG_SHA ; HEAD → $HEAD_SHA" + echo "tag ${RELEASE_TAG} → $TAG_SHA ; HEAD (built) → $HEAD_SHA" if [ "$TAG_SHA" != "$HEAD_SHA" ]; then - echo "REFUSING: desktop build checked out $HEAD_SHA but tag ${RELEASE_TAG} is $TAG_SHA." - echo "Signed assets must be built from the exact released commit. Aborting to avoid attaching mismatched assets." + echo "REFUSING: built commit $HEAD_SHA is not what tag ${RELEASE_TAG} now points at ($TAG_SHA)." + echo "The tag was likely moved after npm was published from the original commit." + echo "Signed assets must match the released commit; aborting to avoid attaching mismatched assets." exit 1 fi