Distributing Mac Apps with Developer ID and Notarization
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
The Mac App Store is not the only way to ship a Mac app. A Mac app can also be downloaded from a website, sent
as an email attachment or installed by a company’s device-management system, and macOS runs it as long as it
passes Gatekeeper. For that, the app must be signed with a Developer ID certificate and notarized by
Apple. This page ships FieldNotes for Mac that way: next to the App Store version described on
App Store Connect, TestFlight and App Review, the
team publishes FieldNotes.dmg on fieldnotes.example.com for customers who can’t use the App Store, such as
organizations that block it on managed Macs. Everything here targets macOS 27 and Xcode 27 with
Swift 6.4. The FieldNotes app keeps its deployment minimum of 26, so the same notarized download runs on
macOS 26 (Tahoe) and macOS 27 (Golden Gate). Notarization itself is older than both: nothing on this page is
new in 27.
The page builds on the signing basics (certificates, App IDs, provisioning profiles and entitlements) from Membership, Signing, Capabilities and Info.plist and does not repeat them. The App Sandbox, which a Developer ID app may keep or drop, is on Security and Privacy. Automating the whole flow on a build server is on CI/CD with Xcode Cloud and the Command Line.
Two Ways to Ship a Mac App
Both channels start from the same archive of the FieldNotes target. They differ in who checks the app, how it
reaches the Mac and what the app may use once it gets there:
| Mac App Store | Developer ID (direct distribution) | |
|---|---|---|
Signing certificate |
Apple Distribution; the installer package is signed with Mac Installer Distribution |
Developer ID Application; installer packages with Developer ID Installer |
Who checks the app |
App Review (human and automated), then the store re-signs it |
The notary service: an automated malware and code-signing scan, not App Review |
App Sandbox |
Required |
Optional, but recommended |
Hardened runtime |
Optional |
Required for notarization |
Delivery and updates |
The App Store installs and updates the app |
You host the file, and you ship updates yourself |
Sign in with Apple, In-App Purchase, Game Center, WeatherKit |
Available |
Not available |
iCloud, CloudKit, push notifications, App Groups, Associated Domains |
Available |
Available, with a Developer ID provisioning profile |
The capability rows come from Apple’s table of the capabilities available to each kind of macOS provisioning profile (checked 2026-09-24): Sign in with Apple, In-App Purchase, Game Center and WeatherKit are ticked only in the Apple Developer Program column, and not in the Developer ID column, while iCloud (CloudKit, documents and key-value storage), push notifications, App Groups and Associated Domains are ticked in both. They matter to FieldNotes: the App Store version offers Sign in with Apple and the FieldNotes Pro subscription from In-App Purchases and Subscriptions with StoreKit 2, and the direct download can offer neither. The section A Developer ID Build of FieldNotes below shows how the project handles that.
See Apple Developer — Supported capabilities (macOS) and Apple Developer — Signing your apps for Gatekeeper with Developer ID.
Figure: From archive to Gatekeeper
configuration Release-DeveloperID"] --> B["Sign: exportArchive
Developer ID Application,
hardened runtime, secure timestamp"] B --> C["Package: FieldNotes.dmg
hdiutil create, codesign"] C --> D["Notarize: notarytool submit --wait
keychain profile fieldnotes-notary"] D -->|Invalid| E["notarytool log:
read the issues, fix, rebuild"] E --> A D -->|Accepted| F["Staple: stapler staple FieldNotes.dmg"] F --> G["Distribute: upload to
fieldnotes.example.com"] G --> H["Download adds the
com.apple.quarantine attribute"] H --> I{"Gatekeeper check
on first open"} I -->|"Developer ID signature valid,
ticket found, not revoked"| J["FieldNotes opens after
a one-time confirmation"] I -->|"Unsigned, modified or
not notarized"| K["Blocked: the person must approve it in
System Settings, Privacy and Security"]
Each box is a section of this page. The only step that talks to Apple is notarize (and, on the person’s Mac, the online ticket lookup that stapling makes unnecessary). The notary service keeps a record of every submission, so if the signing key ever leaks, Apple can revoke the tickets of the builds you did not make.
Platform availability
| API or feature | iOS | iPadOS | macOS | watchOS | visionOS |
|---|---|---|---|---|---|
Developer ID signing |
Not available |
Not available |
Yes |
Not available |
Not available |
Notarization with |
Not available (EU alternative distribution uses its own notarization, see App Store Connect, TestFlight and App Review) |
Not available (same) |
Yes |
Not available |
Not available |
Hardened runtime |
Not applicable |
Not applicable |
Yes (opt-in, required for notarization) |
Not applicable |
Not applicable |
Gatekeeper, quarantine and app translocation |
Not available |
Not available |
Yes |
Not available |
Not available |
DMG, installer package and ZIP delivery |
Not available |
Not available |
Yes |
Not available |
Not available |
Developer ID provisioning profiles for CloudKit and push |
Not available |
Not available |
Yes |
Not available |
Not available |
On the other platforms every app comes from the App Store, TestFlight, an enterprise or custom-app channel, or (on iOS and iPadOS in the EU) an alternative marketplace, which App Store Connect, TestFlight and App Review covers.
Developer ID Certificates
Developer ID uses two certificates, and each signs a different kind of file:
-
Developer ID Application signs code and code containers: the app bundle, its nested frameworks and extensions, command-line tools, and disk images.
-
Developer ID Installer signs flat installer packages (
.pkg).
Only the team’s Account Holder can create them, in Xcode (Settings > Apple Accounts > Manage Certificates)
or in Certificates, Identifiers & Profiles. A team can have up to five of each. Team admins who have the
cloud-managed Developer ID certificate access role can instead sign with a cloud-managed Developer ID
certificate, which Xcode uses without the private key ever leaving Apple. That is the better choice for a team,
because nobody has to pass a .p12 file around. Membership types and the other certificate kinds are compared on
Membership, Signing, Capabilities and Info.plist.
Before building, check which signing identities the Mac actually has:
# Code-signing identities (Developer ID Application, Apple Development, Apple Distribution).
security find-identity -v -p codesigning
# 1) 3F1C…E07A "Developer ID Application: Example Corp (ABCDE12345)"
# 1 valid identities found
# Installer identities are not code-signing identities, so list them without -p codesigning.
security find-identity -v | grep "Developer ID Installer"
# 2) 9B42…11D0 "Developer ID Installer: Example Corp (ABCDE12345)"
See Apple Developer — Developer ID certificates and Apple Developer Documentation — Creating distribution-signed code for macOS.
What happens when things expire is different from the App Store, and it is worth knowing before a customer calls:
-
An expired Developer ID Application certificate doesn’t stop anything that was signed while it was valid. Apps signed then keep installing and running. You need a new certificate only to sign new builds.
-
An expired Developer ID Installer certificate does stop things: an installer package runs only while its certificate is valid, so an old
.pkgmust be re-signed. -
A Developer ID provisioning profile is checked at installation and at every launch. FieldNotes embeds one, because CloudKit and push need it. A profile that expires would stop the app from launching. Since February 22, 2017, Developer ID profiles have been valid for 18 years from creation, so this is rarely a problem in practice.
-
A revoked certificate is different from an expired one: apps signed with it can no longer be installed or launched. Revoke only when a key is compromised.
-
An expired membership doesn’t break shipped apps, but you can’t get new Developer ID certificates until you renew.
A Developer ID Build of FieldNotes
The FieldNotes project gains one build configuration, Release-DeveloperID, created in the project editor’s Info tab by duplicating Release. Everything that differs between the two Mac channels hangs off that configuration, so the App Store archive and the direct-download archive come from the same scheme and the same sources.
Entitlements
The Mac App Store build signs with FieldNotes-macOS.entitlements from
Security and Privacy. The Developer ID build uses a copy without the
entitlements that Developer ID can’t carry, with the push environment set to production:
<?xml version="1.0" encoding="UTF-8"?>
<!-- FieldNotes/FieldNotes-macOS-DeveloperID.entitlements -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Still sandboxed: optional outside the App Store, but it limits what an exploit can reach. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- Location: a sandbox entitlement and also a hardened-runtime resource-access entitlement. -->
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.example.fieldnotes</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.example.fieldnotes</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.aps-environment</key>
<string>production</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:fieldnotes.example.com</string>
<string>applinks:fieldnotes.example.com</string>
</array>
<!-- No com.apple.developer.applesignin: Apple's macOS capabilities table doesn't list Sign in with Apple for Developer ID. -->
</dict>
</plist>
See Apple Developer Documentation — Creating distribution-signed code for macOS and Apple Developer — Supported capabilities (macOS).
iCloud, push and Associated Domains are restricted entitlements: a provisioning profile has to authorize them,
so the exported app embeds a Developer ID provisioning profile for com.example.fieldnotes. With automatic
signing, Xcode creates it during export. App Sandbox, hardened-runtime and App Group entitlements are
unrestricted on macOS and need no profile.
Build settings
The project-wide base file turns the hardened runtime on for every Mac executable (the app and the
FieldNotesWidgets extension). The target’s own .xcconfig, introduced on
One Codebase for iOS, iPadOS, macOS, watchOS and visionOS, switches
the entitlements and adds a compilation condition only in the new configuration:
// Config/Base.xcconfig (added)
// Required for notarization, harmless for the App Store build.
ENABLE_HARDENED_RUNTIME[sdk=macosx*] = YES
// Config/FieldNotes.xcconfig (added) -- the FieldNotes app target only
CODE_SIGN_ENTITLEMENTS[config=Release-DeveloperID][sdk=macosx*] = FieldNotes/FieldNotes-macOS-DeveloperID.entitlements
SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Release-DeveloperID][sdk=macosx*] = $(inherited) FIELDNOTES_DIRECT
Code that differs
FIELDNOTES_DIRECT lets the Swift code leave out what the direct download can’t offer. The sign-in controls keep
passkeys, which only need Associated Domains, and drop the Sign in with Apple button. The Pro upsell is left out
entirely, because StoreKit purchases need the App Store:
// FieldNotes/Views/AccountSignInControls.swift
import SwiftUI
/// The sign-in and upgrade controls this build is allowed to offer.
/// SignInView, PasskeySignInButton and ProStoreView are the views from the Security and StoreKit pages.
struct AccountSignInControls: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
#if FIELDNOTES_DIRECT
// Developer ID build: no Sign in with Apple, no In-App Purchase.
PasskeySignInButton()
Text("FieldNotes Pro is available in the Mac App Store version.")
.font(.footnote)
.foregroundStyle(.secondary)
#else
SignInView()
#if !os(watchOS)
PasskeySignInButton()
#endif
ProStoreView()
#endif
}
}
}
See Apple Developer Documentation — Build settings reference.
The #if rules themselves are on
Attributes and Compiler Control.
AccountSignInControls reads AccountManager and StoreManager through the views it contains, so it must sit
inside a scene that already injects both with .environment(…), as FieldNotesApp does for SettingsView.
Needs a paid developer account (Developer ID) and the Associated Domains capability for passkeys.
Two variants, one bundle ID
Both Mac variants keep the bundle ID com.example.fieldnotes, so they share the same iCloud container and App
Group, and a person who switches from the download to the App Store version keeps their notes. Two
consequences are easy to miss:
-
Privacy permissions are not shared. macOS identifies an app for privacy prompts (location, for example) by its code signature’s designated requirement, and the default requirement of an App Store app is not compatible with that of a Developer ID app. Someone who moves between variants sees the location prompt again, unless both are signed with a custom, mutually compatible designated requirement.
-
Only one copy should be installed. Two apps with the same bundle ID in
/Applicationsconfuse Launch Services and the widget gallery. The download page says so, and the App Store listing mentions the direct download for managed Macs.
Sandboxed or not
Outside the App Store the App Sandbox is optional. FieldNotes keeps it, because the sandbox limits what an
exploited app can touch, and because keeping it means one code path for both variants. Keeping it also means
both variants use the container layout described on
Preferences, Files and Document-Based Apps, and the export
folder is still reached through the security-scoped ExportFolderBookmark from
macOS: Windows, Menus, Commands, Settings and Menu Bar Extras.
An app that drops the sandbox gets the real ~/Library locations instead of a container, and can read any file
the user can. Only do that when the app really needs it (a developer tool, a backup utility), and remember that
data written into the container by an earlier sandboxed version won’t be found at the new location.
The Hardened Runtime
The hardened runtime is a set of runtime protections that macOS enforces on a process whose signature has the
runtime flag. It blocks code injection and library hijacking: no writable-and-executable memory, no loading of
libraries signed by another team, no DYLD_* environment variables, no debugger attaching. It also blocks the
camera, microphone, location, contacts, calendars, photos and Apple Events unless the app declares an
entitlement for each. Notarization rejects executables that don’t use it. Xcode turns it on with the Hardened
Runtime capability, which is the ENABLE_HARDENED_RUNTIME build setting shown above. Outside Xcode, signing
with codesign --options runtime does the same.
Each protection can be relaxed with a runtime exception entitlement. FieldNotes needs none of them. It only needs the location resource access entitlement, which it already declares:
| Capability checkbox | Entitlement | Typical reason |
|---|---|---|
Allow Execution of JIT-compiled Code |
|
A JavaScript engine or emulator that uses |
Allow Unsigned Executable Memory |
|
Legacy code that writes machine code without |
Allow DYLD Environment Variables |
|
Rarely justified in shipping apps |
Disable Library Validation |
|
Hosting third-party plug-ins signed by other teams |
Disable Executable Memory Protection |
|
Almost never: removes a core protection |
Debugging Tool |
|
The app is itself a debugger |
Resource Access: Location (and Camera, Audio Input, Contacts, Calendar, Photos Library, Apple Events) |
|
FieldNotes tags notes with the current location |
The rule is the same as for sandbox entitlements: add the narrowest exception that makes a feature work, and never add one "just in case". A plug-in host must declare everything its plug-ins need, because plug-ins inherit the host’s entitlements.
To confirm what actually ended up in the signed app, inspect it after export:
# The flags line must include "runtime", and a Timestamp line must be present.
codesign --display --verbose=2 build/export/FieldNotes.app 2>&1 | grep -E "flags=|Timestamp="
# CodeDirectory v=20500 size=… flags=0x10000(runtime) hashes=…
# Timestamp=24 Sep 2026 at 10:12:31
# The embedded entitlements, as XML. They must not contain get-task-allow.
codesign --display --entitlements - --xml build/export/FieldNotes.app | plutil -convert xml1 -o - -
See Apple Developer Documentation — Configuring the hardened runtime and Apple Developer Documentation — Hardened Runtime.
Debug builds get the com.apple.security.get-task-allow entitlement so the debugger can attach. Xcode adds it
through CODE_SIGN_INJECT_BASE_ENTITLEMENTS and removes it again when it archives and exports. A custom signing
script that re-signs a Debug product would keep it, and notarization rejects it.
Archiving and Exporting
In Xcode, the Developer ID export is a few clicks: choose the FieldNotes scheme with My Mac as the destination, Product > Archive (with the scheme’s Archive action set to Release-DeveloperID), then in the Organizer click Distribute App and choose Direct Distribution. Xcode signs with Developer ID, uploads the app to the notary service, waits, staples the ticket and exports a ready-to-ship app.
The same steps are scriptable, which is what a release script or a CI job uses. First, an export-options
property list for Developer ID, next to the ExportOptions.plist that
App Store Connect, TestFlight and App Review uses
for the App Store:
<?xml version="1.0" encoding="UTF-8"?>
<!-- ExportOptions-DeveloperID.plist -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>teamID</key>
<string>ABCDE12345</string>
<key>signingStyle</key>
<string>automatic</string>
<key>destination</key>
<string>export</string>
</dict>
</plist>
See Apple Developer Documentation — Customizing the notarization workflow.
Run xcodebuild -help for the full list of export-options keys; Apple doesn’t document them anywhere else.
# 1. Archive the Mac build in the Developer ID configuration.
xcodebuild archive \
-project FieldNotes.xcodeproj -scheme FieldNotes \
-configuration Release-DeveloperID \
-destination 'generic/platform=macOS' \
-archivePath build/FieldNotes-mac.xcarchive
# 2. Export a Developer ID-signed app (hardened runtime and secure timestamp included).
xcodebuild -exportArchive \
-archivePath build/FieldNotes-mac.xcarchive \
-exportOptionsPlist ExportOptions-DeveloperID.plist \
-exportPath build/export
# 3. Check the whole bundle the way the notary service will.
codesign --verify --deep --strict --verbose=2 build/export/FieldNotes.app
See Apple Developer Documentation — Creating distribution-signed code for macOS. Needs the Developer ID Application certificate (or the cloud-managed certificate role) and a paid membership.
If the Organizer shows Distribute Content instead of Distribute App, the archive is a Generic Xcode Archive:
a dependency (usually a framework target) has Skip Install set to No and was copied into the archive’s
Products folder. Set SKIP_INSTALL = YES on every target that is embedded in the app.
Signing by hand
Code that Xcode doesn’t build, such as a prebuilt helper tool copied into the bundle, must be signed by hand.
Apple’s rules for that are short: sign from the inside out, give each main executable its own entitlements,
add --timestamp and --options runtime, and never use --deep to sign (it applies the same options to
every nested item and misses code in unexpected places; --deep is only for verification):
IDENTITY="Developer ID Application: Example Corp (ABCDE12345)"
APP=build/export/FieldNotes.app
# A nested command-line tool first (not bundled, so give it an identifier)...
codesign --force --sign "$IDENTITY" --timestamp --options runtime \
--identifier com.example.fieldnotes.fnexport \
"$APP/Contents/Helpers/fnexport"
# ...then re-sign the app itself last, with its own entitlements.
codesign --force --sign "$IDENTITY" --timestamp --options runtime \
--entitlements FieldNotes/FieldNotes-macOS-DeveloperID.entitlements "$APP"
See Apple Developer Documentation — Creating distribution-signed code for macOS.
Copy bundles with ditto, not cp, so framework symlinks survive, and never run codesign with sudo. The
fnexport helper is only an illustration: FieldNotes itself ships no helper tool, so its export needs no manual
signing at all.
Notarization
Notarization is an automated check by Apple’s notary service. It scans the submission for known malware,
verifies that every executable is signed with Developer ID, uses the hardened runtime, has a secure timestamp and
doesn’t carry get-task-allow, and then issues a ticket. It is not App Review: nobody looks at the app’s
design or behavior. Most submissions finish within 5 minutes and 98 percent within 15. Since macOS 10.15, all
Developer ID software built after June 1, 2019 must be notarized.
Credentials: the fieldnotes-notary keychain profile
notarytool needs App Store Connect credentials on every call. Instead of passing them each time, store them
once in the login keychain under a keychain profile, and refer to it by name. An App Store Connect team API
key (Developer role or higher) is the best choice, because it isn’t tied to a person’s Apple Account:
# Store an App Store Connect API key under the profile name FieldNotes scripts use.
# The .p8 file is the private key downloaded once from App Store Connect; keep it out of the repository.
xcrun notarytool store-credentials fieldnotes-notary \
--key ~/.appstoreconnect/private_keys/AuthKey_YOUR_KEY_ID.p8 \
--key-id YOUR_KEY_ID \
--issuer YOUR_ISSUER_UUID
# Alternative for a single developer: Apple Account + app-specific password.
# Leave out --password and notarytool prompts for it, so it never lands in shell history.
xcrun notarytool store-credentials fieldnotes-notary \
--apple-id "release@example.com" --team-id ABCDE12345
See Apple Developer Documentation — Customizing the notarization workflow and WWDC21 — Faster and simpler notarization for Mac apps.
The API key is created in App Store Connect under Users and Access > Integrations, as shown on
CI/CD with Xcode Cloud and the Command Line, which also
passes the same key to notarytool from CI secrets instead of a keychain profile.
Submitting and waiting
notarytool submit uploads a ZIP, a signed flat installer package or a UDIF disk image. The service can’t
accept a bare .app directory. With --wait the command returns only when the service has finished, so a
script can check the result right away:
# Submit the signed disk image and block until the notary service answers.
xcrun notarytool submit build/FieldNotes.dmg \
--keychain-profile fieldnotes-notary \
--wait
# Conducting pre-submission checks for FieldNotes.dmg and initiating connection to the Apple notary service...
# Submission ID received
# id: 6c1d…-…-…
# Successfully uploaded file
# Waiting for processing to complete.
# Current status: Accepted.......
# Past submissions, one submission's status, and its log (read it even when Accepted: it lists warnings).
xcrun notarytool history --keychain-profile fieldnotes-notary
xcrun notarytool info <submission-id> --keychain-profile fieldnotes-notary
xcrun notarytool log <submission-id> --keychain-profile fieldnotes-notary build/notary-log.json
See Apple Developer Documentation — Notarizing macOS software before distribution and Apple Developer Documentation — Customizing the notarization workflow. Needs a paid Apple Developer Program membership and network access to Apple’s notary service.
submit also takes --webhook <https-url>, which asks the service to call a URL when processing ends. Build
servers that can’t block a job for a quarter of an hour can use that, or the REST Notary API, which does the
same without Xcode installed.
altool is retired
Since November 1, 2023, the notary service no longer accepts uploads from altool or from Xcode 13 and
earlier. Scripts that still call xcrun altool --notarize-app fail and must move to notarytool. Apps that
were notarized with altool before that date keep working.
See Apple Developer — Upcoming Requirements (entry Apple notary service update, since November 1, 2023) and Apple Developer — Signing your apps for Gatekeeper with Developer ID.
Things the service expects
A few rules shape how the release is packaged:
-
Notarize what you ship, outermost container only. For an app inside a disk image, sign the app, sign the image, and notarize only the image; the service opens nested containers and issues tickets for everything inside.
-
Keep the file count and size reasonable. Many small files, non-code files inside
Contents/MacOS, heavily compressed or corrupt images, and large data files that change every build all slow notarization. Verify a disk image withhdiutil verifybefore submitting. -
Limit notarizations to 75 a day, as Apple asks.
-
Build servers need network access to the notary service (Amazon S3 transfer acceleration, or
--no-s3-acceleration) and to Apple’s timestamp server for signing. -
Old releases can be notarized too. You can submit software you already shipped, without rebuilding it, so that Gatekeeper recognizes it.
Stapling the Ticket
After a successful submission the ticket is published online, and Gatekeeper can look it up the first time the app is opened. Stapling attaches the ticket to the file itself, so the check also passes offline (on a plane, behind a strict proxy, on an air-gapped lab Mac). Staple every release:
# Attach the ticket to the disk image, then confirm it is there.
xcrun stapler staple build/FieldNotes.dmg
xcrun stapler validate build/FieldNotes.dmg
# The validate action worked!
stapler works on app bundles, disk images and flat installer packages. It can’t staple to a ZIP archive or to a
standalone binary: staple the app you put inside the ZIP, and create a new ZIP for distribution (next section).
Stapling modifies the file, so it happens after notarization and before the file is uploaded or hashed for a
download page.
Packaging: DMG, Installer Package or ZIP
Pick the container by what the product needs to do on the customer’s Mac:
| Container | Use it when | Signing and notarization |
|---|---|---|
DMG |
A single app that people drag to Applications. The usual choice, and FieldNotes’s choice. |
Sign the image with Developer ID Application. Notarize and staple the image. |
Installer package ( |
Something must go outside |
Sign with Developer ID Installer. Notarize and staple the package. |
ZIP |
Updates downloaded by an in-app updater, or a quick internal build. |
The ZIP can’t be signed or stapled. Notarize the ZIP, then staple the app and re-zip it. |
Disk image
# Stage the exported app next to a link to /Applications for drag-to-install.
mkdir -p build/dmg-root
ditto build/export/FieldNotes.app build/dmg-root/FieldNotes.app
ln -s /Applications build/dmg-root/Applications
# A read-only, zlib-compressed UDIF image (UDZO), then sign it with a unique identifier.
hdiutil create -volname "FieldNotes" -srcfolder build/dmg-root -format UDZO -ov build/FieldNotes.dmg
codesign --sign "Developer ID Application: Example Corp (ABCDE12345)" --timestamp \
--identifier com.example.fieldnotes.dmg build/FieldNotes.dmg
hdiutil verify build/FieldNotes.dmg
# Notarize the outermost container and staple it.
xcrun notarytool submit build/FieldNotes.dmg --keychain-profile fieldnotes-notary --wait
xcrun stapler staple build/FieldNotes.dmg
See Apple Developer Documentation — Packaging Mac software for distribution.
Third-party DMG tools that add a background image and icon layout are fine, as long as the result is still a
signed, read-only UDZO image.
Installer package
FieldNotes doesn’t need an installer, but a version that installed a login item or a shared framework for other
apps would. productbuild --component wraps a single app. pkgbuild builds a component package that
productbuild combines into the final product archive, which is the part that gets signed and notarized:
# One-step product archive for a single app, signed with Developer ID Installer.
productbuild --component build/export/FieldNotes.app /Applications \
--sign "Developer ID Installer: Example Corp (ABCDE12345)" --timestamp \
build/FieldNotes.pkg
# Or in two steps, when you need scripts or several components.
pkgbuild --component build/export/FieldNotes.app --install-location /Applications \
--identifier com.example.fieldnotes.pkg --version 2.2.0 build/FieldNotes-component.pkg
productbuild --package build/FieldNotes-component.pkg \
--sign "Developer ID Installer: Example Corp (ABCDE12345)" --timestamp build/FieldNotes.pkg
# The output must include "Signed with a trusted timestamp" and "Developer ID Installer:".
pkgutil --check-signature build/FieldNotes.pkg
xcrun notarytool submit build/FieldNotes.pkg --keychain-profile fieldnotes-notary --wait
xcrun stapler staple build/FieldNotes.pkg
See Apple Developer Documentation — Packaging Mac software for distribution
and Apple Developer Documentation — Resolving common notarization issues.
productsign signs an existing unsigned package. Apple’s old PackageMaker tool can’t sign at all; replace it
with pkgbuild and productbuild.
ZIP
# ditto keeps symlinks and extended attributes; --keepParent puts FieldNotes.app at the top of the archive.
ditto -c -k --keepParent build/export/FieldNotes.app build/FieldNotes-notarize.zip
xcrun notarytool submit build/FieldNotes-notarize.zip --keychain-profile fieldnotes-notary --wait
# A ZIP can't hold a ticket: staple the app, then zip it again for distribution.
xcrun stapler staple build/export/FieldNotes.app
ditto -c -k --keepParent build/export/FieldNotes.app build/FieldNotes-2.2.0.zip
Gatekeeper
Gatekeeper decides whether a downloaded app may run. Browsers, Mail, Messages and AirDrop add the
com.apple.quarantine extended attribute to what they download. The first time a quarantined app is opened,
macOS checks that:
-
the code signature is valid and was made with a Developer ID certificate that Apple has not revoked;
-
the app is notarized, either from the stapled ticket or by looking up the ticket online;
-
nothing in the bundle changed after signing (a modified resource breaks the seal).
If everything passes, the person sees a one-time dialog that names the developer and says the app was checked for malicious software. If not, the app is blocked. Since macOS Sequoia (announced August 6, 2024), people can no longer Control-click an app to override Gatekeeper: they have to go to System Settings > Privacy & Security and review the app there before allowing it. An un-notarized direct download is now close to unshippable for a consumer app.
See Apple Developer — Updates to runtime protection in macOS Sequoia and Apple Developer — Signing your apps for Gatekeeper with Developer ID.
You can ask the system policy for its verdict before shipping:
# Would Gatekeeper let this app run? Expect "accepted" and "source=Notarized Developer ID".
spctl --assess --type execute --verbose=4 build/export/FieldNotes.app
# build/export/FieldNotes.app: accepted
# source=Notarized Developer ID
# The same question for the disk image and the installer package.
spctl --assess --type open --context context:primary-signature --verbose=4 build/FieldNotes.dmg
spctl --assess --type install --verbose=4 build/FieldNotes.pkg
App translocation is the other thing Gatekeeper does. When someone opens a quarantined app straight from the disk image or the Downloads folder, without moving it first, macOS runs it from a randomized, read-only path. This stops an attacker from shipping a harmless signed app next to malicious files it loads with relative paths. FieldNotes never reads files next to its bundle, so translocation only affects it in one visible way: an in-app updater can’t replace a translocated copy. Translocation happens only on first launch, and a moved app is never translocated.
The only real test is the customer’s path. Upload the stapled FieldNotes.dmg, download it with Safari on a
Mac that has never run FieldNotes, open the image, drag the app to Applications and launch it. Then repeat for
the upgrade case (an older version already installed), for the duplicate case (the same version in another
folder), and for a second user account on the same Mac.
Troubleshooting Notarization
When a submission comes back Invalid, the log says why. Each issue names a path inside the submission and a message:
{
"jobId": "6C1D…",
"status": "Invalid",
"statusSummary": "Archive contains critical validation errors",
"archiveFilename": "FieldNotes.dmg",
"issues": [
{
"severity": "error",
"path": "FieldNotes.dmg/FieldNotes.app/Contents/Helpers/fnexport",
"message": "The executable does not have the hardened runtime enabled."
}
]
}
The messages repeat across projects, and so do their fixes:
| Message in the log | Fix |
|---|---|
The signature of the binary is invalid. |
Something changed the bundle after signing, or a nested item was never signed. Re-sign inside out, then check
with |
The binary is not signed with a valid Developer ID certificate. |
It was signed with Apple Development, Apple Distribution or a self-signed identity. Export with the
|
The signature does not include a secure timestamp. |
A custom signing step left out |
The executable does not have the hardened runtime enabled. |
Set |
The executable requests the com.apple.security.get-task-allow entitlement. |
A Debug build or a re-signed Debug product was submitted. Archive and export instead, or set
|
Embedded entitlements are invalid: syntax error near line 1 |
The entitlements were a binary plist or had a byte-order mark. Run |
The binary uses an SDK older than the 10.9 SDK. |
An old prebuilt library. Rebuild it or replace it. |
The Organizer offers Distribute Content, not Distribute App |
A dependency has Skip Install set to No; set |
|
The file isn’t writable or isn’t a stapleable type (ZIP, bare binary), or the ticket isn’t propagated yet: wait a minute and retry. |
Two quick checks catch most of these before any upload:
# Entitlements file well formed?
plutil -lint FieldNotes/FieldNotes-macOS-DeveloperID.entitlements
# Every nested item signed, sealed and timestamped?
codesign --verify --deep --strict --verbose=2 build/export/FieldNotes.app
codesign --display --verbose=2 build/export/FieldNotes.app 2>&1 | grep -E "Authority=Developer ID Application|Timestamp="
Shipping Updates
The App Store updates apps for you; a direct download doesn’t. The simplest option for FieldNotes is a small
version feed on fieldnotes.example.com that the download build checks, linking to the new FieldNotes.dmg.
Many Mac apps use an update framework such as the open-source Sparkle instead, which downloads, verifies and
installs the update. Whatever
the mechanism, every update is signed with the same Developer ID, notarized and stapled like the first release,
and keeps the build number increasing. A Mac build number must keep growing across versions, also on the App
Store, so both channels share one CURRENT_PROJECT_VERSION.
The whole release fits in one script that CI can run: archive, export, package, notarize, staple and verify. The Xcode Cloud and GitHub Actions versions of it are on CI/CD with Xcode Cloud and the Command Line.
#!/bin/zsh
# Scripts/release-mac.sh -- builds, notarizes and staples FieldNotes.dmg.
set -euo pipefail
xcodebuild archive -project FieldNotes.xcodeproj -scheme FieldNotes \
-configuration Release-DeveloperID -destination 'generic/platform=macOS' \
-archivePath build/FieldNotes-mac.xcarchive
xcodebuild -exportArchive -archivePath build/FieldNotes-mac.xcarchive \
-exportOptionsPlist ExportOptions-DeveloperID.plist -exportPath build/export
codesign --verify --deep --strict build/export/FieldNotes.app
rm -rf build/dmg-root && mkdir -p build/dmg-root
ditto build/export/FieldNotes.app build/dmg-root/FieldNotes.app
ln -s /Applications build/dmg-root/Applications
hdiutil create -volname FieldNotes -srcfolder build/dmg-root -format UDZO -ov build/FieldNotes.dmg
codesign --sign "Developer ID Application: Example Corp (ABCDE12345)" --timestamp \
--identifier com.example.fieldnotes.dmg build/FieldNotes.dmg
# Wait for the verdict and fail the release, with the log, unless it is Accepted.
result=$(xcrun notarytool submit build/FieldNotes.dmg --keychain-profile fieldnotes-notary \
--wait --output-format json)
submission_status=$(print -r -- "$result" | plutil -extract status raw -o - -)
if [[ "$submission_status" != "Accepted" ]]; then
submission_id=$(print -r -- "$result" | plutil -extract id raw -o - -)
xcrun notarytool log "$submission_id" --keychain-profile fieldnotes-notary build/notary-log.json
print -u2 "Notarization finished with status $submission_status; see build/notary-log.json"
exit 1
fi
xcrun stapler staple build/FieldNotes.dmg
spctl --assess --type open --context context:primary-signature --verbose build/FieldNotes.dmg
The script checks the submission’s status instead of relying on the exit code alone: an Invalid verdict is a finished submission, not a failed upload, so the release has to fail on it explicitly and keep the log for whoever fixes the build.
Practical Guidelines
-
Ship both, from one project. One scheme, two configurations (Release and Release-DeveloperID), one bundle ID. Differences live in an entitlements file and one compilation condition, never in a fork.
-
Keep the sandbox. It costs FieldNotes nothing, keeps the two variants' file locations the same, and limits what an exploit can reach.
-
Use an API key for notarization, stored as the
fieldnotes-notarykeychain profile on a Mac and as CI secrets on a runner. Never put an app-specific password in a script or repository. -
Notarize, staple, then test the download. A stapled DMG downloaded with Safari onto a clean Mac is the only test that matches what customers do.
-
Read the notary log every time, even when the status is Accepted: warnings today tend to become errors in a later macOS release.
-
Guard the Developer ID key. Keep few copies, or use the cloud-managed certificate. A leaked key means revocation, and a revoked certificate stops every app signed with it from launching.
-
Re-check Apple’s pages each release. Notary requirements have changed before (the
altoolcutoff of November 1, 2023, and the Sequoia Gatekeeper change of August 6, 2024); the upcoming-requirements page lists new ones with their dates.
References
-
Apple Developer — Signing your apps for Gatekeeper with Developer ID
-
Apple Developer Documentation — Notarizing macOS software before distribution
-
Apple Developer Documentation — Customizing the notarization workflow
-
Apple Developer Documentation — Resolving common notarization issues
-
Apple Developer Documentation — Creating distribution-signed code for macOS
-
Apple Developer Documentation — Packaging Mac software for distribution
-
Apple Developer Documentation — Configuring the hardened runtime
-
Apple Developer Documentation — Adding a build configuration file to your project
-
Apple Developer — Updates to runtime protection in macOS Sequoia