Back to Blog

Notarizing a Mac App for Direct Distribution: Developer ID, DMG, and GitHub Releases

macosdeveloper-idnotarizationcodesigngithub-actionsdeployment

The short version: a Mac app you distribute yourself needs a Developer ID Application signature, a notarization submission of the final artifact, and a stapled ticket attached to the file users download. Skipping any of the three leaves Gatekeeper blocking the app on someone else’s Mac.

You build an app in Xcode. It runs on your Mac. Someone downloads the same build, and macOS refuses to open it. Apple holds no record of having seen that copy.

The Developer ID route fixes this. You sign the app so Gatekeeper knows who built it, notarize it so Apple confirms it checked the build, and hand the user a file macOS will open.

Decide which route you are on before you touch a credential.

Developer ID means you distribute it yourself. Sign the build with your own certificate, notarize it, publish it through a website, GitHub Releases, or a Homebrew cask. You set the release schedule and you take the payments.

The Mac App Store runs on App Store Connect, a Mac App Store distribution certificate, sandboxing, and App Review. Its credentials do not work on the Developer ID route, and the reverse holds too. Keep the two sets apart.

What you need before you start

A Developer ID Application certificate

Create a Developer ID Application certificate in your Apple Developer account under Certificates, Identifiers & Profiles. Download the .cer, install it into Keychain Access, and export it from My Certificates as a .p12 with a password you choose.

Watch for these:

  • Developer ID Application signs the .app. Developer ID Installer signs an Installer Package, which a plain DMG release does not need.
  • The .p12 export password is one you set at export time. It is a separate credential from your Apple ID password.
  • The .p12 and its password stay out of Git repositories, CI logs, and note-taking apps. It is a private key.

Apple documents this step in Developer ID certificates and Signing Mac software with Developer ID.

Your Team ID

Membership details in your Apple Developer account lists it. On its own it is not a secret, and the notarization command needs it.

A notarization credential

Pick one of two.

Apple ID with an app-specific password

This suits a single project. You need an Apple ID with two-factor authentication enabled. Create an app-specific password at account.apple.com. It is a separate credential from your Apple ID login password, and you can revoke it on its own.

Store it once as a notarytool profile, so you are not passing a password on every command line:

xcrun notarytool store-credentials "myapp-notary" \
  --apple-id "APPLE_ID" \
  --team-id "TEAM_ID" \
  --password "APP_SPECIFIC_PASSWORD"

CI passes the values explicitly instead:

xcrun notarytool submit artifact.dmg \
  --apple-id "$APPLE_ID" \
  --team-id "$APPLE_TEAM_ID" \
  --password "$APPLE_APP_PASSWORD" \
  --wait

App Store Connect API key

Use this when you ship several apps or work as an organisation. One key covers every app, where an Apple ID ties you to one per project. Manage keys in App Store Connect under Users and Access → Integrations. You get three values: the .p8 private key, a Key ID, and an Issuer ID.

App Store Connect offers the .p8 for download once. Save it somewhere durable at that moment.

echo "$APPLE_API_KEY_BASE64" | base64 --decode > "$RUNNER_TEMP/api-key.p8"
xcrun notarytool submit artifact.dmg \
  --key "$RUNNER_TEMP/api-key.p8" \
  --key-id "$APPLE_API_KEY_ID" \
  --issuer "$APPLE_API_ISSUER_ID" \
  --wait

notarytool accepts either credential. Configuring both adds a failure mode without adding a capability.

The release, step by step

1. Preflight

Decide what you are shipping before you build anything. Confirm the working tree is clean, or that you know which changes are included. Confirm the version string, changelog, and bundle identifier match your intent, and that CI passes on the commit you are about to tag.

Settle artifact filenames once: no spaces, stable across versions. MyApp.dmg, rather than My App 1.2 (final).dmg. Version information belongs in the tag, the release title, and the app metadata.

Keep three things aligned through a release: the git tag, CFBundleShortVersionString, and the version in the release notes.

git status --short

2. Build a universal app

Build for both architectures unless you have a reason not to.

lipo -info MyApp.app/Contents/MacOS/MyApp
plutil -p MyApp.app/Contents/Info.plist

lipo -info should name arm64 and x86_64. When it names one, the app will not run on half the Macs you expect it to.

3. Sign it, from the inside out

Sign with Developer ID Application and enable Hardened Runtime.

When the app contains a helper, an XPC service, a framework, or an updater, sign the innermost bundle first and the main app last.

embedded executable / XPC service

framework / helper app

the main app

Verify the result:

codesign --verify --deep --strict --verbose=2 MyApp.app
codesign -dvvv MyApp.app

--deep verifies a bundle. It does not sign one in the right order. It will report the outer bundle as signed while an inner helper carries no signature, and notarization rejects that build every time.

4. Package the DMG

A DMG carries the app you already prepared. It signs nothing and notarizes nothing.

signed .app → hdiutil / create-dmg → .dmg

Mount the DMG before you move on. Confirm it holds the .app, that the .app sits at the top level rather than nested inside a copy, and that Contents/Info.plist, Contents/MacOS, and Contents/Resources are all present. When hdiutil complains about the filesystem on Apple Silicon, pass --filesystem APFS.

5. Notarize the artifact people will download

Submit the final DMG or ZIP, not an intermediate build.

xcrun notarytool submit MyApp.dmg \
  --apple-id "$APPLE_ID" \
  --team-id "$APPLE_TEAM_ID" \
  --password "$APPLE_APP_PASSWORD" \
  --wait

The rejection reason sits in the log, not in the last line of that output.

xcrun notarytool log SUBMISSION_ID \
  --apple-id "$APPLE_ID" \
  --team-id "$APPLE_TEAM_ID" \
  --password "$APPLE_APP_PASSWORD"

Read the log before you change anything.

6. Staple, then verify what you will publish

xcrun stapler staple MyApp.app
xcrun stapler validate MyApp.app
spctl -a -vv --type execute MyApp.app

Notarized the DMG? Validate the DMG too, since that file is what the user gets.

Download the release artifact from where you published it, move it to a Mac that holds no development certificate, and open it there. Every check so far ran on a machine that already trusts you. That second Mac shows what a stranger sees.

7. Publish the release

Trigger the release from the tag and let one workflow run the whole chain.

git tag v1.0.0
git push origin v1.0.0
checkout tag
  → build
  → sign
  → package
  → notarize
  → staple
  → validate
  → publish the release

The default GITHUB_TOKEN creates a release in the same repository. A separate personal access token is only needed when a workflow writes to another repository, updating a Homebrew cask elsewhere, for example.

When it fails

Importing the certificate fails on CI

Check these in order. The secret holds the base64 of the complete .p12, not a .cer and not a file path. The export password is the one you set at export time. The temporary keychain is unlocked. set-key-partition-list includes apple-tool:, apple:, and codesign:. A keychain missing those permissions lets security import succeed and codesign fail, which reads like a signing problem rather than a keychain one.

Notarization rejects the upload

The log names the reason. Check for these:

  • The signing identity is not Developer ID Application.
  • A helper or framework inside the bundle carries no signature.
  • You signed the nested bundles in the wrong order.
  • You did not enable Hardened Runtime.
  • The file you uploaded carried no signature, or you repackaged it after signing.
  • The version or bundle metadata is incomplete.

Gatekeeper still blocks it

Work out which state the build is in.

  • Unsigned: macOS blocks it, correctly.
  • Ad-hoc signed: fine for local development, and not a distribution credential.
  • Developer ID signed and notarized: the state you are aiming for.

If the build is in the third state and Gatekeeper still blocks it, check whether the file the user downloaded is the stapled final artifact or an intermediate from the workflow.

hdiutil or create-dmg errors

Check that the source .app exists and its bundle structure is complete. Check for a mounted volume left over from a previous run, a directory of the same name, or stale files at the destination. Unmount volumes before the step finishes.

Do you need Sparkle?

Only when the app updates itself in place. That requires an appcast feed and an Ed25519 signing key. A release users download and install from a GitHub Release page does not need Sparkle, and adding it later invalidates nothing you did here.

Ad-hoc signing is not a release credential

An ad-hoc signed build passes on your machine. It says nothing about whether Developer ID signing or notarization will succeed, because the local build skips signature verification and notarization entirely. Test the pipeline with a real release artifact on a machine that does not already trust you.

This is the process behind the macOS apps I ship, Kipless among them. I ship it under MPL-2.0 as a signed, notarized DMG.

Frequently asked questions

What is the difference between Developer ID and notarization?

Developer ID is a certificate that signs your app and tells Gatekeeper who built it. Notarization is a separate automated check Apple runs on the finished artifact, and `stapler` attaches the result to it. Distribution outside the App Store needs both.

How do I notarize a macOS app from the command line?

Submit the final DMG or ZIP with `xcrun notarytool submit`, passing either an Apple ID with an app-specific password or an App Store Connect API key, and add `--wait` to block until the result returns. When it fails, run `xcrun notarytool log` and read that instead of the last line of the submit output.

Why does Gatekeeper still block my app after notarization?

Check whether the artifact you uploaded carried a signature, whether you repackaged it after signing, whether a helper or framework inside the bundle carries no signature, and whether the file the user downloaded is the stapled final artifact rather than an intermediate from the workflow. Verify the exact file you intend to publish, on a second Mac that holds no development certificate.

Do I need Sparkle to distribute a Mac app outside the App Store?

No. Sparkle updates an app in place, and that requires an appcast feed and an Ed25519 signing key. A release users download from a GitHub Release page and install themselves needs neither.

Should I use an Apple ID or an App Store Connect API key for notarization?

Both work, and `notarytool` accepts either. An Apple ID with an app-specific password suits a single personal project. An API key suits several projects or an organisation, because one key covers every app. Configuring both adds a failure mode without adding a capability.