返回博客

Mac 应用的站外分发:Developer ID、公证与 GitHub Release

macosdeveloper-idnotarizationcodesigngithub-actionsdeployment

一句话结论:自己分发的 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 用来签 .appDeveloper ID Installer 用来签安装包,而单纯发 DMG 用不到它。
  • .p12 的导出密码是导出时你自己设的那个,跟你的 Apple ID 密码是两回事。
  • .p12 和它的密码不要进 Git 仓库、CI 日志和笔记软件。它是私钥。

Apple 在 Developer ID certificatesSigning 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 应该同时列出 arm64x86_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.plistContents/MacOSContents/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 仍然拦,就检查用户下载到的那个文件是装订过的最终产物,还是流程里的中间产物。

hdiutilcreate-dmg 报错

检查源 .app 是否存在、bundle 结构是否完整。检查有没有上一次运行残留的已挂载卷、同名目录,或者目标位置上的陈旧文件。这一步结束前先把卷卸载掉。

需要 Sparkle 吗

只有当应用要原地自我更新时才需要。那需要一条 appcast 源和一个 Ed25519 签名密钥。用户从 GitHub Release 页面下载、自己安装的发布方式不需要 Sparkle,而且以后再加进来也不会让这里做的任何事失效。

ad-hoc 签名不是发布凭据

ad-hoc 签名的构建在你自己的机器上能过。它说明不了 Developer ID 签名或公证会不会成功,因为本地构建完全跳过了签名验证和公证。要测这条流水线,就拿一个真实的发布产物,在一台本来不信任你的机器上测。

这也是我发布的那些 macOS 应用所用的流程,Kipless 是其中之一。我以 MPL-2.0 协议发布它,形式是签名并公证过的 DMG。

常见问题

Developer ID 和公证有什么区别?

Developer ID 是一张证书,用它签名告诉 Gatekeeper 这个应用是谁做的。公证是 Apple 对成品做的另一道自动化检查,`stapler` 把检查结果装订回文件上。站外分发两件都需要。

怎么在命令行公证一个 macOS 应用?

用 `xcrun notarytool submit` 提交最终的 DMG 或 ZIP,认证方式可以是带专用密码的 Apple ID,也可以是 App Store Connect 的 API key,加 `--wait` 阻塞到结果返回。失败时跑 `xcrun notarytool log` 看日志,而不是去看 submit 输出的最后一行。

为什么公证之后 Gatekeeper 还是拦?

检查上传的产物有没有带签名、签名之后有没有重新打包、bundle 里的 helper 或 framework 有没有漏签、以及用户下载到的是不是那个装订过的最终文件而不是流程里的中间产物。要验证,就拿你真正打算发布的那个文件,在一台没有开发证书的 Mac 上打开。

站外分发 Mac 应用一定要用 Sparkle 吗?

不一定。Sparkle 负责让应用原地更新,那需要一条 appcast 源和一个 Ed25519 签名密钥。用户从 GitHub Release 页面下载、自己安装的发布方式,两样都不需要。

公证用 Apple ID 还是 App Store Connect 的 API key?

两种 `notarytool` 都接受。带专用密码的 Apple ID 适合单个个人项目。API key 适合多个项目或以组织身份发布,因为一个 key 覆盖所有应用。两套都配上只会多一个失败点,不会多出任何能力。