1. 从一次分发翻车说起为什么签名和公证不是可选项去年帮一个独立开发的朋友处理他的 Mac 小工具分发问题他打包了一个.app直接丢到网盘让用户下载结果十个用户里有八个打开就弹「已损坏无法打开你应该将它移到废纸篓」。他以为是压缩包坏了反复重传了三次最后才发现问题根本不在文件本身而在于 macOS 的 Gatekeeper 机制——没有经过签名和公证的应用在较新的系统版本上会被直接拦截连「仍要打开」的选项都不给。这件事很典型。很多开发者第一次做 Mac 分发时脑子里只有「编译出 .app」这一步完全没意识到从 Xcode 里跑起来和真正交付到用户手上中间隔着签名Code Signing、公证Notarization、装订Stapling三道关卡。这三件事做不好用户拿到的东西就是一堆打不开的二进制文件。这篇内容就是围绕Mac App 或 pkg 的签名、公证流程展开的。我会把整个链路拆开讲清楚签名到底签的是什么、公证在验什么、pkg 和 app 的处理差异在哪、命令行怎么一步步跑通、以及那些官方文档不会告诉你的坑。适合已经能打出包、但卡在分发环节的开发者也适合想把这套流程接进 CI 的工程同学。读完你应该能独立完成一次从零到可分发产物的完整操作。2. 先搞懂底层逻辑签名、公证、装订到底各管什么2.1 签名签的是「身份」和「完整性」代码签名本质上做两件事。第一是身份绑定用你的开发者证书给二进制文件盖个章证明「这个东西是我发的」。第二是完整性校验签名会计算文件内容的哈希用户运行时系统重新算一遍对不上就说明文件被篡改过。这里有个容易混淆的点签名不是给整个.app文件夹算一个哈希而是对 bundle 内部的每一个可执行文件、每一个 Mach-O 二进制分别签名再对整个 bundle 生成一个_CodeSignature目录记录清单。所以你如果签完之后又往 bundle 里塞了个文件签名就废了。这也是为什么签名必须是打包流程的最后一步。证书类型上常见的有两种Developer ID Application用于分发到 App Store 之外的应用Developer ID Installer用于给 pkg 安装包签名。这两个别搞混用错了公证会直接失败。2.2 公证是苹果服务端的「二次审核」公证Notarization是苹果在 2019 年 macOS 10.15 之后强推的机制。你把签名好的产物上传到苹果的公证服务它在云端做一轮自动化扫描检查有没有恶意代码、有没有用不合规的 API、签名是否有效。通过之后会给你一个「公证票据」ticket。关键点在于公证不改变你的文件内容。它只是给你发了一张「通行证」。用户第一次运行时系统会联网去苹果服务器查这个票据查到就放行。这就引出了下一个问题——如果用户没网呢2.3 装订是把票据「焊」进产物里装订Stapling就是解决离线验证的。它把公证票据直接附加到你的.app或.pkg文件上这样即使用户断网系统也能在本地找到票据完成验证。对于.app票据是附加在 bundle 上的对于.pkg票据是附加在安装包文件里的。流程顺序不能乱先签名再公证最后装订。装订之后产物内容变了但因为是苹果官方附加的票据签名依然有效。如果你装订完又去改文件那就前功尽弃。提示公证和装订是两个独立步骤。很多人公证通过了就以为完事结果用户离线安装还是报错就是因为漏了装订。3. 动手前的准备证书、工具与账号配置3.1 证书申请与导入你需要一个有效的开发者账号然后在开发者后台的 Certificates 页面创建Developer ID Application证书。创建时会让你上传一个 CSR 文件这个文件在「钥匙串访问」里通过「证书助理 → 从证书颁发机构请求证书」生成。拿到.cer文件后双击导入钥匙串确认它落在「登录」或「系统」钥匙串的「我的证书」分类下。这里有个细节证书必须带私钥才能用于签名。如果你是在另一台机器上生成的 CSR导入的.cer里是没有私钥的签名时会报no identity found。检查方法是在钥匙串里展开证书看下面有没有一把小钥匙图标。验证证书是否可用跑这条命令security find-identity -v -p codesigning输出里应该能看到类似Developer ID Application: XXX (TEAMID)的条目。如果列表为空或者只有Apple Development说明 Developer ID 证书没配好。3.2 公证凭证的两种配置方式公证需要向苹果服务认证身份有两种方式。第一种是用 Apple ID 加一个「应用专用密码」配置命令xcrun notarytool store-credentials myprofile \ --apple-id youremail.com \ --team-id TEAMID \ --password app-specific-password第二种是用 App Store Connect 的 API Key需要.p8密钥文件、Key ID 和 Issuer IDxcrun notarytool store-credentials myprofile \ --key /path/to/AuthKey_XXXX.p8 \ --key-id XXXXXXXXXX \ --issuer xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx两种方式都会把凭证存进钥匙串后续用 profile 名字引用即可。我个人更推荐 API Key 方式因为它在 CI 环境里更好管理不会因为账号密码变更而失效。注意notarytool是 Xcode 13 之后的新工具替代了老的altool。如果你还在用altool建议尽快迁移老工具已经停止服务了。3.3 环境检查清单动手前确认几件事Xcode 命令行工具已安装xcode-select --install、xcrun可用、网络能访问苹果服务、磁盘有足够空间公证上传会临时打包。可以用xcrun notarytool --version确认工具存在。4. App 签名与公证的完整实操链路4.1 对 .app 进行深度签名假设你有一个MyApp.app签名命令的核心是codesign。最稳妥的做法是加上--deep和--force但--deep其实有争议后面会讲。基础命令codesign --force --deep --options runtime \ --sign Developer ID Application: XXX (TEAMID) \ --timestamp \ MyApp.app逐项解释--force表示覆盖已有签名--options runtime启用强化运行时Hardened Runtime这是公证的硬性要求不加直接失败--timestamp请求苹果时间戳服务器盖章证明签名时间也是公证必需。签完之后验证codesign --verify --verbose4 MyApp.app spctl --assess --verbose4 MyApp.appcodesign --verify检查签名完整性spctl检查 Gatekeeper 评估结果。注意此时spctl大概率还是 reject因为还没公证这是正常的。4.2 打包成 zip 并提交公证公证工具接受.zip、.pkg、.dmg三种格式。对.app来说最方便的是先压成 zipditto -c -k --keepParent MyApp.app MyApp.zip用ditto而不是zip命令是因为ditto能保留 macOS 的文件元数据和扩展属性zip可能会丢掉符号链接导致签名失效。提交公证xcrun notarytool submit MyApp.zip \ --keychain-profile myprofile \ --wait--wait会让命令阻塞直到公证完成输出里会有一个status字段。成功是Accepted失败是Invalid。如果失败用这个命令拉取详细日志xcrun notarytool log submission-id --keychain-profile myprofile日志是 JSON 格式里面会列出具体哪个文件、哪条规则没过。常见的失败原因包括用了废弃的 API、二进制里带了不合规的 entitlements、某个嵌套的 framework 没签名。4.3 装订票据到 .app公证通过后装订xcrun stapler staple MyApp.app验证装订结果xcrun stapler validate MyApp.app spctl --assess --verbose4 MyApp.app这时候spctl应该输出acceptedsourceNotarized Developer ID。到这一步.app才算真正可分发。4.4 一个完整的脚本示例把上面步骤串起来写成可复用的脚本#!/bin/bash set -e APP_NAMEMyApp APP_PATH${APP_NAME}.app SIGN_IDDeveloper ID Application: XXX (TEAMID) PROFILEmyprofile ZIP_PATH${APP_NAME}.zip echo 签名 codesign --force --options runtime --timestamp \ --sign $SIGN_ID \ --entitlements entitlements.plist \ $APP_PATH echo 验证签名 codesign --verify --verbose4 $APP_PATH echo 打包 rm -f $ZIP_PATH ditto -c -k --keepParent $APP_PATH $ZIP_PATH echo 提交公证 xcrun notarytool submit $ZIP_PATH \ --keychain-profile $PROFILE \ --wait echo 装订 xcrun stapler staple $APP_PATH echo 最终验证 spctl --assess --verbose4 $APP_PATH这个脚本里我特意去掉了--deep改用手动指定 entitlements。原因在下一节讲。5. pkg 安装包的签名与公证差异5.1 pkg 的双层签名结构pkg 比 app 复杂因为它涉及两层签名。第一层是 pkg 内部可能包含的.app或二进制这些需要先用Developer ID Application证书签名第二层是 pkg 安装包本身需要用Developer ID Installer证书签名。很多人只签了外层 pkg结果安装后应用打不开就是因为内层没签。正确顺序是先签内层所有组件再打 pkg最后签 pkg。5.2 用 pkgbuild 和 productbuild 构建构建 pkg 通常分两步。先用pkgbuild打包单个组件pkgbuild --root ./payload \ --identifier com.example.myapp \ --version 1.0.0 \ --install-location /Applications \ MyAppComponent.pkg然后用productbuild合成最终安装包同时签名productbuild --package MyAppComponent.pkg \ --sign Developer ID Installer: XXX (TEAMID) \ --timestamp \ MyApp.pkg注意--timestamp同样不能少公证要求。5.3 pkg 的公证与装订pkg 的公证流程和 app 类似但提交的是.pkg文件本身不需要压 zipxcrun notarytool submit MyApp.pkg \ --keychain-profile myprofile \ --wait装订也是直接对 pkg 操作xcrun stapler staple MyApp.pkg xcrun stapler validate MyApp.pkg验证 pkg 签名用pkgutil --check-signature MyApp.pkg输出里会显示签名链和证书信息确认是Developer ID Installer且带时间戳。5.4 app 与 pkg 的流程对照环节.app.pkg签名证书Developer ID Application内层 Application 外层 Installer签名工具codesigncodesign productbuild公证提交格式需压成 zip直接提交 pkg装订对象.app bundle.pkg 文件验证命令spctl / codesignpkgutil / spctl常见坑嵌套组件漏签内外层证书混用这张表建议存下来每次打包前对一遍能省掉大量排查时间。6. 踩坑实录那些让公证反复失败的细节6.1 --deep 的陷阱codesign --deep看起来很方便它会递归签名 bundle 内所有嵌套内容。但苹果官方明确不推荐在生产环境用它原因是--deep的签名顺序和 entitlements 处理有缺陷可能导致某些嵌套组件用了错误的签名参数。正确做法是从内到外手动签名。先签所有.framework、.dylib、helper 工具再签主 app。可以用脚本遍历find MyApp.app/Contents/Frameworks -name *.framework -maxdepth 1 | while read fw; do codesign --force --options runtime --timestamp \ --sign $SIGN_ID $fw done签完内层再签外层这样每一层都用对了参数。6.2 entitlements 不匹配导致的运行时崩溃强化运行时开启后应用默认失去很多权限。如果你的 app 需要访问摄像头、麦克风、网络客户端等必须在 entitlements 文件里显式声明。比如?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keycom.apple.security.cs.allow-jit/key true/ keycom.apple.security.network.client/key true/ /dict /plist签名时用--entitlements entitlements.plist指定。漏了某个权限app 能通过公证但运行时会崩而且崩溃日志不会直接告诉你是 entitlements 的问题排查起来很痛苦。6.3 时间戳服务器超时--timestamp需要联网访问苹果的时间戳服务。网络不稳定时会报timestamp service is not available。这种情况重试通常能解决但在 CI 里建议加重试逻辑for i in 1 2 3; do codesign --force --options runtime --timestamp \ --sign $SIGN_ID MyApp.app break echo 签名重试 $i sleep 5 done6.4 公证日志的读法公证失败时notarytool log返回的 JSON 里最关键的是issues数组。每条 issue 有severityerror 或 warning、path哪个文件、message具体问题。warning 通常不影响结果error 必须解决。常见 error 及对策错误信息关键词原因解决not signed with a valid Developer ID某组件没签或证书不对检查嵌套组件签名hardened runtime not enabled漏了 --options runtime重新签名加参数invalid entitlementsentitlements 格式或内容问题检查 plist 语法binary not signed有未签名的可执行文件遍历找出并签名timestamp missing漏了 --timestamp重新签名6.5 装订后 spctl 仍 reject偶尔会遇到装订成功但spctl还是拒绝。先确认stapler validate通过然后检查是不是系统缓存问题。可以重置 Gatekeeper 缓存sudo spctl --reset-default或者换个干净的用户账户测试。还有一种可能是产物被移动或修改过装订票据失效需要重新走一遍流程。7. 把流程接进 CI自动化与效率优化7.1 凭证在 CI 中的安全存放CI 环境里不能交互式输入密码所以要用 API Key 方式配置公证凭证。把.p8文件内容存进 CI 的 secret 变量运行时还原成文件再执行store-credentials。证书则导出成.p12文件含私钥同样存 secret运行时用security import导入临时钥匙串security create-keychain -p $KEYCHAIN_PASS build.keychain security import cert.p12 -k build.keychain -P $P12_PASS -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple: -k $KEYCHAIN_PASS build.keychainset-key-partition-list这步很关键不做的话 codesign 会弹窗要权限在 CI 里直接卡死。7.2 缓存与并行优化公证上传是耗时大头产物越大越慢。可以在签名前先做一轮瘦身去掉调试符号、无用的本地化资源。另外公证本身是异步的--wait会阻塞如果 CI 支持后台任务可以提交后先做别的最后再查状态。7.3 版本号与构建号管理每次公证的产物最好带唯一版本号方便追溯。建议在构建脚本里自动从 git tag 或环境变量注入版本号避免手动改漏。pkg 的--version参数和 app 的Info.plist里的CFBundleShortVersionString要保持一致否则用户看到的版本会混乱。8. 几个我反复验证过的实操心得第一签名永远放在打包的最后一步。我见过太多人签完名又去改Info.plist或者替换图标结果签名失效公证白跑一趟。养成习惯所有资源改动完成后再签名。第二本地先跑一遍完整流程再上 CI。CI 环境变量多、日志难读本地用同样的脚本跑通一次能排除掉大部分配置问题。本地跑的时候把--wait加上能实时看到公证状态。第三保留每次公证的 submission-id 和日志。公证失败时日志是唯一能告诉你哪里出问题的东西。建议在脚本里把日志自动存到构建产物目录方便事后分析。第四测试分发用干净的系统。你自己的开发机可能因为装过证书、改过 Gatekeeper 设置而「放行」了未公证的应用导致你以为没问题。找一台没装过开发者证书的机器或者用虚拟机全新安装系统测试才能真实反映用户拿到产物后的体验。第五pkg 的内层签名别偷懒。如果 pkg 里装的是.app一定要先对.app完整签名再打进 pkg。我遇到过只签外层 pkg 的情况安装后应用能启动但某些功能异常排查半天才发现是内层签名缺失导致强化运行时行为不一致。这套流程跑熟之后从打包到可分发大概十几分钟其中大部分时间花在公证排队上。把脚本固化下来每次发版就是改个版本号、跑一条命令的事。真正麻烦的永远是第一次配置和那些边界情况配置对了后面就是重复劳动。