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.

Figure: From archive to Gatekeeper

flowchart LR A["xcodebuild archive
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 notarytool

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)"

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 .pkg must 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>

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
        }
    }
}

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 /Applications confuse 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

com.apple.security.cs.allow-jit

A JavaScript engine or emulator that uses MAP_JIT

Allow Unsigned Executable Memory

com.apple.security.cs.allow-unsigned-executable-memory

Legacy code that writes machine code without MAP_JIT

Allow DYLD Environment Variables

com.apple.security.cs.allow-dyld-environment-variables

Rarely justified in shipping apps

Disable Library Validation

com.apple.security.cs.disable-library-validation

Hosting third-party plug-ins signed by other teams

Disable Executable Memory Protection

com.apple.security.cs.disable-executable-page-protection

Almost never: removes a core protection

Debugging Tool

com.apple.security.cs.debugger

The app is itself a debugger

Resource Access: Location (and Camera, Audio Input, Contacts, Calendar, Photos Library, Apple Events)

com.apple.security.personal-information.location, com.apple.security.device.camera, …​

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 - -

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

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.

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 with hdiutil verify before 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 (.pkg)

Something must go outside /Applications (a launch agent, a shared framework) or IT deploys it with MDM.

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:

  1. the code signature is valid and was made with a Developer ID certificate that Apple has not revoked;

  2. the app is notarized, either from the stapled ticket or by looking up the ticket online;

  3. 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.

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 codesign --verify --deep --strict --verbose=2.

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 developer-id method, or sign with Developer ID Application; packages with Developer ID Installer.

The signature does not include a secure timestamp.

A custom signing step left out --timestamp. Add it, or add --timestamp to OTHER_CODE_SIGN_FLAGS, and make sure the build machine can reach timestamp.apple.com.

The executable does not have the hardened runtime enabled.

Set ENABLE_HARDENED_RUNTIME = YES for every executable target, or sign manually with --options runtime.

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 CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO for the distribution configuration only.

Embedded entitlements are invalid: syntax error near line 1

The entitlements were a binary plist or had a byte-order mark. Run plutil -convert xml1 and plutil -lint on the file before signing.

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 SKIP_INSTALL = YES on embedded targets.

stapler fails although notarization succeeded

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-notary keychain 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 altool cutoff of November 1, 2023, and the Sequoia Gatekeeper change of August 6, 2024); the upcoming-requirements page lists new ones with their dates.