Notarizing macOS Apps in GitHub Actions
notarytool submits a signed archive to Apple, waits for a verdict, then staples the ticket. Here is the split-job workflow and what the wait costs.
Last verified:
Notarizing a macOS app in GitHub Actions means signing the bundle with a Developer ID Application certificate under the hardened runtime, submitting a zip of it to Apple's notary service with xcrun notarytool, waiting for the verdict, and stapling the returned ticket to the bundle. The signing and stapling commands finish in seconds, and the wait for the verdict is the part that shows up on the bill, because a job blocked on Apple's service still bills macOS runner minutes.
This guide covers the notarization sequence and its wait behavior, what a rejection looks like in the log, a workflow that splits the submission from the wait, and the arithmetic for both shapes at macOS runner rates. Labels and rates come from the WarpBuild macOS runner catalog.
Diagnosis
The sequence has seven stages. Only one of them is slow, and only one of them can be moved off an expensive machine.
| Stage | Command | Returns | Wait behavior |
|---|---|---|---|
| Sign | codesign --options runtime --timestamp --sign "Developer ID Application: ..." | Signed bundle | Seconds, plus one call to Apple's timestamp service |
| Package | ditto -c -k --keepParent App.app App.zip | Zip that preserves symlinks and permissions | Seconds |
| Submit | xcrun notarytool submit App.zip | Submission id, status In Progress | Returns when the upload finishes |
| Wait | xcrun notarytool wait <id> or submit --wait | Accepted or Invalid | Blocks on Apple's service with no default timeout |
| Log | xcrun notarytool log <id> log.json | JSON issues array | Seconds |
| Staple | xcrun stapler staple App.app | Ticket written into the bundle | Seconds |
| Verify | xcrun stapler validate, spctl -a -t exec -vvv | Gatekeeper verdict | Seconds |
The wait is a service response with no upper bound in the workflow. notarytool submit --wait holds the job open until Apple answers, and Apple sets that duration. Measure the interval in your own repository across a month of releases and use the median in the model further down. Pass --timeout on the wait so a stalled submission fails the job instead of running to the workflow limit.
A rejection arrives as a JSON document. xcrun notarytool log <id> writes the issues array, and each entry names a path, a severity, and a message:
{
"logFormatVersion": 1,
"jobId": "8d1e4a72-3f2b-4a0e-9d3c-71b0c9a5e412",
"status": "Invalid",
"statusSummary": "Archive contains critical validation errors",
"issues": [
{
"severity": "error",
"path": "App.zip/App.app/Contents/MacOS/App",
"message": "The executable does not have the hardened runtime enabled."
},
{
"severity": "error",
"path": "App.zip/App.app/Contents/Frameworks/Helper.framework",
"message": "The signature does not include a secure timestamp."
}
]
}Four messages account for most first-time rejections, and Apple lists them with their remedies in resolving common notarization issues: the hardened runtime is absent, the signature carries no secure timestamp, the signing certificate is not a Developer ID Application certificate, or the executable still requests com.apple.security.get-task-allow from a debug build. Every one of them is decided at signing time, so a rejection means the archive job was wrong and the minutes spent waiting were spent for nothing.
Accepted is not shippable on its own. The ticket lives on Apple's servers until it is stapled into the bundle, so a user machine with no path to that service blocks the app. Apple documents the stapling step in customizing the notarization workflow.
Workflows still calling altool fail outright. The notary service stopped accepting altool submissions on November 1, 2023, and notarytool is the supported client, as described in Apple technote TN3147.
Fix
Six rules turn a notarization job that works on a laptop into one that works on a runner and does not bill for idle time.
Sign for the notary rules. Local verification passes on signatures the notary service rejects. Pass --options runtime and --timestamp on every codesign call, sign nested frameworks and helpers before the outer bundle, and keep debug entitlements out of the Release build.
Package with ditto. A zip built by zip -r drops symlinks inside the bundle and the submission fails on structure rather than on signing.
Supply credentials as secrets. Store the App Store Connect API key .p8 base64 encoded, with its key id and issuer id, and decode it into $RUNNER_TEMP inside the job. Each WarpBuild runner runs in its own virtual machine that is created on demand and destroyed after the build, each runner has its own encrypted storage volume with the same lifetime, and WarpBuild does not access or store build secrets, which stay in your repository and reach only the runner environment. The isolation model is in the runner security documentation.
Split the submission from the wait. notarytool submit without --wait returns the submission id immediately, which lets the wide runner that compiled the app finish its job. A second job on a smaller label runs notarytool wait and staples.
Verify before publishing. xcrun stapler validate and spctl -a -t exec -vvv both run in seconds and both fail loudly, ahead of a release step that is expensive to undo.
Gate the whole flow on tags. Notarization on every push multiplies the wait by the push count and puts the Developer ID key on every machine that runs it.
Moving these jobs onto WarpBuild is a label change. WarpBuild runners register with GitHub as self-hosted runners carrying warp- labels, so editing runs-on is the migration. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, which is what makes the Linux polling variant below possible in the same workflow file. The warp-macos-26-arm64-6x and warp-macos-26-arm64-12x images ship Xcode 27.0 with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes while GitHub's upstream macOS 27 image is in beta, and a dedicated macOS 27 image follows once that image is released. When a signing step behaves differently on the runner than on a developer machine, the Action Debugger pauses the workflow and opens a session on the runner, which is the shortest route to inspecting a keychain or a signature in place.
Configuration
The archive job compiles and signs on a 12 vCPU label, submits, and stops. The second job waits and staples on a 6 vCPU label.
name: macos-release
on:
push:
tags: ["v*"]
jobs:
archive:
runs-on: warp-macos-26-arm64-12x
environment: release
outputs:
submission-id: ${{ steps.submit.outputs.submission-id }}
steps:
- uses: actions/checkout@v4
- name: Import the Developer ID certificate
env:
DEVELOPER_ID_P12: ${{ secrets.DEVELOPER_ID_P12 }}
DEVELOPER_ID_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
echo "$DEVELOPER_ID_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
-P "$DEVELOPER_ID_P12_PASSWORD" -T /usr/bin/codesign
rm -f "$RUNNER_TEMP/cert.p12"
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
- name: Build
run: |
xcodebuild -scheme App -configuration Release \
-derivedDataPath "$RUNNER_TEMP/DerivedData" \
CODE_SIGNING_ALLOWED=NO build
ditto "$RUNNER_TEMP/DerivedData/Build/Products/Release/App.app" \
"$RUNNER_TEMP/App.app"
- name: Sign with the hardened runtime and a secure timestamp
env:
IDENTITY: ${{ vars.DEVELOPER_ID_IDENTITY }}
run: |
FRAMEWORKS="$RUNNER_TEMP/App.app/Contents/Frameworks"
if [ -d "$FRAMEWORKS" ]; then
find "$FRAMEWORKS" -mindepth 1 -maxdepth 1 -exec \
codesign --force --options runtime --timestamp \
--keychain "$KEYCHAIN" --sign "$IDENTITY" {} \;
fi
codesign --force --options runtime --timestamp \
--entitlements App.entitlements \
--keychain "$KEYCHAIN" --sign "$IDENTITY" \
"$RUNNER_TEMP/App.app"
codesign --verify --deep --strict --verbose=2 "$RUNNER_TEMP/App.app"
- name: Package and submit
id: submit
env:
ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: |
ditto -c -k --keepParent "$RUNNER_TEMP/App.app" "$RUNNER_TEMP/App.zip"
echo "$ASC_KEY_P8" | base64 --decode > "$RUNNER_TEMP/asc.p8"
ID="$(xcrun notarytool submit "$RUNNER_TEMP/App.zip" \
--key "$RUNNER_TEMP/asc.p8" \
--key-id "$ASC_KEY_ID" \
--issuer "$ASC_ISSUER_ID" \
--output-format json \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
rm -f "$RUNNER_TEMP/asc.p8"
echo "submission-id=$ID" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: unstapled-app
path: ${{ runner.temp }}/App.zip
- name: Tear down the keychain
if: always()
run: security delete-keychain "$KEYCHAIN" || true
staple:
needs: archive
runs-on: warp-macos-15-arm64-6x
environment: release
steps:
- uses: actions/download-artifact@v4
with:
name: unstapled-app
path: ${{ runner.temp }}/in
- name: Wait for the verdict
env:
ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
SUBMISSION_ID: ${{ needs.archive.outputs.submission-id }}
run: |
echo "$ASC_KEY_P8" | base64 --decode > "$RUNNER_TEMP/asc.p8"
CREDS=(--key "$RUNNER_TEMP/asc.p8" --key-id "$ASC_KEY_ID"
--issuer "$ASC_ISSUER_ID")
if ! xcrun notarytool wait "$SUBMISSION_ID" "${CREDS[@]}" --timeout 45m; then
xcrun notarytool log "$SUBMISSION_ID" "${CREDS[@]}" "$RUNNER_TEMP/log.json"
cat "$RUNNER_TEMP/log.json"
rm -f "$RUNNER_TEMP/asc.p8"
exit 1
fi
rm -f "$RUNNER_TEMP/asc.p8"
- name: Staple, verify, repackage
run: |
ditto -x -k "$RUNNER_TEMP/in/App.zip" "$RUNNER_TEMP/out"
xcrun stapler staple "$RUNNER_TEMP/out/App.app"
xcrun stapler validate "$RUNNER_TEMP/out/App.app"
spctl -a -t exec -vvv "$RUNNER_TEMP/out/App.app"
ditto -c -k --keepParent "$RUNNER_TEMP/out/App.app" \
"$RUNNER_TEMP/App-stapled.zip"
- uses: actions/upload-artifact@v4
with:
name: stapled-app
path: ${{ runner.temp }}/App-stapled.zipThree details carry the pattern. The submission-id job output is what lets the second job find the submission, so it has to be written to $GITHUB_OUTPUT in the submit step. The artifact is the ditto zip rather than the bundle directory, because an artifact upload of a directory drops the symlinks that make the bundle valid. Both jobs declare environment: release, and dropping that line resolves the environment secrets to empty strings, which surfaces later as an authentication error from notarytool.
notarytool wait is macOS-only, and the underlying service is a REST API, so the wait can also run on a Linux runner that polls until the status leaves In Progress, with stapling still done on macOS. The endpoint and its bearer token format are documented in the Notary API reference:
poll:
needs: archive
runs-on: warp-ubuntu-latest-x64-2x
steps:
- name: Poll until the submission leaves In Progress
env:
ASC_TOKEN: ${{ secrets.ASC_NOTARY_JWT }}
SUBMISSION_ID: ${{ needs.archive.outputs.submission-id }}
run: |
for _ in $(seq 1 90); do
STATUS="$(curl -sS -H "Authorization: Bearer $ASC_TOKEN" \
"https://appstoreconnect.apple.com/notary/v2/submissions/$SUBMISSION_ID" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["attributes"]["status"])')"
[ "$STATUS" = "In Progress" ] || break
sleep 30
done
test "$STATUS" = "Accepted"Cost or Time Model
macOS labels and rates, from the WarpBuild cloud runners documentation and the pricing page, checked on 2026-08-13:
| Label | macOS | vCPU | Memory | Storage | Rate per minute | Alias |
|---|---|---|---|---|---|---|
warp-macos-26-arm64-6x | macOS 26 | 6 | 22GB | 120GB SSD | $0.08 | |
warp-macos-26-arm64-12x | macOS 26 | 12 | 44GB | 270GB SSD | $0.16 | |
warp-macos-15-arm64-6x | macOS 15 | 6 | 22GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x |
warp-macos-15-arm64-12x | macOS 15 | 12 | 44GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x |
warp-macos-14-arm64-6x | macOS 14 | 6 | 22GB | 120GB SSD | $0.08 |
For the rate the wait runs at: warp-macos-latest-arm64-6x (6 vCPU, 22 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB), which is 22 percent lower list price with one more vCPU and 8GB more memory on the WarpBuild side. GitHub list price read from the GitHub Actions per-minute rates, checked on 2026-08-13.
Assumptions for the model, all stated so you can substitute your own: one desktop application repository, 8 tagged releases per month, 12 minutes of compile and sign work, a 9 minute median wait for the verdict measured over the previous month, 2 minutes to staple and verify, and 30 seconds each side for the artifact upload and download that the split introduces.
| Shape | Machines and minutes | Cost per release | Monthly at 8 releases |
|---|---|---|---|
One job with submit --wait | 23.0 min on warp-macos-26-arm64-12x | $3.68 | $29.44 |
| Split across two macOS labels | 12.5 min at $0.16, 11.5 min at $0.08 | $2.92 | $23.36 |
| Split with the wait on Linux | 12.5 min at $0.16, 9.2 min at $0.004, 2.5 min at $0.08 | $2.24 | $17.92 |
The middle row costs $6.08 per month less than the first, and the third row $11.52 less, which is $72.96 and $138.24 over twelve months at the same release volume. The transfer overhead is the only thing the split adds, so it pays back once the wait passes about a minute, and it pays back harder every time Apple is slow: each extra minute of waiting costs $0.16 in the first shape, $0.08 in the second, and $0.004 in the third.
Then price the rejected submission, since that is the failure the diagnosis table exists to prevent. A rejection discovered after the wait costs the wasted wait plus a full re-run of the archive job: on the single-job shape that is 9 wait minutes and 12.5 archive minutes, both at $0.16, so $3.44 before the second submission starts waiting. The codesign --verify --deep --strict step and the four rules in the Fix section run before the submission and cost seconds.
For the full label list and image details, see WarpBuild macOS runners for GitHub Actions. For the job that takes the signed build the rest of the way, see TestFlight uploads from GitHub Actions, for the desktop packaging case see Electron builds on GitHub Actions, and for what a signature proves once it is attached, see code signing.
FAQ
Why does notarytool return Invalid when codesign already verified the app?
codesign verifies that a signature is well formed. The notary service applies additional rules, and the four that reject most first submissions are a missing hardened runtime, a missing secure timestamp, a certificate that is not a Developer ID Application certificate, and a leftover com.apple.security.get-task-allow entitlement from a debug build. Run xcrun notarytool log with the submission id to get the JSON issues array, which names the offending path and message.
Do I have to keep the macOS runner busy while Apple processes the submission?
No. xcrun notarytool submit returns a submission id as soon as the upload finishes, so the wide runner that compiled the app can end its job there. A second job calls xcrun notarytool wait on a 6 vCPU macOS label at $0.08 per minute, or polls the Notary API over HTTPS from a Linux runner at $0.004 per minute and staples on macOS afterwards.
The submission was Accepted, so why does Gatekeeper still block the app on a user machine?
An accepted submission puts a ticket on Apple's servers, and a machine with no network path to that service has no way to see it. Run xcrun stapler staple on the app or disk image inside the workflow, then assert with xcrun stapler validate and spctl before the artifact is published.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.