Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Notarize a macOS App Inside a CI Pipeline

notarytool submit does not accept a .app bundle directly. Apple's own guide is explicit: "you can't upload the .app bundle directly to the notary service" — the notary service accepts UDIF disk images, signed flat installer packages, and ZIP archives. This article keeps the pipeline coherent by packaging the signed app into a signed DMG, submitting that DMG, polling status on a bounded deadline, stapling the ticket to the DMG, and asserting Gatekeeper acceptance with spctl. The commands and accepted formats are documented in Customizing the notarization workflow and the prerequisites in Notarizing macOS software before distribution.

Figment is a direct option for the host. On a Figment M-series Mac from $95/month (exact chip, RAM and disk come back on your quote), xcodebuild archive, codesign, DMG creation and notarization can all run on one machine. The virtual Intel macOS VM at $29/month (2 vCPU, 8 GiB RAM, 80 GiB disk) runs xcrun notarytool and xcrun stapler from its desktop terminal under a compatible Intel toolchain; Apple's porting guide says to use Xcode 26 or earlier on Intel. The i3 Mac at $49/month and physical M-series plans collect requests until supply is confirmed. Managed Build Mac at $249/month bundles one M-series Mac with CI setup, backups, monitoring and 30 minutes of monthly support; Managed Mac Team at $999/month covers two M-series Macs, two CI runners and one hour of monthly support. Access to the $29 VM is through the browser console; inbound public SSH to that VM is not currently offered. Tax, fees and exact hardware are set on the quote.

Prerequisites: Developer ID Application, an API key, and an unlocked keychain

Three independent pieces have to line up before Apple will accept a submission.

Developer ID Application certificate. For an app or DMG you sign with a Developer ID Application identity. Developer ID Installer is a different certificate type used only for .pkg installer payloads; it is not interchangeable with Developer ID Application, and signing a .app or .dmg with the installer certificate will fail notarization. The private key has to be present in a keychain that is in the current user's keychain search list and unlocked when codesign runs. The login keychain is the common place for this, but any custom keychain in the search path — for example a job-scoped keychain created with security create-keychain and added via security list-keychains -s — is valid as long as it stays unlocked for the signing step. Import the identity from an encrypted .p12 using a password kept in the CI secret manager, never in shell history, and never by exporting the whole environment to a plaintext file (it routinely contains unrelated secrets). Provisioning profiles, where a target uses them, are files under ~/Library/MobileDevice/Provisioning Profiles/, not keychain exports.

App Store Connect API key. For unattended CI, authenticate notarytool with an App Store Connect API key. Create the key in App Store Connect under Users and Access → Integrations → App Store Connect API, download the .p8 once (it cannot be re-downloaded), and note the Key ID plus your team's Issuer ID. Store the .p8 in your CI secret manager and materialise it to a path at job start. On a long-lived Figment Mac the keychain-profile shortcut shown in Apple's workflow guide may be more convenient: xcrun notarytool store-credentials "notarytool-profile" --key /path/to/AuthKey.p8 --key-id ABC123 --issuer 12345-... writes the credential into a keychain, and later commands pass --keychain-profile "notarytool-profile". That profile is scoped to a single keychain on a single machine, so ephemeral runners should pass --key / --key-id / --issuer directly from secrets.

An unlocked keychain. codesign and --keychain-profile lookups both require the backing keychain to be unlocked. security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" returns a non-zero exit code if the password is wrong or the file is missing — check it, do not swallow the output, and do not assume every keychain error is silent. If the keychain can relock on idle, set security set-keychain-settings -lut <seconds> to a value longer than the job.

Sign the app inside-out, then build and sign the DMG separately

Apple's notary pipeline is strict about signatures. Sign the app from the inside out with a Developer ID Application identity, hardened runtime (--options runtime), and a secure timestamp (--timestamp). Avoid --deep; sign every embedded framework, helper tool, XPC service and finally the outer bundle explicitly so each nested binary gets its own fresh signature. Then create the DMG around the signed app in a separate step and sign the DMG itself with the same Developer ID Application identity. This is what makes the single artifact that notarytool submit can upload, stapler staple can attach a ticket to, and spctl --assess --type open can later verify.

# Unexecuted example. Run against your own artifact before trusting it in CI.
# 1) Sign each embedded framework/helper first, then the outer bundle.
codesign --sign "Developer ID Application: Example Corp (ABCD123456)" \
  --options runtime --timestamp --force \
  MyApp.app/Contents/Frameworks/SomeHelper.framework
codesign --sign "Developer ID Application: Example Corp (ABCD123456)" \
  --options runtime --timestamp --force \
  MyApp.app

# 2) Build the DMG around the signed app, then sign the DMG.
hdiutil create -volname "MyApp" -srcfolder MyApp.app \
  -ov -format UDZO MyApp.dmg
codesign --sign "Developer ID Application: Example Corp (ABCD123456)" \
  --timestamp --force MyApp.dmg

ZIP submissions are also legal, but stapler cannot staple a ZIP directly, so a signed DMG keeps upload, staple and the spctl open check on one artifact.

notarize-and-staple.sh: validate input, bounded poll, strict status check

The script below accepts exactly one argument — the path to a signed .dmg — and refuses anything else before spending an upload. TIMEOUT_SECONDS and POLL_SECONDS are validated as positive integers. The poll loop calls xcrun notarytool info with --output-format json, reads the parsed status field, and treats anything other than Accepted as a failure, including a timeout. The log is always fetched (Apple's docs note it can carry warnings even on success), and spctl --assess --type open --context context:primary-signature is the Gatekeeper check that matches a DMG. This example is unexecuted here; run it against one of your own signed DMGs before trusting it in CI.

#!/usr/bin/env bash
# notarize-and-staple.sh — Notarize and staple a signed .dmg.
#
# Usage: notarize-and-staple.sh /path/to/Signed.dmg
#
# Required env (set by your CI secret manager, never written to a dumped env file):
#   ASC_API_KEY_PATH    Filesystem path to the App Store Connect .p8 key
#   ASC_API_KEY_ID      App Store Connect Key ID
#   ASC_API_ISSUER_ID   App Store Connect Issuer ID
#
# Optional (defaults in parentheses):
#   TIMEOUT_SECONDS (1800)  Deadline for Apple-side processing
#   POLL_SECONDS    (30)    Seconds between status polls
#
# Unexecuted example.

set -euo pipefail

DMG_PATH="${1:-}"

is_positive_int() { [[ "$1" =~ ^[1-9][0-9]*$ ]]; }

if [[ -z "$DMG_PATH" ]]; then
  echo "Usage: $0 /path/to/Signed.dmg" >&2; exit 2
fi
if [[ "$DMG_PATH" != *.dmg ]]; then
  echo "Refusing: this script submits a signed .dmg (got '$DMG_PATH')." >&2; exit 2
fi
if [[ ! -f "$DMG_PATH" ]]; then
  echo "Refusing: '$DMG_PATH' does not exist or is not a file." >&2; exit 2
fi

: "${ASC_API_KEY_PATH:?Set ASC_API_KEY_PATH}"
: "${ASC_API_KEY_ID:?Set ASC_API_KEY_ID}"
: "${ASC_API_ISSUER_ID:?Set ASC_API_ISSUER_ID}"

TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-1800}"
POLL_SECONDS="${POLL_SECONDS:-30}"
is_positive_int "$TIMEOUT_SECONDS" || { echo "TIMEOUT_SECONDS must be a positive integer." >&2; exit 2; }
is_positive_int "$POLL_SECONDS"    || { echo "POLL_SECONDS must be a positive integer." >&2; exit 2; }

AUTH=(--key "$ASC_API_KEY_PATH" --key-id "$ASC_API_KEY_ID" --issuer "$ASC_API_ISSUER_ID")

parse_json() { /usr/bin/python3 -c "import sys,json;print(json.load(sys.stdin).get('$1',''))"; }

echo "==> Submitting $DMG_PATH"
SUBMIT_JSON=$(xcrun notarytool submit "$DMG_PATH" "${AUTH[@]}" --output-format json)
SUBMISSION_ID=$(printf '%s' "$SUBMIT_JSON" | parse_json id)
if [[ -z "$SUBMISSION_ID" ]]; then
  echo "Submit did not return an id. Raw output:" >&2
  printf '%s\n' "$SUBMIT_JSON" >&2
  exit 1
fi
echo "    Submission ID: $SUBMISSION_ID"

DEADLINE=$(( $(date +%s) + TIMEOUT_SECONDS ))
STATUS="In Progress"
while [[ "$STATUS" == "In Progress" ]]; do
  if (( $(date +%s) > DEADLINE )); then
    echo "Timed out after ${TIMEOUT_SECONDS}s waiting for Apple." >&2
    xcrun notarytool log "$SUBMISSION_ID" "${AUTH[@]}" notarization.log || true
    exit 1
  fi
  sleep "$POLL_SECONDS"
  INFO_JSON=$(xcrun notarytool info "$SUBMISSION_ID" "${AUTH[@]}" --output-format json)
  STATUS=$(printf '%s' "$INFO_JSON" | parse_json status)
  echo "    Status: ${STATUS:-<empty>}"
done

# Always fetch the log, even on success — Apple notes it can carry warnings.
xcrun notarytool log "$SUBMISSION_ID" "${AUTH[@]}" notarization.log || true

if [[ "$STATUS" != "Accepted" ]]; then
  echo "Notarization finished with status '$STATUS'. See notarization.log." >&2
  exit 1
fi

echo "==> Stapling ticket to DMG"
xcrun stapler staple "$DMG_PATH"
xcrun stapler validate "$DMG_PATH"

echo "==> Gatekeeper assessment"
spctl --assess --type open --context context:primary-signature --verbose "$DMG_PATH"

echo "OK: $DMG_PATH is notarized, stapled and Gatekeeper-accepted."

A failed poll is never counted as a successful build. Apple's workflow page states most submissions finish in under 5 minutes and 98% within 15 minutes, and recommends a daily limit of 75 notarizations — comfortable for pre-merge CI, worth remembering if a release branch retries in a loop.

Reading the notarization log when the status is Invalid

When status is Invalid, notarytool log writes JSON with each finding carrying severity, path and message. Common findings and their fixes:

Rejection messageFix
The signature of the binary is invalidRe-sign the binary with Developer ID Application, hardened runtime and timestamp
The binary uses an SDK older than the 10.9 SDKRebuild linking against the macOS 10.9 SDK or later
The executable does not have the hardened runtime enabledAdd --options runtime to every codesign call
The signature does not include a secure timestampAdd --timestamp and confirm the builder can reach Apple's timestamp server
com.apple.security.get-task-allow is set to trueRemove that entitlement from release builds
Binary is not signed with a valid Developer ID certificateConfirm the identity type is Developer ID Application (not Apple Development or Developer ID Installer)

Each resubmission gets a new submission ID; older logs remain retrievable by their own ID. Apple's "Resolving common notarization issues" page is linked from the notarization overview above.

Pick the right Figment Mac for this job — a quick worksheet

Notarization itself is a few HTTPS calls and a short staple, so a small host is sufficient when that is the only step. The useful question is whether to colocate archive, DMG build and notarize on one host or split them across two. Time three recent runs on your current host and fill in your own numbers — not someone else's benchmark.

StepYour measured minutes
xcodebuild archive + inside-out codesign (A)___
hdiutil create + codesign of the DMG (B)___
notarytool submit upload (C)___
notarytool info poll, Apple-side (D)___
stapler staple + spctl assess (E)___
TotalA + B + C + D + E

Decision rule, applied to your own measurements:

  • If A dominates and the project needs Apple silicon tooling (Xcode 27, arm64-only execution during tests, Metal performance work), move the whole pipeline to an M-series Mac from $95/month. Archive time on arm64 is where the saving appears.
  • If A + B are small and C + D + E are most of the wall clock, a $29 virtual Intel macOS VM with a compatible Intel toolchain is a reasonable signing-and-notarization worker. D is Apple-side and will not change when you move hosts.
  • If you want CI setup, backups and monitoring delivered for you, Managed Build Mac at $249/month covers one M-series Mac with 30 minutes of monthly support, and Managed Mac Team at $999/month covers two M-series Macs with two CI runners and one hour of monthly support.

Ask your Figment quote to confirm exact chip, RAM, disk and the installed macOS image before you commit — those are set per order, not published as a fixed spec sheet. Physical plans collect requests until supply is confirmed, so do not plan launch day around instant provisioning.

Start a Mac CI host at /mac-hosting/start.

Related guides


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms