I just added code signing and notarization for PaperAge macOS binaries using GitHub Actions, so here’s a very rough guide to how I did it.
Background
I use GitHub actions for the PaperAge release process, as documented in a previous blog post.
The tl;dr process looks something like this:
- A new release is tagged using cargo-release
- A draft release is created on GitHub
- PaperAge is built for all three supported platforms using the upload-rust-binary-action workflow
- Linux (ARM and x84-64)
- macOS (universal binary)
- Windows (x84-64)
- The GitHub release is published
- The Homebrew formula update is triggered using a repository dispatch event
The upload-rust-binary-action workflow already had support for code signing, and I’d been using it to sign the PaperAge macOS binaries with an ad-hoc identity. During the course of adding signing with a “real” developer identity, I expanded the code signing to support everything that’s needed to also notarize binaries.
Setting up the keychain and identities on GitHub Actions
GitHub already has documentation for installing an Apple certificate on macOS. I used that as the basis for setting up everything that’s needed to sign and notarize CLI apps.
The two tweaks I made were:
- Adding outputs for the various paths that are used in subsequent steps
- Extracting an API key from repository secrets to a temporary file on the runner
name: Release
on:
push:
tags:
- v[0-9]+.*
jobs:
upload-assets:
# ...
# Other set-up omitted for brevity, see the full file at:
# https://github.com/matiaskorhonen/paper-age/blob/58959c29/.github/workflows/release.yml
steps:
- name: Install the Apple certificate, provisioning profile, and API key (macOS)
if: ${{ matrix.target == 'universal-apple-darwin' }}
id: keychain
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
AUTH_KEY_BASE64: ${{ secrets.AUTH_KEY_BASE64 }}
run: |
# create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.provisionprofile
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
AUTH_KEY_PATH=$RUNNER_TEMP/AuthKey.p8
# import certificate and provisioning profile from secrets
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
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
# apply provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
# create auth key file for notarization
echo -n "$AUTH_KEY_BASE64" | base64 --decode -o $AUTH_KEY_PATH
# setup outputs
echo "auth_key_path=$AUTH_KEY_PATH" >> $GITHUB_OUTPUT
echo "keychain_path=$KEYCHAIN_PATH" >> $GITHUB_OUTPUT
echo "pp_path=$PP_PATH" >> $GITHUB_OUTPUT
echo "certificate_path=$CERTIFICATE_PATH" >> $GITHUB_OUTPUT
# subsequent steps abbreviated
The repository secrets used are:
BUILD_CERTIFICATE_BASE64
: A ‘Developer ID Application’ certificate.- Use Apple’s step-by-step wizard to generate one in their developer portal.
- The exported P12 file should be Base64 encoded:
base64 -i BUILD_CERTIFICATE.p12 | pbcopy
P12_PASSWORD
: The password for the certificateBUILD_PROVISION_PROFILE_BASE64
: A Developer ID provisioning profile- Create one in your Apple Developer account
- Like the certificate, it should be Base64 encoded:
base64 -i PROFILE.provisionprofile | pbcopy
AUTH_KEY_BASE64
: A Developer API key for App Store Connect that’s used to notarize the binary- Generate a Team Key under Users and Access → Integrations on App Store Connect
- Base64 encode this too:
base64 -i AUTH_KEY.p8 | pbcopy
KEYCHAIN_PASSWORD
: A keychain password- This is used for the temporary keychain on the runner and can be any random string
Signing the binary
In my case, I use the upload-rust-binary-action action to build and sign the resulting binary (slightly abbreviated again):
- id: upload-rust-binary-action
uses: taiki-e/[email protected]
with:
bin: paper-age
# (optional) Target triple, default is host triple.
target: ${{ matrix.target }}
# (required) GitHub token for uploading assets to GitHub Releases.
token: ${{ secrets.GITHUB_TOKEN }}
# Sign build products using codesign on macOS
codesign: "7FP48PW9TN"
codesign-prefix: "fi.matiaskorhonen."
codesign-options: "runtime"
If you build your binary directly, you’ll need to sign it yourself. For example:
- name: Build the binary
run: cargo build --release
- name: Sign the binary
env:
CODESIGN_IDENTITY: "7FP48PW9TN"
CODESIGN_PREFIX: "fi.matias.korhonen."
run: |
codesign --sign "$CODESIGN_IDENTITY" \
--prefix "$CODESIGN_PREFIX" \
--options runtime target/release/paper-age
The identity should match a valid code signing identity. You can get a list of valid identities using the security
command: security find-identity -v -p codesigning
In both cases, the runtime
option is required for notarization. It sets the hardened runtime capability on the binary.
The prefix is used to generate the identifier with the file name for the signed binary and should be a reverse domain, including the trailing dot (e.g. com.example.
).
Notarizing the signed binary
Notarization is a process where Apple verifies your binary to make sure it has a valid code signature and doesn’t contain malicious content. The Apple notarization service can accept .dmg
, .zip
, and .pkg
files.
In my case, I distribute macOS releases as .tar.gz
files due to some limitations of the rest of the build process, but luckily that doesn’t ultimately matter for notarization.
We can zip up a copy of the binary and upload that to Apple:
- name: Zip the binary for notarization (macOS)
if: ${{ matrix.target == 'universal-apple-darwin' }}
run: zip -r $RUNNER_TEMP/paper-age-signed.zip target/${{ matrix.target }}/release/paper-age
- name: Upload the binary for notarization (macOS)
if: ${{ matrix.target == 'universal-apple-darwin' }}
env:
KEY_ID: ${{ secrets.KEY_ID }}
ISSUER: ${{ secrets.ISSUER }}
run: |
xcrun notarytool submit $RUNNER_TEMP/paper-age-signed.zip \
--key "${{ steps.keychain.outputs.auth_key_path }}" \
--key-id "$KEY_ID" \
--issuer "$ISSUER" \
--wait
The zip file only needs to have any binaries that are executed, and doesn’t need to be persisted. It’s enough that Apple now has a record of the notarized binary.
This step will wait for the notarization to be complete before moving on…
Using a DMG for distribution would be nicer as it would mean that macOS wouldn’t need to be online to check the notarization status of the binary when it’s first run, but for now I’m willing to live with that requirement.
Stapling
If you use a DMG file to distribute your binaries, you can staple the notarization to the disk image using the xcrun stapler
command, but I’ll leave that as an exercise for the reader.
The create-dmg shell script looks useful for creating signed and notarized DMG files, but I haven’t incorporated it into my release process yet.