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:

  1. A new release is tagged using cargo-release
  2. A draft release is created on GitHub
  3. 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)
  4. The GitHub release is published
  5. 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
  • 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.