Build and Ship Apple Apps from the Command Line


Apple development is often treated as inseparable from the Xcode interface. Create a project in a window, click a scheme, press a build button, open Organizer, choose an archive, and work through a signing wizard. That is the familiar path, but it is not the only path.

Almost every important action underneath that interface already has a command-line form. Xcode still needs to be installed because it supplies the SDKs, compilers, simulators, device services, and distribution tools. After a small amount of account and certificate setup, however, the daily loop can live in a terminal:

  • define the project in a text file;
  • regenerate the Xcode project when its structure changes;
  • test and compile with swift and xcodebuild;
  • archive, sign, and export a macOS app;
  • submit it to Apple’s notary service and staple the returned ticket;
  • install an iOS build on a connected device with devicectl.

This is useful for humans who prefer reproducible scripts. It is even more useful for coding agents, which are far more reliable when a repository exposes explicit commands instead of an undocumented sequence of clicks.

The Important Distinction: Xcode the App vs. Xcode the Toolchain

There are two products hiding under one name.

The first is the Xcode application: the editor, inspectors, project settings, visual debuggers, simulators, Organizer, and account screens. The second is the developer directory inside Xcode.app, which contains the build system and platform tools.

A complete Apple toolchain includes more than the standalone Command Line Tools package. The smaller package provides basics such as Clang and Git, but it does not contain the complete iOS SDK or every utility needed for device deployment and notarization. For native iPhone development and a full Mac distribution pipeline, install Xcode and select its developer directory:

xcode-select -p
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch

When Xcode uses its default name and location, the first command should eventually print /Applications/Xcode.app/Contents/Developer. The second command selects that developer directory; the last two accept the license and install required components. Once that foundation is in place, xcodebuild, xcrun, notarytool, stapler, and devicectl can run without launching the editor.

Calling this a “no-Xcode” workflow would therefore be misleading. It is a no-Xcode-GUI workflow after initial setup. You still depend on Apple’s toolchain and must update it when new SDKs or platform requirements arrive.

Put the Project Definition in Git

An .xcodeproj looks like a file in Finder, but it is a directory containing a large project description. Xcode modifies that description as files, targets, build settings, and schemes change. The format works, but it is not pleasant to edit by hand and can produce noisy merge conflicts.

XcodeGen moves the source of truth into a YAML or JSON specification. A project.yml can declare targets, platforms, source directories, dependencies, build settings, schemes, entitlements, and configurations. Running one command recreates the generated project:

brew install xcodegen
xcodegen generate

That changes the maintenance model. Instead of asking a person or agent to manipulate an opaque project bundle, the repository keeps a readable specification and treats the .xcodeproj as generated output. The project can be regenerated on every build, omitted from version control, or checked for drift in continuous integration.

XcodeGen is a choice, not a law. Modern Xcode file-system-synchronized groups reduce some of the file-reference pain, while Tuist, Swift Package Manager, and Fastlane solve overlapping parts of the problem. Complex projects with extensions, multiple platforms, custom build phases, or legacy settings may prefer another tool or keep the project file committed. The principle is more important than the specific generator: make project structure deterministic and reviewable.

Separate the Fast Loop from the Release Loop

One command should not try to serve every purpose. A fast local check has different requirements from software that will be handed to another user.

For code organized as a Swift package, the cheapest test loop may be:

swift test

A macOS target can be compiled without distribution signing:

xcodegen generate
xcodebuild \
  -project Example.xcodeproj \
  -scheme Example-macOS \
  -destination 'platform=macOS' \
  CODE_SIGNING_ALLOWED=NO \
  build

An iOS simulator build follows the same pattern with a simulator destination:

xcodebuild \
  -project Example.xcodeproj \
  -scheme Example-iOS \
  -destination 'generic/platform=iOS Simulator' \
  CODE_SIGNING_ALLOWED=NO \
  build

These commands answer the quick questions: does the project generate, does the code compile, and do the unit tests pass? They avoid certificate and provisioning work that is unnecessary for a simulator or a continuous-integration sanity check.

An unsigned or ad-hoc build is not a release. Gatekeeper will not treat it as a normally distributed Mac app, and capabilities whose entitlements depend on a real team identity may not work. The release loop must archive and export with the correct identity.

Keeping these paths separate makes failures easier to understand. A compile failure belongs to the code or project. A later signing failure belongs to identities, profiles, entitlements, or export settings. Mixing them into one opaque command turns every problem into “Xcode failed.”

Signing Starts with a Private Key

The graphical signing screen can make code signing feel like remote Apple magic. The core mechanism is simpler: a private key in a keychain signs the product, and a certificate connects the corresponding public key to an Apple developer identity.

For direct distribution of a Mac application outside the App Store, Apple documents the Developer ID Application certificate. It is distinct from the Apple Development certificate used for local development and device testing. Creating an identity from a certificate-signing request leaves the locally generated private key in the keychain. Moving that identity to another machine requires importing an exported identity such as a password-protected .p12; importing Apple’s .cer certificate by itself does not include the private key.

That private key deserves the same care as any release credential. A certificate without the corresponding private key cannot sign with that identity. Back up the identity securely, limit access to the build environment, and do not place exported key material in the repository.

Project-specific values such as the development team and bundle identifier prefix can live in a local configuration file:

// Local.xcconfig
DEVELOPMENT_TEAM = ABC1234567
BUNDLE_PREFIX = com.example

Commit a safe example, add the real Local.xcconfig to .gitignore, and include it from the generated project settings. This gives each developer or build machine a clear setup point without baking personal identifiers into a reusable template.

Signing secrets should remain outside Git as well. The login keychain can hold the signing key. notarytool can keep its authentication profile in the keychain. A CI system can inject a temporary keychain and App Store Connect credentials at runtime. The repository needs names and commands, not raw secrets.

A Mac Release Is a Pipeline, Not a Button

For a directly distributed macOS app, a dependable release script should express each stage separately:

  1. Regenerate the project.
  2. Archive a Release build.
  3. Export the archive using Developer ID signing.
  4. Package the signed app for notarization.
  5. Submit it to Apple’s notary service and wait for a result.
  6. Staple the notarization ticket to the app.
  7. Verify the signature, ticket, and Gatekeeper assessment.
  8. Copy the verified app to its final location or distribution package.

The archive and export stages are handled by xcodebuild:

xcodebuild \
  -project Example.xcodeproj \
  -scheme Example-macOS \
  -configuration Release \
  -archivePath build/Example.xcarchive \
  -allowProvisioningUpdates \
  archive

xcodebuild \
  -exportArchive \
  -archivePath build/Example.xcarchive \
  -exportPath build/export \
  -exportOptionsPlist ExportOptions.plist \
  -allowProvisioningUpdates

The export options identify the distribution method, team, and signing style. Keep that file in version control when it contains no secret data. Build it from a template when environments need different values.

The script should use strict shell behavior such as set -euo pipefail, validate prerequisites before a long archive, and fail at the first broken stage. It should also remove or isolate old build output so yesterday’s .app cannot be mistaken for today’s successful export.

This is where a short custom script can be better than a long checklist. The sequence becomes executable documentation. A developer, CI runner, or coding agent invokes the same entry point and receives the same checks.

Notarization and Signing Solve Different Problems

Signing establishes who produced an app and whether its contents changed after signing. Notarization sends the signed software to an automated Apple service that scans for malicious content and code-signing problems. On success, Apple issues a ticket. Stapling attaches that ticket to the deliverable so Gatekeeper can validate it even when it cannot retrieve the ticket online.

Apple’s current command-line path uses notarytool; the older altool upload flow is no longer accepted. Store credentials once in a named keychain profile:

xcrun notarytool store-credentials ExampleNotary \
  --apple-id "developer@example.com" \
  --team-id ABC1234567

The command prompts for an app-specific password. Avoid putting that password on a command line where it can leak into shell history or process listings.

For the release itself, package the signed app, submit it, and staple the ticket:

ditto -c -k --keepParent \
  build/export/Example.app \
  build/Example-notarization.zip

xcrun notarytool submit \
  build/Example-notarization.zip \
  --keychain-profile ExampleNotary \
  --wait

xcrun stapler staple build/export/Example.app

Do not treat a successful submission as the final check. Validate the artifact that users will actually receive:

codesign --verify --deep --strict --verbose=2 build/export/Example.app
xcrun stapler validate build/export/Example.app
spctl --assess --verbose=4 --type execute build/export/Example.app

Apple’s notarization requirements also include a valid Developer ID signature, hardened runtime, a secure timestamp, and suitable entitlements. In particular, a shipping app should not accidentally retain the debugger-oriented com.apple.security.get-task-allow entitlement. A headless pipeline does not remove these rules; it makes them visible and testable.

Installing on a Real iPhone Without the Run Button

iOS device deployment uses a different identity and does not use the Mac notarization service. A development build for a physical iPhone must be signed with an Apple Development identity and an appropriate provisioning profile.

First archive for the device platform:

xcodebuild \
  -project Example.xcodeproj \
  -scheme Example-iOS \
  -destination 'generic/platform=iOS' \
  -allowProvisioningUpdates \
  -derivedDataPath build/ios \
  -archivePath build/Example-iOS.xcarchive \
  archive

Then list paired devices and install the built application:

xcrun devicectl list devices
xcrun devicectl device install app \
  --device DEVICE-UDID \
  /path/to/Example.app

This is a development and testing path, not an App Store release workflow. TestFlight and App Store distribution add App Store Connect records, distribution signing, metadata, review, and upload steps. Those can also be automated, often with Fastlane or an App Store Connect CLI, but they are a separate system and should not be implied by a local USB install.

Give the Repository an Operator’s Manual

Automation becomes much more valuable when the repository explains how to use it. A short AGENTS.md, CLAUDE.md, or contributor guide should name the canonical commands and the boundaries between them:

## Validation

- Run `swift test` for package tests.
- Run `xcodegen generate` after changing project structure.
- Run `./scripts/build-macos.sh` for an unsigned compile check.
- Run `./scripts/release-macos.sh` only for a signed, notarized release.

## Credentials

- Never commit `Local.xcconfig`, certificates, private keys, or passwords.
- The release script expects the `ExampleNotary` keychain profile.

That file is not special because an agent reads it. It is useful because it captures the contract of the build system. A new teammate gets the same map. CI can invoke the same commands. Reviewers can see whether a change modifies the documented path or invents a parallel one.

For agents, explicit commands reduce improvisation. Instead of asking a model to rediscover signing every session, tell it which cheap command verifies a change and which guarded command produces a release. Let the script own the sequence and let the agent respond to concrete failures.

Where the GUI Still Wins

A terminal-first workflow should not become a purity contest. Xcode remains valuable for tasks that are naturally visual or interactive:

  • stepping through code with the debugger;
  • inspecting view hierarchies and performance instruments;
  • configuring a new account or certificate when the CLI path is unclear;
  • resolving a signing edge case by seeing Xcode’s interpretation of project settings;
  • using previews, Interface Builder, simulators, or device logs.

The goal is not to forbid the application. The goal is to remove the GUI from the repeatable build and release contract.

There are also legitimate objections to hand-written release scripts. Fastlane already packages mature mobile automation. Tuist may model a large project more comfortably than YAML. XcodeGen may be unnecessary for projects that benefit from synchronized groups. A complicated workspace may contain edge cases that a simple generator spec does not capture.

Choose the smallest system that stays understandable. A 60-line release script can be excellent when it expresses eight stable commands. It becomes a liability when it quietly grows into a private build framework with no tests, ownership, or documentation.

The Real Payoff Is Reproducibility

Avoiding Xcode windows is a convenience. The deeper improvement is that the software’s construction becomes inspectable.

A button hides state: the selected scheme, active destination, signing identity, cached derived data, export choice, and account session. A command can name those inputs. A script can check them. CI can run it. An agent can repeat it. When it fails, the failing stage becomes evidence instead of a vague red badge in an organizer window.

The result is a clean division of responsibility:

  • Apple supplies the compiler, SDK, signing infrastructure, device service, and notary service.
  • The project generator turns a reviewable specification into an Xcode project.
  • Build scripts encode the fast and release paths.
  • The keychain protects identities and credentials.
  • Repository instructions tell humans and agents which path to use.

You still need judgment. You still need to test the app, protect signing keys, understand entitlements, inspect notarization failures, and decide when a generated project is appropriate. Automation does not make Apple distribution simple. It makes the complexity explicit, repeatable, and easier to operate.

That is a much better foundation than teaching every new contributor—or every new coding-agent session—where to click.

Sources