macOS App Distribution: Code Signing, Notarization, and Auto-Updates

15 min read
macoscode-signingnotarizationsparkledistributionindie-devdevops

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:

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

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

  3. 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 encoded
  • DEVELOPER_ID_CERT_PASSWORD -- The password for the .p12 file
  • APPLE_ID -- Your Apple ID email
  • APPLE_TEAM_ID -- Your 10-character team identifier
  • NOTARIZATION_PASSWORD -- An app-specific password generated at appleid.apple.com
  • SPARKLE_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.