我之前 fork 了一份 Alacritty,把一些很有用但是上游维护者不肯合并的补丁都合了进来,像是上下左右可以分别设置的 padding、选区错位的修复、Big Sur 风格的图标,另外也加了一些自己的改动。之前 CI 只是把编译物产出来,没有签名,更没有公证。发给朋友之后,他们的电脑上会弹出 Apple 的警告,大意是无法打开,因为 Apple 无法检查其是否包含恶意软件,每次都得手动放行,很不方便。我刚好有付费的开发者账号,于是决定用它对构建出来的 app 签名并公证,直接在 CI 里做完。中途踩了不少坑,所以写篇文章记录一下,具体代码在 PR。签名和公证签名是用开发者证书给 app 盖一个章,证明它是谁发布的、发布之后没有被改过。公证是把签过名的 app 提交给 Apple,Apple 自动扫描一遍,没有问题就会记录下来。macOS 的 Gatekeeper 则在打开一个从网上下载的 app 时,检查它有没有签名,有没有公证。只有两样都有,才不会弹出刚才那种警告。要做的事情就是在 CI 里依次完成构建、签名、公证,最后打成 DMG 上传。在 CI 里导入证书签名需要 Developer ID Application 证书。先在本机的钥匙串里把它连同私钥导出成 .p12,转成 base64,放进仓库的 Secrets 里,证书相关的放了三个,证书本身、.p12 的密码,以及一个临时钥匙串的密码。在 CI 里每次都要新建一个临时钥匙串,需要先把证书导进去,然后用完再删掉。YAML复制- name: Install Apple certificate env: BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} P12_PASSWORD: ${{ secrets.P12_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH security default-keychain -s $KEYCHAIN_PATH这里面的 set-key-partition-list 这一行是绝对不能少的。没有它的话,codesign 去用私钥的时候 macOS 会弹窗问要不要允许,而 CI 里没有人能点,构建就会一直卡在那里。(血的教训😭)整个 job 的最后还有一步清理,if: always(),不管前面成功还是失败,都把这个临时钥匙串删掉。另外在构建之前,还加了一行 security find-identity -v -p codesigning,会把当前能用的签名身份在日志里打印出来,方便 debug。编辑 MakefileAlacritty 自带的 Makefile 在打包 .app 的时候,会用 codesign --sign - 做一次 ad-hoc 签名,也就是不使用任何证书的签名,这样的签名 Gatekeeper 是不认的。我把它换成了自己的证书。MAKE复制@codesign --force --deep --entitlements $(APP_ENTITLEMENTS) --options runtime --timestamp --sign "Developer ID Application: Bojin Li (TEAMID)" -v "$(APP_DIR)/$(APP_NAME)"后面带的三个参数都是用来公证的。--options runtime 打开 Hardened Runtime,--timestamp 附上 Apple 时间戳服务的时间戳,这两样是公证的硬性要求,缺了的话公证会直接被拒。--entitlements 指向一份权限声明文件。另外 Info.plist 里的 CFBundleIdentifier 原来是上游的 org.alacritty,我换成了自己的 xxxbrian.alacritty。Entitlements打开 Hardened Runtime 之后,app 访问摄像头、麦克风、通讯录、日历、定位、照片这些受保护资源,都得在签名的时候事先声明对应的 entitlement,不声明就会被直接拒绝。这是因为终端比较特殊,它虽然自己用不到这些,但是在终端里运行的程序会用到,比如调用摄像头的命令行工具,权限是算在终端头上的。如果不声明,这些程序在我的 Alacritty 里就跑不了。所以我得加一份 macos-entitlement.plist,把常见的几项都声明上。XML复制<key>com.apple.security.automation.apple-events</key> <true/> <key>com.apple.security.device.camera</key> <true/> <key>com.apple.security.device.audio-input</key> <true/> <key>com.apple.security.device.bluetooth</key> <true/> <key>com.apple.security.personal-information.addressbook</key> <true/> <key>com.apple.security.personal-information.calendars</key> <true/> <key>com.apple.security.personal-information.location</key> <true/> <key>com.apple.security.personal-information.photos-library</key> <true/>公证和 staple签完名的 universal .app 交给公证。我没有自己写 notarytool 的调用,直接用了 lando/notarize-action,提供 Apple ID、专用密码和 Team ID 就行。专用密码要去 appleid.apple.com 里单独生成,不能用 Apple ID 本身的密码。公证通过之后,要把公证的结果贴回 app 里,这一步叫 staple,我用的是 BoundfoxStudios/action-xcode-staple。贴上之后,用户在没有网络的时候也能验证这个 app 是公证过的。YAML复制- name: Make Universal run: | security find-identity -v -p codesigning make app-universal - name: Notarize Release Build uses: lando/notarize-action@v2 with: product-path: "./target/release/osx/Alacritty.app" appstore-connect-username: ${{ secrets.NOTARIZATION_USERNAME }} appstore-connect-password: ${{ secrets.NOTARIZATION_PASSWORD }} appstore-connect-team-id: TEAMID verbose: true - name: Staple Release Build uses: BoundfoxStudios/action-xcode-staple@v1 with: product-path: "./target/release/osx/Alacritty.app" - name: Make DMG run: make dmg-universal生成 DMG最麻烦的是最后的 DMG。Alacritty 的 Makefile 里,DMG 的目标是依赖 .app 的目标的,直接 make dmg-universal 会先把 .app 重新构建一遍,包括重新拷贝文件、重新签名。可是签名、公证和 staple 已经在前面做完了。我一开始的想法是加一个不依赖 .app 的目标,叫 dmg-universal-exist,直接拿现成的 .app 打包。但是写出来之后,它的规则和原来那条几乎一模一样,依赖也没有去掉,起不到任何作用,最后只好删掉,改回了 make dmg-universal,这就是 PR 里最后那个 fix: corrected DMG creation process。Important这里记录的是当时 PR 的最终状态,不是推荐的打包顺序。make dmg-universal 仍会重新执行 app 构建和签名,不能假定前一步贴好的公证票据仍然保留。更稳妥的流程是只打包已经签名、公证并 staple 的 app,不再重新构建;发布前应从最终 DMG 中取出实际分发的 app,执行文末的验证命令。最后最终的发布流程是这样的,推一个 tag,CI 就会在 macOS 上编译 x86 和 ARM 两个版本,合成 universal,用我的证书签名,提交公证,staple,再打成 DMG 传到 Release 里。构建出来的东西可以在本机上做一下验证。BASH复制codesign -dv --verbose=4 Alacritty.app spctl -a -vv Alacritty.app xcrun stapler validate Alacritty.app如果 spctl 输出 accepted 并且显示 source=Notarized Developer ID,就说明通过了 Gatekeeper 认证。
我之前 fork 了一份 Alacritty,把一些很有用但是上游维护者不肯合并的补丁都合了进来,像是上下左右可以分别设置的 padding、选区错位的修复、Big Sur 风格的图标,另外也加了一些自己的改动。
之前 CI 只是把编译物产出来,没有签名,更没有公证。发给朋友之后,他们的电脑上会弹出 Apple 的警告,大意是无法打开,因为 Apple 无法检查其是否包含恶意软件,每次都得手动放行,很不方便。
我刚好有付费的开发者账号,于是决定用它对构建出来的 app 签名并公证,直接在 CI 里做完。中途踩了不少坑,所以写篇文章记录一下,具体代码在 PR。
签名和公证
签名是用开发者证书给 app 盖一个章,证明它是谁发布的、发布之后没有被改过。公证是把签过名的 app 提交给 Apple,Apple 自动扫描一遍,没有问题就会记录下来。macOS 的 Gatekeeper 则在打开一个从网上下载的 app 时,检查它有没有签名,有没有公证。只有两样都有,才不会弹出刚才那种警告。
要做的事情就是在 CI 里依次完成构建、签名、公证,最后打成 DMG 上传。
在 CI 里导入证书
签名需要 Developer ID Application 证书。先在本机的钥匙串里把它连同私钥导出成 .p12,转成 base64,放进仓库的 Secrets 里,证书相关的放了三个,证书本身、.p12 的密码,以及一个临时钥匙串的密码。
在 CI 里每次都要新建一个临时钥匙串,需要先把证书导进去,然后用完再删掉。
这里面的 set-key-partition-list 这一行是绝对不能少的。没有它的话,codesign 去用私钥的时候 macOS 会弹窗问要不要允许,而 CI 里没有人能点,构建就会一直卡在那里。(血的教训😭)
整个 job 的最后还有一步清理,if: always(),不管前面成功还是失败,都把这个临时钥匙串删掉。
另外在构建之前,还加了一行 security find-identity -v -p codesigning,会把当前能用的签名身份在日志里打印出来,方便 debug。
编辑 Makefile
Alacritty 自带的 Makefile 在打包 .app 的时候,会用 codesign --sign - 做一次 ad-hoc 签名,也就是不使用任何证书的签名,这样的签名 Gatekeeper 是不认的。我把它换成了自己的证书。
后面带的三个参数都是用来公证的。--options runtime 打开 Hardened Runtime,--timestamp 附上 Apple 时间戳服务的时间戳,这两样是公证的硬性要求,缺了的话公证会直接被拒。--entitlements 指向一份权限声明文件。
另外 Info.plist 里的 CFBundleIdentifier 原来是上游的 org.alacritty,我换成了自己的 xxxbrian.alacritty。
Entitlements
打开 Hardened Runtime 之后,app 访问摄像头、麦克风、通讯录、日历、定位、照片这些受保护资源,都得在签名的时候事先声明对应的 entitlement,不声明就会被直接拒绝。
这是因为终端比较特殊,它虽然自己用不到这些,但是在终端里运行的程序会用到,比如调用摄像头的命令行工具,权限是算在终端头上的。如果不声明,这些程序在我的 Alacritty 里就跑不了。所以我得加一份 macos-entitlement.plist,把常见的几项都声明上。
公证和 staple
签完名的 universal .app 交给公证。我没有自己写 notarytool 的调用,直接用了 lando/notarize-action,提供 Apple ID、专用密码和 Team ID 就行。专用密码要去 appleid.apple.com 里单独生成,不能用 Apple ID 本身的密码。
公证通过之后,要把公证的结果贴回 app 里,这一步叫 staple,我用的是 BoundfoxStudios/action-xcode-staple。贴上之后,用户在没有网络的时候也能验证这个 app 是公证过的。
生成 DMG
最麻烦的是最后的 DMG。
Alacritty 的 Makefile 里,DMG 的目标是依赖 .app 的目标的,直接 make dmg-universal 会先把 .app 重新构建一遍,包括重新拷贝文件、重新签名。可是签名、公证和 staple 已经在前面做完了。
我一开始的想法是加一个不依赖 .app 的目标,叫 dmg-universal-exist,直接拿现成的 .app 打包。但是写出来之后,它的规则和原来那条几乎一模一样,依赖也没有去掉,起不到任何作用,最后只好删掉,改回了 make dmg-universal,这就是 PR 里最后那个 fix: corrected DMG creation process。
这里记录的是当时 PR 的最终状态,不是推荐的打包顺序。make dmg-universal 仍会重新执行 app 构建和签名,不能假定前一步贴好的公证票据仍然保留。更稳妥的流程是只打包已经签名、公证并 staple 的 app,不再重新构建;发布前应从最终 DMG 中取出实际分发的 app,执行文末的验证命令。
最后
最终的发布流程是这样的,推一个 tag,CI 就会在 macOS 上编译 x86 和 ARM 两个版本,合成 universal,用我的证书签名,提交公证,staple,再打成 DMG 传到 Release 里。
构建出来的东西可以在本机上做一下验证。
如果 spctl 输出 accepted 并且显示 source=Notarized Developer ID,就说明通过了 Gatekeeper 认证。