我之前 fork 了一份 Alacritty,合併了一些很實用、但上游維護者不願接受的 patch,像是上下左右可各自設定的 padding、選取範圍錯位的修正,以及 Big Sur 風格的圖示。另外也加了一些自己的改動。之前 CI 只負責產出建置結果,沒有簽署,更沒有公證。傳給朋友之後,他們的電腦會跳出 Apple 的警告,大意是無法開啟,因為 Apple 無法檢查它是否含有惡意軟體。每次都得手動放行,很不方便。我剛好有付費的開發者帳號,於是決定用它替建置好的應用程式簽署並公證,直接在 CI 裡完成。過程中踩了不少坑,所以寫篇文章記錄一下,程式碼在這個 PR。簽署與公證簽署就像用開發者憑證替應用程式蓋章,證明是誰發佈的,也讓系統能檢查簽署後是否遭到修改。公證則是將簽署好的應用程式提交給 Apple,自動掃描沒有問題後,Apple 就會記錄結果。開啟從網路下載的應用程式時,macOS 的 Gatekeeper 會檢查簽署與公證。有了這兩項,就能避免前面遇到的那種警告。要做的就是在 CI 裡依序完成建置、簽署、公證,最後打包成 DMG 上傳。在 CI 裡匯入憑證簽署需要 Developer ID Application 憑證。先在本機的鑰匙圈裡,將憑證連同私密金鑰匯出成 .p12,轉成 base64,再放進儲存庫的 Secrets。我用了三個與憑證相關的 secret:憑證本身、.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,把目前可用的簽署身分印到紀錄裡,方便除錯。編輯 MakefileAlacritty 隨附的 Makefile 在打包 .app 時,會用 codesign --sign - 做一次不使用憑證的 ad hoc 簽署。Gatekeeper 不會將它視為可信任的 Developer ID 簽署,所以我改成使用自己的憑證。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 後,應用程式若要存取相機、麥克風、聯絡人、行事曆、定位、照片等受保護資源,必須在簽署時宣告對應的 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、App 專用密碼與 Team ID 即可。App 專用密碼要在 appleid.apple.com 另外產生,不能使用 Apple ID 本身的密碼。公證通過後,還要將公證票證附加到應用程式上,這一步叫 staple。我用的是 BoundfoxStudios/action-xcode-staple。附加票證後,使用者即使沒有網路,也能驗證應用程式已通過公證。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 仍會重新建置並簽署應用程式,不能假設前一步附加的公證票證還在。更穩妥的流程是只打包已經簽署、公證並完成 staple 的應用程式,不再重新建置。發佈前,應從最終 DMG 中取出實際要分發的應用程式,再執行文末的驗證命令。最後最後的發佈流程是這樣:推送一個 tag,CI 就會在 macOS 上編譯 x86 與 ARM 兩個版本,合成 universal,用我的憑證簽署、提交公證、staple,再打包成 DMG 上傳到 GitHub 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,合併了一些很實用、但上游維護者不願接受的 patch,像是上下左右可各自設定的 padding、選取範圍錯位的修正,以及 Big Sur 風格的圖示。另外也加了一些自己的改動。
之前 CI 只負責產出建置結果,沒有簽署,更沒有公證。傳給朋友之後,他們的電腦會跳出 Apple 的警告,大意是無法開啟,因為 Apple 無法檢查它是否含有惡意軟體。每次都得手動放行,很不方便。
我剛好有付費的開發者帳號,於是決定用它替建置好的應用程式簽署並公證,直接在 CI 裡完成。過程中踩了不少坑,所以寫篇文章記錄一下,程式碼在這個 PR。
簽署與公證
簽署就像用開發者憑證替應用程式蓋章,證明是誰發佈的,也讓系統能檢查簽署後是否遭到修改。公證則是將簽署好的應用程式提交給 Apple,自動掃描沒有問題後,Apple 就會記錄結果。開啟從網路下載的應用程式時,macOS 的 Gatekeeper 會檢查簽署與公證。有了這兩項,就能避免前面遇到的那種警告。
要做的就是在 CI 裡依序完成建置、簽署、公證,最後打包成 DMG 上傳。
在 CI 裡匯入憑證
簽署需要 Developer ID Application 憑證。先在本機的鑰匙圈裡,將憑證連同私密金鑰匯出成 .p12,轉成 base64,再放進儲存庫的 Secrets。我用了三個與憑證相關的 secret:憑證本身、.p12 的密碼,以及暫用鑰匙圈的密碼。
每次 CI 執行時都建立一個暫用鑰匙圈,先匯入憑證,用完再刪除這個鑰匙圈。
其中的 set-key-partition-list 這一行絕對不能少。少了它,codesign 使用私密金鑰時,macOS 會跳出視窗詢問是否允許,但 CI 裡沒有人能按,建置就會一直卡住。(血淚教訓😭)
整個 job 的最後還有一個清理步驟,設定了 if: always(),不論前面成功或失敗,都會刪除暫用鑰匙圈。
另外在建置前,我也加了一行 security find-identity -v -p codesigning,把目前可用的簽署身分印到紀錄裡,方便除錯。
編輯 Makefile
Alacritty 隨附的 Makefile 在打包 .app 時,會用 codesign --sign - 做一次不使用憑證的 ad hoc 簽署。Gatekeeper 不會將它視為可信任的 Developer ID 簽署,所以我改成使用自己的憑證。
這裡有三個與公證相關的參數。--options runtime 開啟 Hardened Runtime,--timestamp 則附上 Apple 時間戳記服務的時間戳記。這兩項都是公證的必要條件,少了其中一項,公證就會被拒絕。--entitlements 指向權限宣告檔。
另外,Info.plist 裡的 CFBundleIdentifier 原本是上游的 org.alacritty,我改成了自己的 xxxbrian.alacritty。
Entitlements
開啟 Hardened Runtime 後,應用程式若要存取相機、麥克風、聯絡人、行事曆、定位、照片等受保護資源,必須在簽署時宣告對應的 entitlement,否則存取就會遭到拒絕。
終端機比較特殊,雖然本身用不到這些資源,但裡面執行的程式可能會用到,例如存取相機的命令列工具。這些權限會歸到終端機上;如果沒有宣告,工具就無法在我的 Alacritty 裡使用這些資源。所以我加了一份 macos-entitlement.plist,宣告常見的幾項權限。
公證與 staple
接著將簽署好的 universal .app 送去公證。我沒有自己寫 notarytool 的呼叫,而是直接使用 lando/notarize-action,提供 Apple ID、App 專用密碼與 Team ID 即可。App 專用密碼要在 appleid.apple.com 另外產生,不能使用 Apple ID 本身的密碼。
公證通過後,還要將公證票證附加到應用程式上,這一步叫 staple。我用的是 BoundfoxStudios/action-xcode-staple。附加票證後,使用者即使沒有網路,也能驗證應用程式已通過公證。
產生 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 仍會重新建置並簽署應用程式,不能假設前一步附加的公證票證還在。更穩妥的流程是只打包已經簽署、公證並完成 staple 的應用程式,不再重新建置。發佈前,應從最終 DMG 中取出實際要分發的應用程式,再執行文末的驗證命令。
最後
最後的發佈流程是這樣:推送一個 tag,CI 就會在 macOS 上編譯 x86 與 ARM 兩個版本,合成 universal,用我的憑證簽署、提交公證、staple,再打包成 DMG 上傳到 GitHub Release。
建置好的應用程式可以在本機驗證。
如果 spctl 輸出 accepted 並且顯示 source=Notarized Developer ID,就表示通過了 Gatekeeper 的檢查。