A very rough guide to notarizing CLI apps for macOS

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 certificate

BUILD_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/upload-rust-binary-action@v1.22.0
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.