fix(release): abort if Sparkle signature is empty + in-repo RELEASE-GUIDE

release.sh previously printed "SKIP Sparkle signing" when the private
key was absent on the release machine, but then still generated and
pushed an appcast with sparkle:edSignature="". This is exactly how
v2.1.6 shipped unsigned — every user saw "此更新未正确签名".

Now: if ED_SIG is empty after the sign_update step, exit 1 with a
diagnostic pointing at docs/RELEASE-GUIDE.md §3 (canonical key).

Also copies the release guide into the repo at docs/RELEASE-GUIDE.md
so new admins don't need the chat-history PDF. Includes the v2.1.6
incident + emergency-resign recipe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
徐翔宇 2026-04-19 00:09:04 +08:00
parent 8485179d1d
commit a547d43470
2 changed files with 296 additions and 6 deletions

272
docs/RELEASE-GUIDE.md Normal file
View file

@ -0,0 +1,272 @@
# MioIsland 发版指南
> 面向项目管理员与有发版权限的合作者。包含完整的发版流程、密钥信息和故障排除。
>
> 这是仓库里的权威文档。聊天记录里的旧版可能过时,以这一份为准。
## 1. 分支架构
| 分支 | 用途 | 自动化 |
|------|------|--------|
| `main` | 开发主干,合并 PR发版代码 | — |
| `landing-page` | 介绍页 + `appcast.xml`Sparkle 更新源) | push 后自动部署到 GitHub Pages |
| `feature/*` | 功能分支 | PR 合并到 main |
## 2. 关键文件
| 文件 | 说明 |
|------|------|
| `scripts/release.sh` | 一键发版脚本build + 签名 + DMG + appcast + 部署) |
| `scripts/generate-keys.sh` | 生成 Sparkle EdDSA 密钥对(只需跑一次) |
| `.sparkle-keys/eddsa_private_key` | EdDSA 私钥,**不入 git**,发版必需 |
| `ClaudeIsland/Info.plist` | 包含 `SUFeedURL``SUPublicEDKey` |
| `landing/public/appcast.xml` | landing-page 分支Sparkle 检查更新的数据源 |
| `releases/appcast.xml` | 本地生成的 appcast由脚本自动同步到 landing-page |
## 3. 密钥信息
### EdDSA 私钥
```
jqkcvSdDnRdfhDPCmsFhVtE1lJMkP1ITOICXmRYuXBU=
```
存放路径:项目根目录 `.sparkle-keys/eddsa_private_key`
```bash
# 新机器设置私钥
mkdir -p .sparkle-keys
echo 'jqkcvSdDnRdfhDPCmsFhVtE1lJMkP1ITOICXmRYuXBU=' > .sparkle-keys/eddsa_private_key
```
### EdDSA 公钥(已写入 Info.plist
```
2099yGC8J95uPjXVnchyCOXCmRwyOhszsElVw/4ih2Q=
```
### 安全警告
- 私钥 **绝对不能** 提交到 git已在 `.gitignore` 中)
- 私钥丢失 = 必须生成新密钥对,旧版本用户无法平滑升级
- 私钥泄露 = 攻击者可以伪造更新包,必须立即换密钥
- 通过安全渠道(面对面、加密通信)传递私钥,**不要** 发到聊天群或邮件
## 4. 发版流程
### 前置条件
```bash
# 1. 安装 create-dmg
brew install create-dmg
# 2. 确认私钥存在
cat .sparkle-keys/eddsa_private_key
# 应输出: jqkcvSdDnRdfhDPCmsFhVtE1lJMkP1ITOICXmRYuXBU=
# 3. 确认 gh CLI 已登录
gh auth status
# 4. 确认在 main 分支且代码是最新的
git checkout main && git pull
```
### 一键发版
```bash
./scripts/release.sh v2.2.0
```
脚本自动完成:
1. 更新 `MARKETING_VERSION`(如 2.2.0
2. 递增 `CURRENT_PROJECT_VERSION`build number如 17 → 18
3. Build unsigned universal 二进制arm64 + x86_64
4. 打包内置插件stats.bundle 等)
5. Ad-hoc 签名
6. 创建 ZIP 包
7. 创建 DMG带 Applications 拖拽链接)
8. 用 EdDSA 私钥签名 DMG
9. **检查签名是否为空**——为空则 `exit 1`,绝不发版未签名 appcast
10. 生成 `releases/appcast.xml`
11. 自动切到 landing-page 部署 appcast.xml 并 push
12. 切回 maincommit 版本号变更,打 git tag
### 脚本跑完后手动执行
```bash
# 推送代码和 tag
git push origin main --tags
# 创建 GitHub Release上传 DMG 和 ZIP
gh release create v2.2.0 MioIsland-v2.2.0.dmg MioIsland-v2.2.0.zip \
--title "v2.2.0 — Mio Island"
```
### 验证
```bash
# 1. 确认 appcast 已更新
curl -s "https://miomioos.github.io/MioIsland/appcast.xml?nocache=$(date +%s)" \
| grep -E "shortVersion|edSignature"
# 应看到新版本号 + 非空签名
# 2. 确认 DMG 可下载
curl -sI https://github.com/MioMioOS/MioIsland/releases/download/v2.2.0/MioIsland-v2.2.0.dmg | head -3
# 3. 本地测一下自动更新(可选但强烈推荐)
# - 从 DerivedData 跑一个老 build 的 Mio Island
# - 点 Check for Updates
# - 确认走完"下载 → 验证签名 → 安装"全流程
```
## 5. 应用内升级原理
```
用户运行 v2.1.0 (build 16)
▼ 启动时自动 / 手动点击「检查更新」
▼ 请求 https://miomioos.github.io/MioIsland/appcast.xml
▼ appcast 内容: v2.1.1, build 17, DMG 地址, EdDSA 签名
▼ Sparkle 比较: 本地 build 16 < appcast build 17 弹出更新提示
▼ 用户点击「安装更新」
▼ 下载 DMG → 用 app 内置的公钥验证 EdDSA 签名
▼ 签名匹配 → 替换 /Applications 下的旧版本 → 自动重启
```
### 版本比较规则
Sparkle 用 `sparkle:version`(对应 `CFBundleVersion`,即 build number做比较**不是** marketing version。
- appcast 中 `sparkle:version` = build number如 17
- appcast 中 `sparkle:shortVersionString` = 显示给用户的版本号(如 2.1.1
- app 中 `CFBundleVersion` = 当前 build number如 16
**build number 必须严格递增**`release.sh` 会自动处理。
## 6. 多人开发协作
### 权限分级
| 角色 | 能做什么 | 需要什么 |
|------|---------|---------|
| 开发者 | 提交代码、创建 PR | GitHub 仓库写权限 |
| 管理员/发版人 | 以上 + 发版 | GitHub 写权限 + EdDSA 私钥 + `gh` CLI |
### 新管理员配置
```bash
# 1. 克隆项目
git clone https://github.com/MioMioOS/MioIsland.git
cd MioIsland
# 2. 设置私钥(向项目管理员索取)
mkdir -p .sparkle-keys
echo 'jqkcvSdDnRdfhDPCmsFhVtE1lJMkP1ITOICXmRYuXBU=' > .sparkle-keys/eddsa_private_key
# 3. 安装工具
brew install create-dmg
gh auth login
# 4. 首次 build下载 Sparkle SPM 包,生成 sign_update 工具)
xcodebuild -scheme ClaudeIsland -configuration Release build \
ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=""
# 5. 验证 sign_update 工具可用
find ~/Library/Developer/Xcode/DerivedData/ClaudeIsland-*/SourcePackages/artifacts -name "sign_update"
# 应输出一个路径
# 6. dry-run 验证签名链路
./scripts/release.sh v0.0.0-dryrun || true # 应该能跑到签名那一步,不要真推
```
**没有私钥就不要跑 release.sh。** 脚本会在签名失败时硬停,见下面的故障排除。
## 7. 故障排除
### 「此更新未正确签名」v2.1.6 事故实录)
**症状:** Sparkle 提示"此更新未正确签名,无法验证其真实性"。
**根因:** 线上 appcast.xml 的 `sparkle:edSignature=""` 为空。
**历史:** 2026-04-17 另一位协作者在没有 `.sparkle-keys/eddsa_private_key` 的机器上跑了 `release.sh v2.1.6`。老版 `release.sh` 在缺私钥时只打印 "SKIP Sparkle signing" 然后继续生成无签名 appcast 并推到 landing-page导致全量用户升级失败。
**修复:** 当时的紧急修复
```bash
# 下载已经在 GitHub 上的 DMG不要重做包SHA256 已经广播给了用户)
curl -LO https://github.com/MioMioOS/MioIsland/releases/download/v2.1.6/MioIsland-v2.1.6.dmg
# 用正确的私钥签名
SIGN_UPDATE=$(find ~/Library/Developer/Xcode/DerivedData/ClaudeIsland-*/SourcePackages/artifacts -name sign_update | head -1)
"$SIGN_UPDATE" MioIsland-v2.1.6.dmg --ed-key-file .sparkle-keys/eddsa_private_key
# 把输出的 sparkle:edSignature="..." 填到 landing-page 分支的 landing/public/appcast.xml
git checkout landing-page
# 手动编辑 landing/public/appcast.xml把空的 edSignature 替换进去
git commit -am "fix: sign v2.1.Z appcast"
git push origin landing-page
```
**预防:** 现在的 `release.sh` 如果签名为空会直接 `exit 1`,不会再把没签名的 appcast 推出去。
### 「You're up to date」但版本不对
Sparkle 比较的是 build number不是 marketing version。检查 appcast 的 `sparkle:version` 是否大于当前 app 的 `CFBundleVersion`
```bash
# 查看 app 的 build number
defaults read /Applications/"Mio Island.app"/Contents/Info CFBundleVersion
# 查看 appcast 的 build number
curl -s https://miomioos.github.io/MioIsland/appcast.xml | grep "sparkle:version"
```
### 「An error occurred in retrieving update information」
appcast URL 不可达。确认 `Info.plist` 中的 `SUFeedURL` 是:
```
https://miomioos.github.io/MioIsland/appcast.xml
```
确认 landing-page 分支的 appcast 已部署:
```bash
curl -s "https://miomioos.github.io/MioIsland/appcast.xml?nocache=$(date +%s)" | head -5
```
### 更新后 app 没有自动重启
用户需要在 macOS 系统设置 → 隐私与安全 → App 管理 中开启 Mio Island 的权限。首次更新时会提示。
### 私钥丢失怎么办
必须生成新密钥对,发一个特殊版本让旧用户手动下载:
```bash
./scripts/generate-keys.sh
# 更新 Info.plist 中的 SUPublicEDKey 为新公钥
# 在 GitHub release 页面和网站上提示用户手动下载新版本
```
## 8. 技术细节
| 配置 | 值 |
|------|-----|
| Sparkle 版本 | 2.6+ (SPM) |
| 签名算法 | Ed25519 (EdDSA) |
| 签名永久有效 | 是,不会过期 |
| appcast 地址 | `https://miomioos.github.io/MioIsland/appcast.xml` |
| GitHub Pages 来源 | `landing-page` 分支 → `landing/public/` |
| Build 方式 | unsigned + ad-hoc sign无需 Developer ID |
| 架构 | Universal (arm64 + x86_64) |
| 最低系统版本 | macOS 15.0 |

View file

@ -128,12 +128,6 @@ if [ -n "$SPARKLE_SIGN" ] && [ -f "$KEYS_DIR/eddsa_private_key" ]; then
echo ">>> Signing DMG with Sparkle EdDSA..."
SPARKLE_SIG=$("$SPARKLE_SIGN" "$DMG_PATH" --ed-key-file "$KEYS_DIR/eddsa_private_key" 2>&1)
echo " Signature: ${SPARKLE_SIG:0:40}..."
elif [ ! -f "$KEYS_DIR/eddsa_private_key" ]; then
echo ">>> SKIP Sparkle signing: no key at $KEYS_DIR/eddsa_private_key"
echo " Run ./scripts/generate-keys.sh to set up Sparkle signing"
else
echo ">>> SKIP Sparkle signing: sign_update tool not found"
echo " Build the project in Xcode first to download Sparkle package"
fi
# 8. Generate appcast.xml
@ -147,6 +141,30 @@ ED_SIG=$(echo "$SPARKLE_SIG" | grep -oE 'sparkle:edSignature="[^"]*"' | sed 's/s
SIG_LENGTH=$(echo "$SPARKLE_SIG" | grep -oE 'length="[^"]*"' | sed 's/length="//;s/"//' || true)
[ -z "$SIG_LENGTH" ] && SIG_LENGTH="$DMG_SIZE"
# HARD STOP: Sparkle auto-update breaks silently with empty signatures.
# v2.1.6 shipped a bare appcast (no key on the release machine) and every
# user saw "此更新未正确签名". We would rather abort now than push that.
if [ -z "$ED_SIG" ]; then
echo ""
echo "ERROR: Sparkle EdDSA signing did not produce a signature."
echo "Refusing to publish an unsigned appcast — every Sparkle auto-update"
echo "would fail with '此更新未正确签名'."
echo ""
if [ ! -f "$KEYS_DIR/eddsa_private_key" ]; then
echo "Cause: .sparkle-keys/eddsa_private_key is missing."
echo "Fix: ask the project admin for the canonical private key."
echo " See docs/RELEASE-GUIDE.md §3 for details."
elif [ -z "$SPARKLE_SIGN" ]; then
echo "Cause: sign_update tool not found."
echo "Fix: build the project in Xcode once to download the Sparkle"
echo " SPM package, then re-run this script."
else
echo "Cause: sign_update ran but returned unexpected output:"
echo "$SPARKLE_SIG" | sed 's/^/ /'
fi
exit 1
fi
echo ">>> Generating appcast.xml..."
cat > "$APPCAST_PATH" << APPCAST_EOF
<?xml version="1.0" encoding="utf-8"?>