为 Swift 程序接入版本管理
本 skill 描述一条已在 macOS 项目上跑通的完整链路:Info.plist 版本号 → SwiftPM 打包 .app → DMG → Sparkle EdDSA 签名 → appcast → GitHub Release → 自动更新。assets/ 里是可直接复制的模板。
先确认目标形态
- 已经是
.appbundle 且有Resources/Info.plist→ 直接按步骤改造。 - 只有 SwiftPM 可执行文件、没有任何 bundle → 先用
assets/Info.plist.fragment.xml+assets/build-app.sh造 bundle,再把代码里依赖Bundle.main的路径校验一遍。 - 目标是 iOS / 非 Apple 平台 → 说明不适用,本链路只覆盖 macOS。
版本管理的最小定义:CFBundleShortVersionString(人看的,如 0.1.0)+ CFBundleVersion(机器比较的整数 build 号)。Sparkle 用后者判断新旧,必须单调递增。
步骤
-
写 Info.plist:复制
assets/Info.plist.fragment.xml进Resources/Info.plist的<dict>,替换{{...}}。关键是CFBundleIdentifier、CFBundleExecutable(必须等于 bundle 内可执行文件名)、SUFeedURL、SUPublicEDKey。 -
加 Sparkle 依赖:在
Package.swift加
swift .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4")
target 的 dependencies 加"Sparkle"。 -
接 Updater:复制
assets/Updater.swift,并在 App 的场景里加入菜单项:
swift CommandGroup(after: .appInfo) { Button("检查更新…") { Updater.shared.checkForUpdates() } .disabled(!Updater.shared.canCheck) } -
生成 EdDSA 密钥对(只需一次):
bash swift build --disable-sandbox # 先下载 Sparkle .build/artifacts/sparkle/Sparkle/bin/generate_keys
输出的公钥填进SUPublicEDKey;私钥由 Sparkle 存进登录钥匙串,不要提交、不要丢失。换机器时要导出/导入(generate_keys -x/-f),否则无法再签发更新。 -
放脚本:把
assets/build-app.sh、assets/make-dmg.sh、assets/release.sh、assets/appcast.xml放到scripts/与仓库根目录,按{{APP_NAME}}/{{owner}}/{{repo}}替换,然后chmod +x scripts/*.sh。appcast.xml首次可以先用占位 item,第一次发版时由release.sh生成。 -
首次发版:
bash ./scripts/release.sh --dry-run # 先看它打算做什么 ./scripts/release.sh # 0.1.0 → 0.1.1,打包+签名+写 appcast
release.sh只能递增版本号。要重发一个已存在的版本号(例如重建 0.1.0),不要用它,改为只把CFBundleVersion+1 后手动跑build-app.sh/make-dmg.sh/sign_update,再改 appcast 对应 item。 -
发布顺序固定:先创建 GitHub Release 并上传 DMG,再把 appcast 推到 main。反了会让用户拿到 404 的更新地址。
bash gh release create v0.1.1 build/<App>-0.1.1.dmg --title "<App> 0.1.1" --notes "发版说明" git add Resources/Info.plist appcast.xml && git commit -m "release: v0.1.1" && git push
验证
# 线上 appcast(raw CDN 有约 5 分钟缓存)
curl -s https://raw.githubusercontent.com/<owner>/<repo>/main/appcast.xml | head -20
# 资产大小与字节:下载后用 sha256 对比本地签名包,必须完全一致
curl -sIL https://github.com/<owner>/<repo>/releases/download/v<版本>/<App>-<版本>.dmg | grep -iE "HTTP/|content-length"
应用内实测(当前代码的 build 号一般等于待发版本,直接开它只会说“已是最新”):
cp -R build/<App>.app /tmp/<App>-test.app
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion 1" /tmp/<App>-test.app/Contents/Info.plist
codesign --force --deep --sign - /tmp/<App>-test.app
open /tmp/<App>-test.app
菜单里点“检查更新…”应提示新版本;安装后 CFBundleVersion 变为目标值。排查日志:
log stream --level debug --predicate 'subsystem == "org.sparkle-project.Sparkle"'
必须知道的坑
- 沙箱:
make-dmg.sh(hdiutil 访问/dev/rdisk*)和sign_update(读钥匙串私钥)在受限沙箱里都会失败,必须在外层执行。swift build需要--disable-sandbox。 - PlistBuddy 重排:
PlistBuddy -c "Set ..."会重写整个 Info.plist 的键顺序,产生巨大 diff。手动改版本号时直接编辑<string>值。 - build 号必须递增:只改
CFBundleShortVersionString不提 build,已安装的设备永远收不到更新。 - ad-hoc 签名可用:Sparkle 2 的判定是 EdDSA 或 Apple 代码签名任一通过即可,没有 Developer ID 也能发更新;但没 Apple 签名的话用户首次打开仍需去隔离标记(
xattr -dr com.apple.quarantine <dmg>)。 - 不要重新打包 DMG:appcast 里的
edSignature和length绑定的是那一份字节,重打后必须重新签名并重传。 - 网络:raw.githubusercontent.com 与 GitHub 资产下载在国内常直连超时,
curl/gh用HTTPS_PROXY;而 Sparkle 只认系统代理,测试前用scutil --proxy确认已开启。 gh auth login的网络/权限:设备码授权发生在浏览器,但 CLI 交换令牌需要能访问github.com;受限环境下加代理跑。