# Sign and notarize macOS and iOS builds in GitHub Actions (https://tenki.cloud/blog/sign-macos-ios-builds-github-actions)

> For the complete documentation index, see [llms.txt](https://tenki.cloud/llms.txt)

- Author: Guzman Pintos (Product)
- Published: Sep 22, 2026
- Category: [CI/CD](https://tenki.cloud/blog/category/ci-cd)
- Reading time: 9 min

Import a .p12 into a temporary keychain, install profiles, notarize with notarytool or use fastlane match, without leaving signing keys on the runner.

On your laptop, code signing mostly happens without you. Xcode finds your certificate in the login keychain, picks a provisioning profile, and `codesign` runs without asking for anything. A CI runner has none of that. Every job starts without a keychain, a certificate or a profile, so the workflow has to rebuild your signing setup from secrets, use it, and make sure nothing sensitive outlives the job.

The private key is what matters. Anyone who has your distribution key and its password can sign software that looks like yours.

## What a signing job needs

| Piece                       | What it is                                                           | How it reaches the runner                                            |
| --------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Certificate and private key | A `.p12` exported from Keychain Access, protected by a password      | Base64 in a GitHub Actions secret, plus the password as a second one |
| Keychain                    | A keychain created for the job, with a password you make up          | Created by `security` at the start of the job                        |
| Provisioning profile        | A `.mobileprovision` (iOS) or `.provisionprofile` (macOS) file       | Base64 in a secret, copied into the profiles folder                  |
| Notarization credentials    | An App Store Connect API key: a `.p8` file, its key ID and issuer ID | Secrets, written to a temp file before `notarytool` runs             |

Notarization only applies to macOS software you distribute outside the Mac App Store with a Developer ID certificate. [Apple's notarization guide](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution) says the App Store submission process already includes equivalent checks, so Mac App Store builds skip it, and iOS apps go through App Store Connect instead.

## Put the certificate in a secret

Encode the `.p12` so it fits in a secret. This is the command from [GitHub's signing guide](https://docs.github.com/en/actions/how-tos/deploy/deploy-to-third-party-platforms/sign-xcode-applications):

```bash
base64 -i BUILD_CERTIFICATE.p12 | pbcopy
```

Do the same for the provisioning profile. The keychain password can be any random string; it only protects a keychain that exists for one job.

Where you store the secret matters as much as how you encode it, and three GitHub behaviors affect that choice:

* Secrets other than `GITHUB_TOKEN` [aren't passed to workflows triggered from forks](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets), so an outside pull request can't reach your signing key.
* Put signing secrets in an [environment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) rather than at the repository level. Jobs can only read environment secrets after its rules pass, so you can require a reviewer and limit deploys to your release branch or tags.
* Log redaction matches exact values. GitHub's [secure use reference](https://docs.github.com/en/actions/reference/security/secure-use) says a transformed secret, such as a Base64-decoded one, isn't redacted unless you register it too. Never `cat` or `echo` the decoded files.

## Import it into a temporary keychain

This is the step from the [Tenki signing docs](https://tenki.cloud/docs/runners/secrets-and-signing.md). Only `runs-on` is specific to Tenki; on GitHub-hosted runners use `macos-latest` or another macOS label.

```yaml
jobs:
  build:
    runs-on: tenki-macos-26-medium
    environment: release
    steps:
      - uses: actions/checkout@v4

      - name: Import signing certificate
        env:
          CERTIFICATE_P12: ${{ secrets.CERTIFICATE_P12 }}
          CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          echo "$CERTIFICATE_P12" | base64 -D -o "$RUNNER_TEMP/cert.p12"

          security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
          security set-keychain-settings -lut 3600 build.keychain
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
          security list-keychains -d user -s build.keychain $(security list-keychains -d user | tr -d '"')
          security default-keychain -s build.keychain

          security import "$RUNNER_TEMP/cert.p12" -k build.keychain \
            -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
          security set-key-partition-list -S apple-tool:,apple:,codesign: \
            -s -k "$KEYCHAIN_PASSWORD" build.keychain

      - name: Build
        run: xcodebuild -scheme MyApp -configuration Release archive
```

What each part is for:

* `set-keychain-settings -lut 3600` locks the keychain after an hour of inactivity. Set the timeout longer than your slowest build; GitHub's example uses 21600 seconds.
* `list-keychains` adds the new keychain to the search list, so `codesign` and Xcode can find the identity. The subshell keeps the existing keychains in the list.
* `-T /usr/bin/codesign` lets only `codesign` use the imported key. GitHub's example uses `-A` instead, which the `security` man page describes as allowing any application to access the key and labels "insecure, not recommended". It matters most on a Mac you keep.
* `set-key-partition-list` is the step people most often miss. The man page says `apple:` must be in a key's partition list for `/usr/bin/codesign` to use it. Without it, `codesign` waits for a keychain access prompt nobody can click, and the job hangs until it times out.

Provisioning profiles are ordinary files. Decode them in the same step and copy them into `~/Library/MobileDevice/Provisioning Profiles/`, as both GitHub's and Tenki's examples do. Xcode 16 moved the folder it manages profiles in to `~/Library/Developer/Xcode/UserData/Provisioning Profiles`, and [fastlane changed match](https://github.com/fastlane/fastlane/discussions/29228) to write to whichever folder your Xcode version uses. If `xcodebuild` reports that it can't find a profile you installed, try copying it to the newer folder.

## Notarize a macOS app

Notarization needs network access to Apple and credentials that can upload. Use an App Store Connect API key instead of an Apple ID and app-specific password: a team key isn't tied to one person's Apple ID, and you can revoke it in App Store Connect without touching anyone's account. Apple [makes the private key downloadable only once](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api), so store it as a secret as soon as you create it.

The notary service doesn't accept a bare `.app`; it takes ZIP archives, disk images and signed flat installer packages. Following [Apple's notarization workflow](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow), zip the exported app, submit it and wait, then staple the ticket:

```bash
echo "$NOTARY_KEY_P8" | base64 -D -o "$RUNNER_TEMP/AuthKey.p8"

/usr/bin/ditto -c -k --keepParent "MyApp.app" "MyApp.zip"

xcrun notarytool submit MyApp.zip \
  --key "$RUNNER_TEMP/AuthKey.p8" \
  --key-id "$NOTARY_KEY_ID" \
  --issuer "$NOTARY_ISSUER_ID" \
  --wait

xcrun stapler staple "MyApp.app"
```

`--wait` makes `notarytool` exit only when Apple has finished processing, so you don't need a polling loop. Apple says most submissions finish within 5 minutes and 98% within 15, so the wait usually fits inside a job. If the status isn't `Accepted`, fetch the details with `xcrun notarytool log` and the submission ID. Apple recommends reading the log even on success, since it can contain warnings.

You can't staple a ticket to a ZIP. Staple the `.app`, then zip it again for distribution, or ship a disk image or installer package and staple that.

## Why an ephemeral runner changes cleanup

Everything above leaves sensitive files behind: a keychain holding your private key, the decoded `.p12` and `.p8` in `$RUNNER_TEMP`, and installed profiles. Where that leftover goes depends on the runner.

GitHub's guide describes its hosted runners as isolated VMs that are destroyed at the end of the job, taking the certificates and profiles with them, and only asks for a cleanup step on self-hosted runners. Tenki macOS jobs work the same way. Each job gets a fresh VM that is destroyed when the job ends, whether it passed or failed, and the [security docs](https://tenki.cloud/docs/trust/security.md) state there is no shared filesystem, keychain or user account between jobs. The keychain and key go with the VM, which is why the Tenki docs treat a `security delete-keychain` step as optional.

A fresh VM doesn't protect you while the job is running. Any step that runs after the import, including a compromised build script or dependency, runs in the same user session as an unlocked keychain. So import the certificate right before the steps that need it, keep signing out of workflows that run on pull requests, and don't upload `$RUNNER_TEMP` or anything else that contains the decoded files as an artifact.

### On a Mac you keep

On a self-hosted Mac, or any machine that runs more than one job, the runner empties `$RUNNER_TEMP` at the end of each job, but GitHub warns that the keychain and provisioning profile might still be there. The Tenki example creates `build.keychain` in your user's keychain folder rather than in `$RUNNER_TEMP`, so on a machine you keep it stays until you delete it. Three things help there.

* Add a cleanup step that always runs. This is GitHub's version:

  ```yaml
  - name: Clean up keychain and provisioning profile
    if: ${{ always() }}
    run: |
      security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
      rm ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision
  ```

  Match the paths to where your import step created the files. It won't run if the machine loses power or the runner process is killed mid-job, so check for leftover keychains now and then.

* Be careful with global state. `default-keychain -s` and `list-keychains -s` change settings for the user account, so they outlast the job, and two jobs running as the same user at the same time will overwrite each other's settings.

* Import with `-T /usr/bin/codesign` instead of `-A`, so a key left behind isn't usable by every other process on the machine.

If you can, make self-hosted Macs ephemeral too. The same reasoning is covered for Linux in [self-hosted runner security](https://tenki.cloud/blog/self-hosted-runners-arc-security-guide).

## fastlane match instead

The keychain script works well for one app and one certificate. With a team, several bundle IDs, or profiles that expire and need renewing, [fastlane match](https://docs.fastlane.tools/actions/match/) is usually less work. Match keeps certificates and profiles in storage you control (a Git repository, Google Cloud Storage, Amazon S3 or GitLab Secure Files). In a Git repository they are encrypted with OpenSSL using a passphrase, which you supply as `MATCH_PASSWORD`.

On CI, you pair it with [`setup_ci`](https://docs.fastlane.tools/actions/setup_ci/), which creates a temporary keychain (`fastlane_tmp_keychain` by default, with a 3600-second timeout) and switches match to read-only mode so CI never creates new certificates:

```ruby
lane :beta do
  setup_ci
  match(type: "appstore", readonly: is_ci)
  gym(scheme: "Release")
end
```

In GitHub Actions the secrets become `MATCH_PASSWORD` and `MATCH_GIT_BASIC_AUTHORIZATION` (the output of `echo -n username:token | base64`, for a token that can read the certificates repository). For App Store Connect uploads, [`app_store_connect_api_key`](https://docs.fastlane.tools/actions/app_store_connect_api_key/) takes the same key ID, issuer ID and `.p8` content that `notarytool` uses.

The trade-off is that match moves the trust from a GitHub secret holding the `.p12` to a storage repository plus a passphrase. Anyone with both can read your signing identity, so treat that repository with the same care as the secret it replaces.

## Running this on Tenki

The workflows above run unchanged on Tenki macOS runners. The [signing docs](https://tenki.cloud/docs/runners/secrets-and-signing.md) confirm that `fastlane match` needs nothing beyond the credentials you already keep in secrets, and that `xcrun notarytool` ships with Xcode on the standard images.

[macOS runners](https://tenki.cloud/docs/runners/macos-runners.md) run on Apple Silicon M4 Pro in four sizes, from `tenki-macos-26-mini` (2 vCPU, 4 GB) to `tenki-macos-26-large` (8 vCPU, 32 GB); [Runner Sizes & Labels](https://tenki.cloud/docs/runners/sizes.md) has the full table. They run macOS 26 with Xcode 26 by default. They're available on all plans at $0.020 per core-minute, so the 2-vCPU mini costs $0.04 a minute; see [Pricing](https://tenki.cloud/docs/pricing.md).

Tenki doesn't run its own secrets store. Your certificate, profiles and API key stay in GitHub Actions secrets, or in a manager like Vault that you pull from during the job. That keeps your signing key in systems you already audit, and moving onto or off Tenki doesn't mean re-keying anything.

For the full reference, including external secret managers and the complete keychain step, read [Secrets & Signing](https://tenki.cloud/docs/runners/secrets-and-signing.md).