Two Mac Builds from One Godot Project: Notarized Developer ID and Sandboxed App Store Packages
The Gatekeeper Problem: Maze Point has offered a macOS download on itch.io for a while. The build ran fine, but nearly every first launch started with a warning dialog. macOS could not verify the developer, offered to move the app to the Trash, and left players to find the override in System Settings. For a small puzzle game, that dialog loses players before they reach the title screen.
The cause was simple. The direct-download zip carried an ad-hoc signature, which is enough to run locally but means nothing to Gatekeeper. Fixing it properly raised a second question. We also wanted a Mac App Store build, and Apple's store wants a very different artifact. We ended up with two macOS export presets in one Godot project, each with its own certificate, entitlements, packaging, and verification steps.
1. Why One Artifact Cannot Serve Both Channels
It is tempting to sign one .app and upload it everywhere. Apple's distribution model rules that out, for three reasons.
- Different certificates. Software distributed outside the store is signed with a Developer ID Application certificate and notarized by Apple. Store builds are signed with an Apple Distribution certificate, and the installer package around them needs a separate Mac Installer Distribution certificate. Gatekeeper does not trust a store-signed app downloaded from the web, and App Store Connect rejects a Developer ID app.
- The App Sandbox. Every Mac App Store app must enable the App Sandbox entitlement. Directly distributed apps may enable it but do not have to. We keep the direct build unsandboxed so it behaves like any other downloaded Mac app.
- Packaging. Notarized direct downloads ship as a zip (or disk image) with the notarization ticket stapled to the app. Store submissions are a signed flat installer package (
.pkg) with a provisioning profile embedded in the app bundle.
Godot's macOS exporter already exposes the settings we needed: distribution type, signing tool, identity, installer identity, and the full list of sandbox entitlements. So we kept one project and split the difference at the preset level.
2. Two Export Presets, Two Signing Identities
The existing macOS preset became the direct-distribution build. We switched its signing tool from the built-in ad-hoc signer to Xcode's codesign, set a Developer ID identity, and left the App Sandbox off. A new macOS App Store preset uses the store distribution type, an Apple Distribution identity, an installer identity, and the sandbox flag. Simplified, the two option blocks differ like this:
# Direct download (itch.io)
codesign/codesign=3
codesign/identity="Developer ID Application: <ORG> (<TEAM_ID>)"
codesign/entitlements/app_sandbox/enabled=false
# Mac App Store
export/distribution_type=2
codesign/codesign=3
codesign/identity="Apple Distribution: <ORG> (<TEAM_ID>)"
codesign/installer_identity="Mac Installer Distribution: <ORG> (<TEAM_ID>)"
codesign/entitlements/app_sandbox/enabled=true
The store preset enables the sandbox with every optional capability (network, USB, file access, and so on) switched off. Maze Point stores its saves inside its own container and needs nothing else, and a minimal entitlement set follows least privilege and keeps review straightforward. Our export preset tests read export_presets.cfg and assert both directions: the store preset must be sandboxed and use the installer identity, and the direct preset must stay unsandboxed with an identity that starts with Developer ID Application:. If someone copies a setting between presets, CI fails before anything is signed.
Godot's exporter signs the direct build with the Hardened Runtime turned on, which notarization requires. We confirm it by checking for the runtime flag in codesign -dvvv output, alongside a secure timestamp.
3. The Notarization Pipeline for Direct Downloads
Signing alone does not satisfy Gatekeeper on current macOS. The app has to be submitted to Apple's notary service, and the resulting ticket should be stapled to the bundle so the app opens without a network lookup. We wrapped the whole sequence in one helper script that runs as a series of gated stages, each of which stops the run on failure:
- Credential preflight. Confirm a matching signing identity is in the keychain, the App Store Connect API key is present, and
codesign,spctl,ditto,notarytool, andstaplerare all available. Failing here takes seconds instead of a failed upload. - Signature verification.
codesign --verify --deep --stricton the exported bundle. - Archive. Zip the bundle with
ditto, notzip. - Submit and wait. Upload with
notarytooland block until Apple returns a verdict. - Staple and assess. Attach the ticket and ask Gatekeeper what it thinks.
codesign --verify --deep --strict --verbose=2 MazePoint.app
ditto -c -k --keepParent MazePoint.app MazePoint-notarize.zip
xcrun notarytool submit MazePoint-notarize.zip \
--key <API_KEY_PATH> --key-id <KEY_ID> --issuer <ISSUER_ID> --wait
xcrun stapler staple MazePoint.app
spctl --assess --type execute --verbose=4 MazePoint.app
The ditto detail matters. A Mac app bundle contains symlinks and extended attributes, and code signatures depend on both. A plain zip -r can flatten symlinks or drop attributes, which leaves you with an archive whose app fails verification once unpacked. ditto -c -k --keepParent produces the same structure Finder would, so the bundle Apple notarizes is identical to the one we ship. After stapling we zip again with ditto and then verify the final release zip itself: extract it to a temporary folder, rerun the signature check, run stapler validate, and repeat the spctl assessment. The artifact players download is the one we tested.
Fail-Closed Release Tooling
Notarization now gates our release manager. Before it dispatches a release, it runs the notarization preflight, and if notarization of the macOS bundle does not end in an accepted status, the release is not published. There is a skip flag for fast local iteration, but the default is to refuse. We would rather delay a release than put an unnotarized build back in front of players.
What Code Review Caught
Our reviewer agents flagged two subtle problems in the first version of the script, both about trusting the wrong signal.
First, the wait result. Notarization can take minutes, so the script supports resuming a pending submission later. The original version treated a clean exit from notarytool wait as success. But wait can return because its timeout expired while the submission is still in progress. The fix was to always query notarytool info after waiting and derive the outcome from the final status: Accepted proceeds, In Progress reports a timeout with a resume command, and Invalid or Rejected prints the notary log and fails.
Second, the resume path. When a submission is created, the script records the bundle's CDHash (the hash that identifies its code signature). When resuming, it compares the current bundle's CDHash with the recorded one before stapling, so we never staple a ticket onto a bundle that was re-exported in the meantime. The first draft quietly continued if the state file was missing or had no hash. Review changed that check to fail closed: missing state, a mismatched submission, or a missing hash all stop the run unless someone explicitly passes an override flag saying the bundle is unchanged.
4. The Sandboxed Installer Package for the Mac App Store
The store build skips notarization entirely; App Review covers that role. What it does need is a signed installer package whose app carries the sandbox entitlement and an embedded provisioning profile (Contents/embedded.provisionprofile). Because the macOS App Store preset sets the App Store distribution type, an Apple Distribution identity for the app, and a Mac Installer Distribution identity for the package, Godot's exporter signs the sandboxed app and writes a signed flat .pkg in one export. The provisioning profile is not part of that export; it has to be embedded before the package is uploaded, and checking for it is one of the things the verification step below looks for. Our packaging helper also keeps a productbuild fallback for repackaging an already-signed bundle:
productbuild --component MazePoint.app /Applications \
--sign "Mac Installer Distribution: <ORG> (<TEAM_ID>)" MazePoint.pkg
Because an installer package is opaque, the same helper verifies it from the outside in before it is uploaded:
pkgutil --check-signature MazePoint.pkg
pkgutil --expand-full MazePoint.pkg expanded/
codesign -dvvv --deep --strict expanded/.../MazePoint.app
codesign -d --entitlements :- expanded/.../MazePoint.app
ls expanded/.../MazePoint.app/Contents/embedded.provisionprofile
The first command confirms the outer installer signature. Expanding the package gives us the real app bundle to inspect, where we check the deep, strict code signature, confirm that com.apple.security.app-sandbox appears in the entitlements, and confirm the provisioning profile is embedded. Each of these is a common rejection reason in App Store Connect, and each is much cheaper to catch locally.
Takeaways
- Treat direct distribution and store distribution as separate products with separate presets. Apple's certificates, sandbox rules, and packaging differ too much to share one artifact.
- Archive Mac apps with
ditto -c -k --keepParent, and verify the archive you ship, not the bundle you built. - Derive success from the final notary status, not from whether a waiting command exited cleanly.
- Make resumable signing steps fail closed. A missing hash should stop the pipeline, not pass it.
- Encode preset invariants in tests so a copied setting cannot leak a sandbox flag or the wrong identity into a release.