Sign and notarize macOS and iOS builds in GitHub Actions
Import a .p12 into a temporary keychain, install profiles, notarize with notarytool or use fastlane match, without leaving signing keys on the runner.
Guzman Pintos
9 min read
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 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:
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_TOKENaren't passed to workflows triggered from forks, so an outside pull request can't reach your signing key. - Put signing secrets in an environment 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 says a transformed secret, such as a Base64-decoded one, isn't redacted unless you register it too. Never
catorechothe decoded files.
Import it into a temporary keychain
This is the step from the Tenki signing docs. Only runs-on is specific to Tenki; on GitHub-hosted runners use macos-latest or another macOS label.
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 3600locks the keychain after an hour of inactivity. Set the timeout longer than your slowest build; GitHub's example uses 21600 seconds.list-keychainsadds the new keychain to the search list, socodesignand Xcode can find the identity. The subshell keeps the existing keychains in the list.-T /usr/bin/codesignlets onlycodesignuse the imported key. GitHub's example uses-Ainstead, which thesecurityman 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-listis the step people most often miss. The man page saysapple:must be in a key's partition list for/usr/bin/codesignto use it. Without it,codesignwaits 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 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, 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, zip the exported app, submit it and wait, then staple the ticket:
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 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:
- 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.mobileprovisionMatch 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 -sandlist-keychains -schange 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/codesigninstead 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.
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 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, 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:
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 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 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 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 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.
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.


