macOS App Distribution: Code Signing, Notarization, and Auto-Updates
The Part Nobody Warns You About
I spent 24 days building DropVox. The Swift code, the WhisperKit integration, the UI, the license system. It was hard but rewarding work. Then I had to ship it.
The distribution pipeline -- code signing, hardened runtime, notarization, DMG packaging, and auto-updates -- took another full week. Not because the concepts are complex, but because the documentation is scattered, the error messages are cryptic, and the process has dozens of failure points that only surface when you try to do it for real.
This is the guide I wish existed when I started. Every step, in order, with the actual commands and the actual errors you will encounter.
Why Not the App Store?
Before diving in, a reasonable question: why not just submit to the Mac App Store and let Apple handle distribution?
For DropVox, three reasons:
-
App Sandbox restrictions. The App Store requires full sandboxing. DropVox needs audio device access, broad file system access for drag-and-drop from any location, and the ability to download ML models to Application Support. Sandboxing would require extensive entitlement requests and may not be approved.
-
Revenue. Apple takes 30% (15% for small developers under $1M). For a $9.99 app, that is $3 per sale that I would rather keep or pass to users as a lower price.
-
Update speed. App Store review takes 1-7 days. With direct distribution, I push a tag to GitHub and users have the update within minutes.
The trade-off is discovery. The App Store is where people search for apps. Direct distribution means I need to drive all traffic myself. For now, that trade-off is acceptable.
The Chain
Distribution outside the App Store requires a specific sequence of steps. Skip any one and the app will not launch on users' machines. macOS Gatekeeper enforces this chain aggressively.
Code Signing
-> Hardened Runtime
-> Notarization
-> Stapling
-> DMG Packaging
-> Auto-Updates (Sparkle)
Each step depends on the previous one. Let me walk through them.
Step 1: Code Signing
The Right Certificate
Apple offers multiple certificate types. For distribution outside the App Store, you need a Developer ID Application certificate. Not "Mac App Distribution" (that is App Store only). Not "Apple Development" (that is for running on your own machine during development). Specifically Developer ID Application.
You create this in the Apple Developer portal under Certificates, Identifiers & Profiles. It requires an Apple Developer Program membership ($99/year). The certificate gets installed in your Keychain and is valid for 5 years.
Signing Order Matters
DropVox ships as a .app bundle that contains the main binary plus several helper processes. If you have used Electron, the structure will look familiar:
DropVox.app/
Contents/
MacOS/
DropVox # Main binary
Frameworks/
WhisperKit.framework/ # ML framework
Sparkle.framework/ # Auto-update framework
Versions/B/
Sparkle
Autoupdate
Updater.app/
Resources/
...
The critical rule: sign leaf binaries first, then work outward to the app bundle. The codesign --deep flag exists and it is tempting, but it signs in an unpredictable order and frequently produces bundles that fail notarization.
The correct order:
# 1. Sign nested frameworks and helpers (innermost first)
codesign --force --options runtime \
--sign "Developer ID Application: Your Name (TEAMID)" \
--entitlements entitlements.plist \
"DropVox.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate"
codesign --force --options runtime \
--sign "Developer ID Application: Your Name (TEAMID)" \
--entitlements entitlements.plist \
"DropVox.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app"
codesign --force --options runtime \
--sign "Developer ID Application: Your Name (TEAMID)" \
--entitlements entitlements.plist \
"DropVox.app/Contents/Frameworks/Sparkle.framework"
codesign --force --options runtime \
--sign "Developer ID Application: Your Name (TEAMID)" \
--entitlements entitlements.plist \
"DropVox.app/Contents/Frameworks/WhisperKit.framework"
# 2. Sign the main app bundle last
codesign --force --options runtime \
--sign "Developer ID Application: Your Name (TEAMID)" \
--entitlements entitlements.plist \
"DropVox.app"
The --options runtime flag enables Hardened Runtime, which I will cover next. The --entitlements flag applies your entitlements file. Both are required for notarization.
Verifying Signatures
After signing, verify everything is correct:
# Check the signature
codesign --verify --deep --strict --verbose=2 DropVox.app
# Check Gatekeeper acceptance
spctl --assess --type execute --verbose DropVox.app
If spctl reports "accepted," your signing is correct. If it reports "rejected," something in the chain is wrong. The most common cause is an unsigned binary nested inside the bundle that --deep would have caught but your manual signing missed.
Step 2: Hardened Runtime
Hardened Runtime is a security feature that restricts what your app can do at runtime. It disables JIT compilation, unsigned memory allocation, DYLD environment variable injection, and several other capabilities unless you explicitly opt in via entitlements.
Apple requires Hardened Runtime for notarization. There is no way around this.
Entitlements
The entitlements file declares which restricted capabilities your app needs. For DropVox:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Audio input access for future live transcription -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- Network access for license validation and model downloads -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Read/write access to user-selected files (drag-and-drop) -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
Each entitlement you add is a capability your app gets and a security boundary it bypasses. Add only what you need. Apple's notarization service will accept unnecessary entitlements, but it is bad practice and may cause issues if you later submit to the App Store.
Some entitlements that tripped me up:
com.apple.security.cs.allow-jit-- Required if your app uses JIT compilation. Most native apps do not need this. But if you embed JavaScriptCore or a scripting engine, you might.com.apple.security.cs.disable-library-validation-- Required if your app loads third-party frameworks that are not signed with your team ID. Sparkle's framework, for example, is signed with Sparkle's own identity. You need this entitlement, or the hardened runtime will refuse to load it.com.apple.security.cs.allow-unsigned-executable-memory-- Required for some ML runtimes that allocate executable memory. WhisperKit does not need this because CoreML handles memory allocation internally, but other ML frameworks might.
Testing Hardened Runtime Locally
Before submitting for notarization, test the hardened runtime locally:
# Build with hardened runtime
codesign --force --options runtime --sign "Developer ID Application: ..." DropVox.app
# Run the app
open DropVox.app
# Test every feature
# - Transcription (ML model loading)
# - File drag-and-drop
# - License validation (network)
# - Model download (network)
If a feature crashes or silently fails after enabling hardened runtime, you are missing an entitlement. The crash log (in Console.app) will usually mention the missing entitlement by name.
Step 3: Notarization
Notarization is Apple's automated security check. You submit your signed app to Apple's servers, they scan it for malware and verify the code signing chain, and they issue a "ticket" that tells Gatekeeper the app is safe.
The Tool
Apple's current tool is notarytool, which replaced the older altool in Xcode 14. If you find documentation referencing altool, it is outdated.
# Create a ZIP for submission
ditto -c -k --keepParent DropVox.app DropVox.zip
# Submit for notarization
xcrun notarytool submit DropVox.zip \
--apple-id "your@email.com" \
--team-id "TEAMID" \
--password "app-specific-password" \
--wait
The --wait flag blocks until notarization completes. Without it, notarytool returns a submission ID and you poll for status manually.
Timing
Notarization takes anywhere from 2 minutes to 45 minutes. There is no way to predict which you will get. Apple does not publish SLAs for the notarization service. In my experience, most submissions complete in under 10 minutes, but I have had submissions sit for 30+ minutes during what I assume are peak hours.
For CI/CD pipelines, this unpredictability is annoying. I set a 60-minute timeout in GitHub Actions and have never hit it, but the possibility exists.
Common Failures
These are the notarization failures I encountered and how I fixed them:
"The binary is not signed with a valid Developer ID certificate." I was using a development certificate instead of a Developer ID Application certificate. The names are similar. Check which certificate you are using with security find-identity -v -p codesigning.
"The signature of the binary is invalid." A nested binary inside the app bundle was not signed. Usually a helper binary inside a framework. The fix is to sign leaf binaries first, as described above.
"The executable does not have the hardened runtime enabled." Missing the --options runtime flag during code signing. Add it and re-sign.
"The signature does not include a secure timestamp." This happens when signing on a machine with no internet connection or when Apple's timestamp server is down. The --timestamp flag (included by default with --options runtime) requires an internet connection.
Stapling
After notarization succeeds, staple the ticket to the app:
xcrun stapler staple DropVox.app
Stapling embeds the notarization ticket directly in the app bundle. Without stapling, Gatekeeper needs to contact Apple's servers to verify notarization on first launch. If the user is offline, the app will not open. With stapling, the verification works offline.
Always staple. There is no reason not to.
Step 4: DMG Packaging
Users expect a DMG. They download it, open it, drag the app to Applications, and eject. This is the standard macOS installation experience.
create-dmg
I use the create-dmg tool (available via Homebrew) rather than raw hdiutil commands:
create-dmg \
--volname "DropVox" \
--volicon "dmg-icon.icns" \
--window-pos 200 120 \
--window-size 600 400 \
--icon-size 100 \
--icon "DropVox.app" 150 190 \
--app-drop-link 450 190 \
--background "dmg-background.png" \
"DropVox-1.0.0.dmg" \
"build/"
This creates a DMG with a custom background image, the app icon on the left, and an Applications folder alias on the right. The user drags left to right. Clean, standard, expected.
The DMG itself also needs to be signed and notarized:
codesign --force --sign "Developer ID Application: ..." DropVox-1.0.0.dmg
xcrun notarytool submit DropVox-1.0.0.dmg --apple-id ... --wait
xcrun stapler staple DropVox-1.0.0.dmg
Yes, you notarize both the app and the DMG. The app notarization covers the app itself. The DMG notarization covers the container. Belt and suspenders.
Step 5: Auto-Updates with Sparkle
Once users install your app, they need a way to get updates. Without the App Store handling this, you need your own update mechanism.
Sparkle is the de facto standard for macOS app auto-updates outside the App Store. It is an open-source framework used by Firefox, VLC, Sublime Text, and thousands of other macOS apps.
Integration
Sparkle integrates via Swift Package Manager or as a framework bundle. I use SPM:
// Package.swift
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.5.0")
]
In the app, you initialize the updater and configure it to check for updates:
import Sparkle
class AppDelegate: NSObject, NSApplicationDelegate {
let updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
func applicationDidFinishLaunching(_ notification: Notification) {
// Sparkle checks for updates automatically based on user preferences
// Manual check can be triggered via menu item
}
}
The Appcast
Sparkle uses an XML feed called an appcast to discover new versions. I host mine on GitHub alongside the releases:
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>DropVox Updates</title>
<item>
<title>Version 1.0.1</title>
<sparkle:version>1.0.1</sparkle:version>
<sparkle:shortVersionString>1.0.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<pubDate>Mon, 17 Feb 2026 12:00:00 +0000</pubDate>
<enclosure
url="https://github.com/helrabelo/dropvox/releases/download/v1.0.1/DropVox-1.0.1.dmg"
sparkle:edSignature="BASE64_SIGNATURE_HERE"
length="15728640"
type="application/octet-stream" />
<description><![CDATA[
<ul>
<li>Bug fixes and performance improvements</li>
</ul>
]]></description>
</item>
</channel>
</rss>
EdDSA Signing
Sparkle 2.x uses EdDSA (Ed25519) signatures for update verification. During setup, you generate a key pair:
# Generate EdDSA key pair (do this once, store the private key securely)
./bin/generate_keys
This outputs a public key (goes in your app's Info.plist) and a private key (goes in your CI secrets, never committed to the repo). Every update DMG is signed with the private key, and Sparkle verifies it with the public key before installing.
This prevents man-in-the-middle attacks where someone replaces your update with malware. The user's installed copy of DropVox has the public key baked in, so even if someone compromises the download server, they cannot produce a valid signature without the private key.
The User Experience
When Sparkle detects a new version, it shows a native macOS dialog: "A new version of DropVox is available." The user clicks "Install Update," Sparkle downloads the DMG, verifies the signature, replaces the app, and relaunches. The entire process takes seconds.
For a menu bar app, this seamless update experience is critical. Users do not think about menu bar apps. They install them and forget they exist. If updating requires manual intervention, many users will simply never update.
The GitHub Actions Pipeline
All of this -- build, sign, notarize, package, publish -- runs in a GitHub Actions workflow triggered by version tags.
name: Release macOS
on:
push:
tags: ['v*']
jobs:
build-and-release:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Import certificates
env:
CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERT_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
run: |
echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.p12
security create-keychain -p "" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "" build.keychain
security import certificate.p12 -k build.keychain \
-P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: \
-s -k "" build.keychain
- name: Build
run: |
xcodebuild -scheme DropVox \
-configuration Release \
-archivePath build/DropVox.xcarchive \
archive
- name: Export
run: |
xcodebuild -exportArchive \
-archivePath build/DropVox.xcarchive \
-exportPath build/export \
-exportOptionsPlist ExportOptions.plist
- name: Sign nested binaries
run: |
# Sign frameworks and helpers in correct order
# (script handles the leaf-first ordering)
./scripts/sign-app.sh build/export/DropVox.app
- name: Notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NOTARIZATION_PASSWORD: ${{ secrets.NOTARIZATION_PASSWORD }}
run: |
ditto -c -k --keepParent build/export/DropVox.app DropVox.zip
xcrun notarytool submit DropVox.zip \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$NOTARIZATION_PASSWORD" \
--wait
xcrun stapler staple build/export/DropVox.app
- name: Create DMG
run: |
brew install create-dmg
create-dmg \
--volname "DropVox" \
--window-size 600 400 \
--icon "DropVox.app" 150 190 \
--app-drop-link 450 190 \
"DropVox-${{ github.ref_name }}.dmg" \
"build/export/"
- name: Sign and notarize DMG
run: |
codesign --force --sign "Developer ID Application: ..." \
"DropVox-${{ github.ref_name }}.dmg"
xcrun notarytool submit "DropVox-${{ github.ref_name }}.dmg" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$NOTARIZATION_PASSWORD" \
--wait
xcrun stapler staple "DropVox-${{ github.ref_name }}.dmg"
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: DropVox-${{ github.ref_name }}.dmg
generate_release_notes: true
Secrets Management
The pipeline needs several secrets stored in GitHub:
DEVELOPER_ID_CERT_BASE64-- The .p12 certificate, base64 encodedDEVELOPER_ID_CERT_PASSWORD-- The password for the .p12 fileAPPLE_ID-- Your Apple ID emailAPPLE_TEAM_ID-- Your 10-character team identifierNOTARIZATION_PASSWORD-- An app-specific password generated at appleid.apple.comSPARKLE_PRIVATE_KEY-- The EdDSA private key for signing updates
The certificate needs special handling. You export it from Keychain Access as a .p12 file, base64 encode it, and store the encoded string as a secret. The workflow decodes it back and imports it into a temporary keychain on the runner.
This is the most fragile part of the pipeline. If the certificate expires, if the app-specific password is revoked, or if Apple changes the notarization API, the pipeline breaks silently. I check the expiration dates quarterly.
For Electron Apps
I also went through this process for Falavra, an Electron app. The good news: electron-builder handles most of the signing and packaging automatically. The bad news: notarization still requires the same Apple dance.
electron-builder's afterSign hook runs notarytool with the same credentials. The DMG creation is built in. The auto-update mechanism uses electron-updater instead of Sparkle, but the concept is identical -- a feed of versions hosted somewhere, signature verification, and seamless installation.
The main difference is that Electron apps have more binaries to sign. The Electron framework includes helper processes (Helper, Helper (GPU), Helper (Renderer), Helper (Plugin)), each of which needs individual signing. electron-builder handles this, but if something goes wrong, debugging the signing order of 20+ binaries is significantly more painful than debugging a native app with 3-4 binaries.
The Honest Take
This is the most annoying part of shipping macOS software. It is not intellectually stimulating. It does not make the product better. It is bureaucratic compliance work that exists because Apple decided it should exist.
But it is also non-negotiable. Without notarization, your app shows a scary "Apple cannot check it for malicious software" dialog on first launch. Most users will not click through that. Some will drag it to the trash immediately. A few will Google what to do and find instructions involving right-clicking and selecting Open, which makes your app feel sketchy.
Proper signing and notarization means the app launches cleanly, like any app from a trusted developer. That trust signal is worth the one-time pain of setting up the pipeline.
My advice: budget a full week for distribution the first time you do this. Once the pipeline is working, subsequent releases are automatic. Tag, push, wait, done. But getting to that first working pipeline will test your patience in ways that writing code never does.
If you are shipping a macOS app outside the App Store and hitting cryptic notarization errors at 2 AM, I have been there. Reach out on LinkedIn or check helrabelo.dev for more posts about indie macOS development.