一句话结论:自己分发的 Mac 应用需要三样东西——用 Developer ID Application 签名、对最终产物做公证、把装订凭据附到用户下载的那个文件上。少任何一样,Gatekeeper 都会在别人的 Mac 上把应用拦下来。
你在 Xcode 里写好一个应用,它在你的 Mac 上跑得好好的。别人下载了同一个构建,macOS 拒绝打开它。Apple 那边没有任何记录表明见过这份拷贝。
Developer ID 这条路解决的就是这件事。你签名,让 Gatekeeper 知道是谁做的;你公证,让 Apple 确认它检查过这个构建;然后你把一个 macOS 愿意打开的文件交给用户。
动手碰任何凭据之前,先定下你要走哪条路。
Developer ID 意味着你自己分发。用自己的证书签名,做公证,然后通过网站、GitHub Release 或者 Homebrew cask 发布。发布节奏你定,收入也归你。
Mac App Store 走的是 App Store Connect、Mac App Store 分发证书、沙盒和 App 审核。那套凭据在 Developer ID 这条路上用不了,反过来也一样。两套东西分开放。
开始之前需要什么
一张 Developer ID Application 证书
在 Apple Developer 账号的 Certificates, Identifiers & Profiles 里创建一张 Developer ID Application 证书。下载 .cer,装进「钥匙串访问」,然后从 我的证书 里把它连同一个你自己设的密码导出成 .p12。
几个要留意的点:
- Developer ID Application 用来签
.app。Developer ID Installer 用来签安装包,而单纯发 DMG 用不到它。 .p12的导出密码是导出时你自己设的那个,跟你的 Apple ID 密码是两回事。.p12和它的密码不要进 Git 仓库、CI 日志和笔记软件。它是私钥。
Apple 在 Developer ID certificates 和 Signing Mac software with Developer ID 里写了这一步。
你的 Team ID
账号里的 Membership details 能查到。它本身不是秘密,而且公证命令需要它。
一份公证凭据
两种选一种。
带专用密码的 Apple ID
适合单个项目。你需要一个开启了两步验证的 Apple ID,然后在 account.apple.com 创建一个专用密码。它跟你的 Apple ID 登录密码是两回事,可以单独吊销。
把它存成 notarytool 的一份 profile,就不用每次在命令行上带密码:
xcrun notarytool store-credentials "myapp-notary" \
--apple-id "APPLE_ID" \
--team-id "TEAM_ID" \
--password "APP_SPECIFIC_PASSWORD"
CI 里则直接显式传值:
xcrun notarytool submit artifact.dmg \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" \
--wait
App Store Connect 的 API key
当你发多个应用、或者以组织身份发布时用这个。一个 key 覆盖所有应用,而 Apple ID 是一个项目一个。在 App Store Connect 的 Users and Access → Integrations 里管理 key。你会拿到三个值:.p8 私钥、Key ID 和 Issuer ID。
.p8 只有一次下载机会。当场就把它存到靠谱的地方。
echo "$APPLE_API_KEY_BASE64" | base64 --decode > "$RUNNER_TEMP/api-key.p8"
xcrun notarytool submit artifact.dmg \
--key "$RUNNER_TEMP/api-key.p8" \
--key-id "$APPLE_API_KEY_ID" \
--issuer "$APPLE_API_ISSUER_ID" \
--wait
notarytool 两种凭据都收。两套都配上,只会多一个失败点,不会多出任何能力。
发布流程,一步一步
1. 起飞前检查
在构建任何东西之前先定下你要发的是什么。确认工作区是干净的,或者你清楚哪些改动包含在内。确认版本号、changelog 和 bundle identifier 与你的意图一致,并且你即将打 tag 的那个提交上 CI 是绿的。
产物文件名一次定死:不带空格,跨版本稳定。用 MyApp.dmg,不要用 My App 1.2 (final).dmg。版本信息属于 tag、release 标题和应用元数据。
一次发布里让三样东西保持一致:git tag、CFBundleShortVersionString、以及 release notes 里的版本。
git status --short
2. 构建通用二进制
除非有理由不这么做,两个架构都构建。
lipo -info MyApp.app/Contents/MacOS/MyApp
plutil -p MyApp.app/Contents/Info.plist
lipo -info 应该同时列出 arm64 和 x86_64。只列一个,说明这个应用在你预期的一半 Mac 上跑不起来。
3. 由内向外签名
用 Developer ID Application 签名,并打开 Hardened Runtime。
当应用里有 helper、XPC 服务、framework 或更新器时,先签最内层的 bundle,最后签主应用。
内嵌可执行文件 / XPC 服务
↓
framework / helper app
↓
主应用
验证结果:
codesign --verify --deep --strict --verbose=2 MyApp.app
codesign -dvvv MyApp.app
--deep 用来验证一个 bundle,它不会按正确顺序去签名。它会把外层 bundle 报成已签名,而内层 helper 根本没签,公证每次都会把这个构建打回来。
4. 打包 DMG
DMG 只承载你已经准备好的那个应用。它不签名,也不做公证。
签名后的 .app → hdiutil / create-dmg → .dmg
往下走之前先把 DMG 挂载起来看一眼。确认里面有 .app,.app 位于顶层而不是嵌在某个副本里面,Contents/Info.plist、Contents/MacOS、Contents/Resources 都在。如果 hdiutil 在 Apple Silicon 上抱怨文件系统,加上 --filesystem APFS。
5. 对用户真正要下载的那个产物做公证
提交最终的 DMG 或 ZIP,不是中间构建。
xcrun notarytool submit MyApp.dmg \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" \
--wait
被拒绝的原因在日志里,不在那条命令输出的最后一行。
xcrun notarytool log SUBMISSION_ID \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD"
改任何东西之前,先把日志读完。
6. 装订,然后验证你将要发布的那个文件
xcrun stapler staple MyApp.app
xcrun stapler validate MyApp.app
spctl -a -vv --type execute MyApp.app
公证的是 DMG?那 DMG 也要验证,因为用户拿到的是那个文件。
从你发布的地方把产物下载下来,挪到一台没有任何开发证书的 Mac 上打开。到目前为止所有检查都跑在一台本来就信任你的机器上。第二台 Mac 才能让你看到陌生人看到的东西。
7. 发布 release
从 tag 触发发布,让一个 workflow 跑完整条链。
git tag v1.0.0
git push origin v1.0.0
checkout tag
→ build
→ sign
→ package
→ notarize
→ staple
→ validate
→ publish the release
默认的 GITHUB_TOKEN 就能在同一个仓库里创建 release。只有当 workflow 要写到别的仓库时,才需要单独一个 personal access token,比如去更新别处的 Homebrew cask。
失败的时候
CI 上导入证书失败
按顺序检查这几项。secret 里存的是完整 .p12 的 base64,不是 .cer,也不是一个文件路径。导出密码就是导出时你设的那个。临时钥匙串已解锁。set-key-partition-list 里包含 apple-tool:、apple: 和 codesign:。钥匙串少了这几个权限时,security import 会成功而 codesign 失败,看起来像签名问题,其实是钥匙串问题。
公证把上传打回来
日志里写了原因。检查这几项:
- 签名身份不是 Developer ID Application。
- bundle 里的 helper 或 framework 没有签名。
- 嵌套 bundle 的签名顺序反了。
- 没有打开 Hardened Runtime。
- 你上传的文件没带签名,或者签名之后又把它重新打包了一遍。
- 版本或 bundle 元数据不完整。
Gatekeeper 还是拦
先弄清这个构建处在哪个状态。
- 未签名:macOS 拦下来,拦得对。
- ad-hoc 签名:本地开发够用,不是分发凭据。
- Developer ID 签名且已公证:你要达到的状态。
如果构建处在第三种状态而 Gatekeeper 仍然拦,就检查用户下载到的那个文件是装订过的最终产物,还是流程里的中间产物。
hdiutil 或 create-dmg 报错
检查源 .app 是否存在、bundle 结构是否完整。检查有没有上一次运行残留的已挂载卷、同名目录,或者目标位置上的陈旧文件。这一步结束前先把卷卸载掉。
需要 Sparkle 吗
只有当应用要原地自我更新时才需要。那需要一条 appcast 源和一个 Ed25519 签名密钥。用户从 GitHub Release 页面下载、自己安装的发布方式不需要 Sparkle,而且以后再加进来也不会让这里做的任何事失效。
ad-hoc 签名不是发布凭据
ad-hoc 签名的构建在你自己的机器上能过。它说明不了 Developer ID 签名或公证会不会成功,因为本地构建完全跳过了签名验证和公证。要测这条流水线,就拿一个真实的发布产物,在一台本来不信任你的机器上测。
这也是我发布的那些 macOS 应用所用的流程,Kipless 是其中之一。我以 MPL-2.0 协议发布它,形式是签名并公证过的 DMG。