--- name: release-app description: Cut and publish a new NarraCat-app version — bump the version number, build the Windows x64 package in CI, package + sign + notarize the macOS build locally, and publish both platforms into one GitHub Release. Use this whenever the user wants to ship a new version, asks what version is live, or wants to know whether a release is ready to go — 发新版 / 发版 / 发布新包 / 打包发出去 / 上线新版本 / 给用户推更新 / 出个正式版 / 现在线上是哪一版. Also use it for anything that goes wrong before, during, or after a release: notarization or signing looks stuck or failed, the release build fails to produce a shippable package, users report they are not receiving updates, the update feed looks broken, or a bad version needs rolling back — 公证卡住 / 签名失败 / 出包失败 / 用户收不到更新 / 更新链断了 / 退回上一版. This covers the release pipeline itself, not everyday CI, build or test failures. Releasing is irreversible and must always cover both platforms, so route any release-shaped request through this skill rather than improvising the commands. --- # 发版:mac + Windows 双平台 发一个新版本 = 抬版本号 → CI 出 Windows 包 → 本机打 mac 包并签名公证 → 两个平台的产物进**同一个** GitHub Release。 **这件事不可逆**:Release 一旦发布,所有装了 App 的用户都会自动收到。所以下面每一步的顺序和闸都不是形式主义,撞到闸就停下来问人,不要绕过去。 ## 唯一一条不能违反的规则:必须双平台 更新代理(`workers/narracat-update`)把**两个平台的更新清单都**翻译成 `releases/latest/download/<清单名>`: ``` /mac-arm64/latest-mac.yml → releases/latest/download/latest-mac.yml /win-x64/latest.yml → releases/latest/download/latest.yml ``` 所以一个**只含单平台产物**的 Release 一旦成为 `latest`,另一个平台的清单查询直接 404 —— 那条更新链就此断掉,直到下一次带上该平台产物的发布为止。不是"用户停在上一版",是**从此收不到任何更新,两端都没有报错**。 `release.mjs` 已经加了闸(必须显式给 `--with-win` 或 `--mac-only`),但闸拦得住命令、拦不住判断:`--mac-only` 只在 Windows 从未发布过、或 Windows CI 出不了包而 mac 有紧急修复要发时才用。**Windows 正式发布之后,用它就等于主动掐断 Windows 用户的更新链。** ## 两个平台为什么不能在同一个地方打 | 平台 | 在哪打 | 为什么只能在那儿 | |---|---|---| | macOS arm64 | 本机 | 签名要读本机钥匙串里的 Developer ID 证书,之后还要跑 Apple 公证 | | Windows x64 | GitHub CI | mac 交叉编译不出能用的 Windows 包(keytar / better-sqlite3 是 mac 原生二进制);且 SignPath 的免费签名硬要求"可验证地从源码构建" | 版本号一致不靠人盯:两边都读同一个 `package.json`(ADR-0038)。 --- ## 步骤 0:预检 先跑这个,它把该确认的一切一次报全(只读,不改任何东西): ```bash node .claude/skills/release-app/scripts/preflight.mjs ``` Windows 产物在本机时(`--with-win` 那条老路)再跑一次带目录核对: ```bash node .claude/skills/release-app/scripts/preflight.mjs --win-dir <目录> ``` 走 `--win-from-release`(常规路径)时不需要这一步 —— 产物在 GitHub 上,由发布脚本远程核验。 它检查:在不在 main、工作区干不干净、与远端同步没、版本号是否合法且高于线上已发布的 latest、这个 tag 是不是已经发过、三样凭证齐不齐、签名身份在不在、Windows 三件产物齐不齐且版本号对不对得上。 有 `✗` 就先解决再往下走 —— 这些问题在后面每一步都会重新撞上,只是那时候人已经等了半小时。把结果用大白话转述给用户,不要只甩脚本输出。 ## 步骤 1:定版本号 版本号由人决定,不会自己往上走(ADR-0038)。改 `package.json` 的 `version` 一处即可,其余全部从它派生。 | 这次发的是 | 怎么走 | |---|---| | 修 bug、小改进 | patch:`0.3.0` → `0.3.1` | | 有用户能感知的新能力 | minor:`0.3.0` → `0.4.0` | | 正式公开发布 | `1.0.0`(留给那一刻,不要提前用掉) | **改完要走 PR 合进 main**,不要直接推 main(有分支保护,直推不会被拒、只会留一条绕过记录)。 发版前值得先问用户一句:**这一版对用户来说是什么?** 这既决定 patch 还是 minor,也是 Release notes 的内容。可以用 `git log --oneline v<上一版>..main` 看这一版实际包含什么,用大白话总结给用户确认 —— 用户关心的是"能感觉到什么变化",不是提交列表。 **确认下来的那段话就写进 `docs/release-notes/<版本号>.md`**,跟版本号一起走 PR 合进 main。 它会成为 Release 页最前面的正文;不写就只有通用样板。 ## 步骤 2:CI 出 Windows 包(会自动传进 draft) ```bash gh workflow run windows-release-build.yml --ref main gh run watch --exit-status # 约 5 分钟 ``` CI 出完包会**直接把三件产物传进 `v<版本>` 的 draft Release**,本机不需要下载任何东西。 draft 不被匿名 API 与 `releases/latest` 看见,所以**传上去 ≠ 发出去** —— 人工确认闸仍在步骤 3。 > 为什么不是「下载 artifact 再上传」:产物 244MB,而国内实测下载约 23KB/s(要 3 小时), > 那条路等于把发版卡死。CI 在 GitHub 内网里传是秒级的。artifact 仍然保留,供调试与留档。 CI 拒绝往**已发布**的 Release 里传东西(那会把线上文件悄悄换掉而版本号不变)。撞上这个报错说明版本号忘了抬。 ⚠️ **CI 全绿不构成任何功能保证** —— 这条流水线从不启动界面,而已知的 Windows 崩溃全在渲染层。CI 只证明"包能打出来"。 ## 步骤 3:打 mac 包并发布双平台 ```bash bun --no-cache run release --win-from-release ``` `--win-from-release` = Windows 三件已由 CI 就位在 draft 里。发布前会远程核验它们真的在、且不是 0 字节的空壳(上传中断会留下名字齐全的空文件);缺任何一件都会中止并告诉你重跑哪条命令。 产物已经在本机时(少见)用 `--with-win <目录>` 那条老路,行为不变。 这一条命令会:打 mac 包 → 签名 → 公证 → 建 draft Release → 传两个平台的全部产物 → 最后才 publish。**20-40 分钟**,其中大部分是公证在等 Apple。 顺序纪律:先建 draft、传完全部资产、最后才 publish。draft 不被匿名 API 与 `releases/latest` 看见,所以在资产全部传完之前没有任何用户能看到这个版本 —— 任一步失败,Release 停在 draft,线上仍是上一版,零影响。 中途会有一个确认界面列出待传的全部文件。**这是最后一道人工闸**,让用户过目再确认。 ⚠️ **这条命令必须由用户在真实终端窗口里跑**(Terminal / iTerm)。它检查 `process.stdin.isTTY`, 拒绝任何非交互调用——包括 Claude Code 里的 `! 命令` 前缀,那个也不是 TTY。这道闸防的正是 「脚本或 CI 自动把版本发出去」,所以不要试图绕过它。把 `cd <仓库路径>` 和发布命令一起给用户, 让他粘进终端;发完让他说一声,再自己去核验完成判据(下面那节),不要凭他一句「好了」就收工。 ## 步骤 4:发完之后(不要跳过) **不需要再开任何「抬版本号」的收尾 PR。** 以前要把 `HIGHEST_SHIPPED_VERSION` 常量抬到刚发的版本、 再把 `package.json` 抬到下一个占位号,2026-09-16 起撤了:「线上最高是几」由预检和发布脚本直接问 GitHub 的 latest release,仓库里不再手抄。下一版的号在下次发版的第一步(步骤 1)再定, `package.json` 在两次发版之间停在刚发的那个号上是正常状态。 然后验证线上真的通了: ```bash curl -sS -o /dev/null -w "mac %{http_code}\n" https://update.narracat.com/mac-arm64/latest-mac.yml curl -sS -o /dev/null -w "win %{http_code}\n" https://update.narracat.com/win-x64/latest.yml ``` 两个都应该是 200。**任何一个是 404,就说明那个平台的更新链断了** —— 多半是这次的 Release 少传了那个平台的产物。 --- ## 会撞上的几件事 **公证轮询超时 ≠ 公证失败。** 脚本等不到不代表 Apple 没批。先查: ```bash xcrun notarytool info --key ... --key-id ... --issuer ... ``` 状态是 `Accepted` 的话,公证早就过了,只差把票据钉上去:`xcrun stapler staple `。这能省掉重新公证的二十多分钟 —— 别急着重跑整条流水线。 **dmg 的 sha512 与清单里的值对不上是正常的**,不是要修的 bug。electron-builder 的清单记的是 zip 的哈希,dmg 是另外打的。 **三样凭证缺一不可**(签名证书 / 公证凭证 / 语料 token),发版档在第一步就断言。其中语料 token 最阴 —— 缺了 App 照常能开、照常能写,只是永远不注入真人范例,**静默降质**,没人会发现。 **bun 不把 `.env` 传给 node 子进程**(`package:release` 正是 bun run → node),所以脚本自己加载 `.env.local` / `.env`。凭证明明配好了却报缺失,先往这个方向查。 **发版是给所有用户推送**,所以 mac 包发出去之前值得本机装一下走一遍(能打开、能新建项目、能写一章)。CI 和单测都证明不了这件事。 **Windows 首个 beta 是未签名的**,用户必然撞 SmartScreen 蓝屏警告(点"更多信息"→"仍要运行")。这是 SignPath Foundation 免费签名的硬条款要求的必经步骤 —— 必须先以未签名形态发布过才能申请 —— 不是疏忽,发之前跟用户说清楚。 ## 出事了要回退 不需要传任何文件,也不需要发新版本:打开 Release 页 → 编辑上一个正常版本 → 勾选 "Set as the latest release" → 保存。 更新代理认的就是 GitHub 的 `latest` 标记,所以这个动作立刻让所有客户端回到上一版。 ## 完成判据 说"发好了"之前,这几条都要成立: - Release 页上这个 tag 存在、不是 draft、被标记为 latest - 两个平台的产物都在资产列表里(mac 五件 + Windows 三件) - 上面两条 `curl` 都返回 200 - **发布说明是给用户看的内容,不是「草稿:…」这类内部占位串**(`gh release view --json body -q .body`) 前四条任何一条不成立,都要如实告诉用户哪里没成,而不是报告"发布成功"。 > 发布说明那条是 0.3.1 踩出来的:CI 建 draft 时写的占位串被当成发布说明发给了所有用户, > 连「Windows 未签名会撞 SmartScreen」那段该告知的话都一起丢了(`releaseNotes()` 在 > `--win-from-release` 路径上根本没被调用)。脚本已修并有变异验证过的测试兜底, > 但**产物齐全 + 更新链 200 不代表这个 Release 对用户是像样的**,所以判据里单列一条。 **这一版对用户是什么,写在 `docs/release-notes/<版本号>.md`**(见该目录 README)。 发版时它会排在发布说明最前面,后面自动接上通用样板(升级方式 + 首次安装 + Windows 未签名提示)。 不写不阻断发版,但预检会出一条提醒,且发布说明就只剩样板、答不了「这一版改了什么」。 **步骤 3 那个确认界面现在会把真正会发出去的发布说明原样打出来** —— 按确认之前先读一遍它, 用户在 Release 页看到的就是那段。