Building and Shipping Mac and iOS Apps Without Ever Opening Xcode
- Headless by design: Xcode must be installed but never opened. All tools (
xcodebuild,notarytool,stapler,devicectl) live insideXcode.appand run from a shell. A one-time credential setup (Apple ID sign-in, Developer ID certificate, notarytool keychain profile) makes every subsequent build and deploy fully headless. - Project management:
XcodeGenreplaces the.xcodeprojwith aproject.ymlcommitted to git, regenerating the project on every build so the binary folder is never in version control. - One-command release:
scripts/release.shruns the entire chain — archive, Developer ID sign, notarize, staple, verify, install to/Applications. - Two build paths:
CODE_SIGNING_ALLOWED=NOfor fast unsigned builds (local/CI).release.shfor shipping with Developer ID signing and full notarization. Zero secrets in the repo — the private key stays in your login keychain, the notary credential in a keychain profile. - iOS too:
xcodebuild archive+xcrun devicectl device installdeploys to a connected iPhone without the GUI. - Agent-native: A
CLAUDE.mdtells your LLM coding tool the conventions — build paths, notary profile naming, scheme names. “Ship a build” becomes a single sentence. - One interactive step:
notarytool store-credentialsrequires typing an app-specific password once. Everything after that is scriptable end to end.
This post is a condensed summary of Scott Willsey’s detailed guide. All credit for the original workflow, the release script pattern, and the CLAUDE.md conventions goes to him.
Process Flow
flowchart TD
Setup["One-Time Credential Setup"]
DevFast["Unsigned Build (Dev/CI)"]
Release["Release Pipeline"]
iOSPath["iOS Device Deploy"]
Setup --> DevFast
Setup --> Release
Setup --> iOSPath
DevFast --> Gen["xcodegen generate"]
DevFast --> Test["swift test"]
DevFast --> AdHoc["xcodebuild
CODE_SIGNING_ALLOWED=NO"] Release --> Arch["1. Archive
xcodebuild archive"] Arch --> Exp["2. Developer ID Export
xcodebuild -exportArchive"] Exp --> Not["3. Notarize
notarytool submit --wait"] Not --> Sta["4. Staple Ticket
stapler staple"] Sta --> Ver["5. Verify
spctl / stapler validate"] Ver --> Inst["6. Install to /Applications
cp -R"] iOSPath --> iOSArch["xcodebuild archive
-destination generic/platform=iOS"] iOSArch --> iOSInst["devicectl device install"] click Setup "#one-time-setup" "Go to One-Time Setup commands" click DevFast "#build--unsigned-quick-checks" "Go to unsigned build commands" click Gen "#build--unsigned-quick-checks" "Go to xcodegen generate" click AdHoc "#build--unsigned-quick-checks" "Go to unsigned build commands" click Release "#release-pipeline-mac" "Go to Release Pipeline commands" click Arch "#archive--export-manual-steps" "Go to archive/export commands" click Exp "#archive--export-manual-steps" "Go to archive/export commands" click Not "#notarization--stapling" "Go to notarization commands" click Sta "#notarization--stapling" "Go to notarization commands" click Ver "#verification" "Go to verification commands" click Inst "#release-pipeline-mac" "Go to Release Pipeline commands" click iOSPath "#ios-device-deployment" "Go to iOS deployment commands" click iOSArch "#ios-device-deployment" "Go to iOS deployment commands" click iOSInst "#ios-device-deployment" "Go to iOS deployment commands" style Setup fill:#1e293b,color:#fff,stroke:#475569 style DevFast fill:#1e3a5f,color:#fff,stroke:#2563eb style Release fill:#3b1f3b,color:#fff,stroke:#7e22ce style iOSPath fill:#1a3a2a,color:#fff,stroke:#059669
CODE_SIGNING_ALLOWED=NO"] Release --> Arch["1. Archive
xcodebuild archive"] Arch --> Exp["2. Developer ID Export
xcodebuild -exportArchive"] Exp --> Not["3. Notarize
notarytool submit --wait"] Not --> Sta["4. Staple Ticket
stapler staple"] Sta --> Ver["5. Verify
spctl / stapler validate"] Ver --> Inst["6. Install to /Applications
cp -R"] iOSPath --> iOSArch["xcodebuild archive
-destination generic/platform=iOS"] iOSArch --> iOSInst["devicectl device install"] click Setup "#one-time-setup" "Go to One-Time Setup commands" click DevFast "#build--unsigned-quick-checks" "Go to unsigned build commands" click Gen "#build--unsigned-quick-checks" "Go to xcodegen generate" click AdHoc "#build--unsigned-quick-checks" "Go to unsigned build commands" click Release "#release-pipeline-mac" "Go to Release Pipeline commands" click Arch "#archive--export-manual-steps" "Go to archive/export commands" click Exp "#archive--export-manual-steps" "Go to archive/export commands" click Not "#notarization--stapling" "Go to notarization commands" click Sta "#notarization--stapling" "Go to notarization commands" click Ver "#verification" "Go to verification commands" click Inst "#release-pipeline-mac" "Go to Release Pipeline commands" click iOSPath "#ios-device-deployment" "Go to iOS deployment commands" click iOSArch "#ios-device-deployment" "Go to iOS deployment commands" click iOSInst "#ios-device-deployment" "Go to iOS deployment commands" style Setup fill:#1e293b,color:#fff,stroke:#475569 style DevFast fill:#1e3a5f,color:#fff,stroke:#2563eb style Release fill:#3b1f3b,color:#fff,stroke:#7e22ce style iOSPath fill:#1a3a2a,color:#fff,stroke:#059669
Click any node in the diagram to jump to its corresponding command table.
Command Reference
One-Time Setup
| Command | Purpose |
|---|---|
xcode-select -p |
Check which toolchain is active |
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer |
Switch to Xcode’s full toolchain |
brew install xcodegen |
Install XcodeGen for project generation |
sudo xcodebuild -license accept |
Accept Xcode license agreement |
sudo xcodebuild -runFirstLaunch |
Install Xcode additional components |
xcrun notarytool store-credentials <App-Name> --apple-id "<email>" --team-id <TEAM-ID> |
Store notarization credential (prompts for app-specific password) |
xcrun notarytool history --keychain-profile <App-Name> |
Verify notarization credential is stored |
cp Local.xcconfig.example Local.xcconfig |
Create local config file for team ID and bundle prefix |
Build — Unsigned (Quick Checks)
| Command | Purpose |
|---|---|
swift test |
Run unit tests (SPM, no Xcode build) |
xcodegen generate |
Regenerate .xcodeproj from project.yml |
xcodebuild -project <App>.xcodeproj -scheme <App>-macOS -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build |
Compile macOS app (ad-hoc, unsigned) |
xcodebuild -project <App>.xcodeproj -scheme <App>-iOS -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build |
Compile iOS app for Simulator (unsigned) |
Release Pipeline (Mac)
| Command | Purpose |
|---|---|
./scripts/release.sh |
Full chain: archive → Developer ID sign → notarize → staple → install to /Applications |
<APP>_NOTARY_PROFILE=<name> ./scripts/release.sh |
Override the notary profile for a different identity |
Archive & Export (Manual Steps)
| Command | Purpose |
|---|---|
xcodebuild -project <App>.xcodeproj -scheme <App>-macOS -configuration Release -derivedDataPath build/derived -archivePath build/<App>.xcarchive -allowProvisioningUpdates archive |
Archive the macOS app |
xcodebuild -exportArchive -archivePath build/<App>.xcarchive -exportPath build/Export -exportOptionsPlist build/ExportOptions.plist -allowProvisioningUpdates |
Export Developer ID-signed .app from archive |
Notarization & Stapling
| Command | Purpose |
|---|---|
ditto -c -k --keepParent <App>.app notarize.zip |
Package app into zip for upload |
xcrun notarytool submit notarize.zip --keychain-profile <App-Name> --wait |
Submit for notarization (blocks until complete) |
xcrun stapler staple <App>.app |
Attach notarization ticket to the app bundle |
xcrun stapler validate <App>.app |
Verify ticket is attached |
Verification
| Command | Purpose |
|---|---|
codesign -dv --verbose=4 /Applications/<App>.app |
Show signing identity and certificate chain |
spctl -a -vvv -t exec /Applications/<App>.app |
Check Gatekeeper acceptance |
xcrun stapler validate /Applications/<App>.app |
Validate notarization ticket on installed app |
iOS Device Deployment
| Command | Purpose |
|---|---|
xcodebuild -project <App>.xcodeproj -scheme <App>-iOS -destination 'generic/platform=iOS' -allowProvisioningUpdates -derivedDataPath build/ios archive -archivePath build/<App>-iOS.xcarchive |
Archive iOS app for device deployment |
xcrun devicectl list devices |
List connected devices and their UDIDs |
xcrun devicectl device install app --device <UDID> path/to/<App>.app |
Install .app onto a connected iPhone |