Shipping a .app directly to users — bypassing the App Store — means archive, codesign, notarize, staple, package. The recipe is small. The traps are not.
Apple's notarization docs cover the happy path. They do not cover the three things that actually go wrong:
/Volumes write access for unsandboxed processes without Full Disk Access. Every DMG tool that mounts read-write at /Volumes/MyApp/ and tries to copy files in now fails. The error surfaces as "internal error in Code Signing subsystem" mid-build — nothing about TCC, nothing about disks. You will chase your signing identity for an hour before finding it.errSecInternalComponent from codesign means the host process can't read the Keychain. Not a corrupt certificate, not a missing private key — your terminal (or LingCode, or whatever is running codesign) lacks Full Disk Access. The error message is identical to half a dozen other failure modes, so the diagnosis is non-obvious.sign_update tool disappears whenever DerivedData is cleaned. Xcode rebuilds it on next archive, but if your release script runs before the rebuild it dies on a missing binary. The fix isn't to wait — it's to grab a prebuilt sign_update from the matching Sparkle GitHub release.LingCode runs this whole pipeline end to end. The steps below show what it does and how to recognize each failure the moment it surfaces.
developer.apple.com > Certificates. Distinct from "Apple Development" (used for Xcode debug builds) and "Apple Distribution" (App Store).appleid.apple.com > Sign-In and Security > App-Specific Passwords. Don't use your Apple ID password directly.developer.apple.com/account. notarytool needs it explicitly.Store the notarytool credentials in the Keychain once so you don't paste secrets into every release:
xcrun notarytool store-credentials "AC_PASSWORD" \
--apple-id "[email protected]" \
--team-id "ABCDE12345" \
--password "xxxx-xxxx-xxxx-xxxx"
From here on every notarytool command refers to --keychain-profile "AC_PASSWORD", never the raw password.
Ask LingCode to archive your app from the command line. The archive is a self-contained build product with debug symbols — what Xcode's Organizer would produce, just scripted:
xcodebuild archive \
-project MyApp.xcodeproj \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive \
-destination "generic/platform=macOS" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM=ABCDE12345 \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name (ABCDE12345)"
Manual signing here is deliberate — automatic signing in non-interactive scripts is the first thing that breaks in CI and the first thing to surface confusing keychain prompts. Pin the identity and team ID and the build is reproducible.
security find-identity -v -p codesigning lists everything available; if your cert is missing, re-download it from developer.apple.com.
Export the archive into a plain .app bundle. This is where the export options plist matters — direct distribution uses developer-id, App Store uses app-store:
# ExportOptions.plist
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>teamID</key>
<string>ABCDE12345</string>
<key>signingStyle</key>
<string>manual</string>
</dict>
</plist>
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build/export \
-exportOptionsPlist ExportOptions.plist
After this, build/export/MyApp.app exists and is already signed by Xcode's export step. The next phase re-signs anything Xcode didn't sign — embedded helpers, frameworks, bundled binaries.
If your app bundles helper binaries (a CLI, a Node runtime, a Sparkle updater, a Python interpreter), they need to be signed individually before the outer bundle is signed. The order matters — sign inside-out:
# Sign each embedded binary first
codesign --force --options runtime --timestamp \
--sign "Developer ID Application: Your Name (ABCDE12345)" \
--entitlements MyApp.entitlements \
"build/export/MyApp.app/Contents/Resources/bin/node"
codesign --force --options runtime --timestamp \
--sign "Developer ID Application: Your Name (ABCDE12345)" \
--entitlements MyApp.entitlements \
"build/export/MyApp.app/Contents/Resources/bin/mycli"
# Then re-sign the outer app bundle
codesign --force --options runtime --timestamp --deep \
--sign "Developer ID Application: Your Name (ABCDE12345)" \
--entitlements MyApp.entitlements \
"build/export/MyApp.app"
# Verify
codesign --verify --deep --strict --verbose=2 "build/export/MyApp.app"
spctl --assess --type execute --verbose "build/export/MyApp.app"
--options runtime enables Hardened Runtime. Without it, notarization rejects the build. --timestamp attaches a secure timestamp from Apple's server — also required.
errSecInternalComponent trap. If codesign fails with "errSecInternalComponent", your terminal (or the process running codesign) doesn't have Full Disk Access. macOS blocks Keychain reads to private key material without it. Fix: System Settings > Privacy & Security > Full Disk Access > add Terminal.app (or whichever host process you're running from). Restart the terminal session before retrying. This is also why running ship scripts from inside another sandboxed host can fail mysteriously — run from a real Terminal.app window.
Notarization runs server-side at Apple. You zip the signed app, upload, and wait. The --wait flag blocks until the result comes back — usually 1–5 minutes, sometimes 30+:
# Zip the signed app
ditto -c -k --keepParent "build/export/MyApp.app" "build/MyApp.zip"
# Submit and block until done
xcrun notarytool submit "build/MyApp.zip" \
--keychain-profile "AC_PASSWORD" \
--wait
On success the output reads status: Accepted with a UUID. If it says Invalid, pull the log:
xcrun notarytool log <submission-uuid> \
--keychain-profile "AC_PASSWORD" \
notarization-log.json
The JSON pinpoints which binary failed and why. Common causes: unsigned embedded helper (re-do Phase 3), missing Hardened Runtime flag, missing secure timestamp, or use of a private API in a third-party framework.
Stapling attaches the notarization ticket to the app bundle so Gatekeeper can verify it offline (first launch on a user's Mac doesn't need network):
xcrun stapler staple "build/export/MyApp.app"
xcrun stapler validate "build/export/MyApp.app"
The bundle is now ready to distribute. You can hand the .app directly to a user, but for a polished install you'll want it inside a DMG.
The naïve DMG recipe: hdiutil create a read-write image, mount at /Volumes/MyApp/, copy the app in, set the volume icon, unmount, convert to compressed read-only. This works on macOS 14 and earlier.
On macOS 26.2+, mounting at /Volumes/ as an unsandboxed script without Full Disk Access fails silently or returns mid-pipeline errors that look like signing failures. The fix is to mount inside /private/tmp/ instead, which TCC doesn't gate:
# Stage layout in a writable scratch dir
STAGING="$(mktemp -d)/MyApp-staging"
mkdir -p "$STAGING"
cp -R "build/export/MyApp.app" "$STAGING/"
ln -s /Applications "$STAGING/Applications"
# Build the DMG via /private/tmp, not /Volumes
hdiutil create -volname "MyApp" \
-srcfolder "$STAGING" \
-ov -format UDZO \
"build/MyApp.dmg"
# Sign the DMG itself
codesign --force --timestamp \
--sign "Developer ID Application: Your Name (ABCDE12345)" \
"build/MyApp.dmg"
# Notarize and staple the DMG (separately from the app)
xcrun notarytool submit "build/MyApp.dmg" \
--keychain-profile "AC_PASSWORD" --wait
xcrun stapler staple "build/MyApp.dmg"
The DMG needs its own notarization round-trip. Apple treats the disk image as a separately distributable artifact and Gatekeeper will block download-then-mount if the DMG itself isn't notarized.
hdiutil create reports "Resource busy" or "hdiutil: create failed" on macOS 26.2+: it's the TCC /Volumes block. Either grant your terminal Full Disk Access, or — better — keep the staging path inside /private/tmp/ and never touch /Volumes/ directly.
If your app uses Sparkle for auto-updates, your release script signs each DMG with an EdDSA key and appends the signature to your appcast. The tool is sign_update, built from the Sparkle source:
sign_update build/MyApp.dmg
# Outputs: sparkle:edSignature="…" length="…"
The trap: sign_update lives in DerivedData (Xcode builds it on demand from the Sparkle Swift package). Any rm -rf ~/Library/Developer/Xcode/DerivedData wipes it. Your release script then fails on a missing binary, and the obvious fix — "rebuild the project" — costs 10+ minutes per attempt because Xcode has to re-resolve packages.
The faster fix: grab the prebuilt sign_update from the matching Sparkle GitHub release. Sparkle ships a tarball at https://github.com/sparkle-project/Sparkle/releases/ that contains bin/sign_update — just check it into your repo or download it from your release script:
# In your release script, before signing
if ! command -v sign_update >/dev/null; then
SPARKLE_VERSION="2.6.4" # whatever matches your project
curl -fsSL "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" \
| tar -xJ -C /tmp/sparkle
export PATH="/tmp/sparkle/bin:$PATH"
fi
sign_update build/MyApp.dmg
This decouples your release pipeline from Xcode's DerivedData state. A fresh checkout on a fresh machine ships in minutes, not an Xcode rebuild later.
When something fails mid-pipeline, walk the ladder top-to-bottom — each rung is faster to check than the next:
security find-identity -v -p codesigning — is the Developer ID Application cert present and unexpired? If not, the rest of the pipeline can't start.codesign --verify --deep --strict --verbose=2 MyApp.app — is the bundle internally consistent? Mismatches between embedded helper signatures and the outer bundle surface here.spctl --assess --type execute --verbose MyApp.app — would Gatekeeper accept this bundle right now? Pre-notarization this says "rejected"; post-staple it should say "accepted source=Notarized Developer ID."xcrun notarytool log <uuid> … — Apple's authoritative answer for any notarization rejection. Don't guess; pull the log.stapler validate MyApp.app — is the notarization ticket actually attached? If you skip stapling, first-launch on a fresh machine without network silently blocks.errSecInternalComponent and /Volumes write failures both come from missing FDA.Drop this skill into LingCode's skills folder and ask LingCode for "codesign and notarize this Mac app" to run the whole pipeline:
---
name: mac-codesign-and-notarize
description: Use when shipping a macOS app outside the App Store — codesign, notarize, staple, DMG creation, and Sparkle EdDSA signing. Triggers: 'codesign fails', 'notarization rejected', 'errSecInternalComponent', 'internal error in Code Signing subsystem', 'sign_update not found', 'DMG mount fails', /Volumes write blocked, Sparkle EdDSA issue, hardened runtime error. Actions: archive, export with developer-id method, codesign with --options runtime + --timestamp, notarytool submit --wait, stapler staple, hdiutil create DMG, sign Sparkle update. Errors: errSecInternalComponent (FDA missing), macOS 26.2+ TCC /Volumes trap, missing sign_update after DerivedData clean. Skip if: App Store submission (use ios-signing-and-app-store) or simulator builds.
---
Codesign, notarize, and staple a Mac app for direct distribution.
Phase 1 — Archive: xcodebuild archive with manual signing,
Developer ID Application identity pinned.
Phase 2 — Export: xcodebuild -exportArchive with developer-id
method in ExportOptions.plist.
Phase 3 — Codesign: sign embedded helpers inside-out before the
outer bundle. --options runtime + --timestamp required. If
codesign reports errSecInternalComponent, the host process is
missing Full Disk Access — fix in System Settings first.
Phase 4 — Notarize: ditto into a zip, notarytool submit --wait
with --keychain-profile. On Invalid, pull notarytool log for the
exact rejected binary.
Phase 5 — Staple: stapler staple, then stapler validate.
DMG step: stage inside /private/tmp (NOT /Volumes — macOS 26.2+
TCC blocks /Volumes writes without FDA). hdiutil create -format
UDZO, codesign the DMG, notarize and staple separately.
Sparkle sign_update: if missing after DerivedData clean, fetch
the prebuilt binary from the matching Sparkle GitHub release
rather than waiting for an Xcode rebuild.
Diagnostic order: security find-identity → codesign --verify →
spctl --assess → notarytool log → stapler validate → FDA check.
Save as ~/.lingcode/skills/mac-codesign-and-notarize/SKILL.md — see Install a skill for the exact location and how skills get discovered.