Skip to main content

Scripts and CI

The CLI is built to be called by other programs as much as by you. Every command has a machine-readable output mode, a meaningful exit code, and a way to refuse prompting, which together are what a git hook, a Makefile, a CI job, or an AI coding agent needs to work with an Xcode project without a human watching.

Use GitHub Actions for iOS for Linux iPhone builds and device delivery. The macOS CI guide covers native Mac jobs and Linux compilation of macOS apps. The interface conventions on this page apply to both workflows.

Exit codes​

Every command exits with a code that says what kind of thing went wrong, not just that something did:

CodeMeaning
0Success.
1Generic failure.
2Usage error: bad flags or arguments.
3The build or the tests failed.
4Couldn't resolve a target: unknown scheme, destination, simulator, device.
5A required tool is missing (xcodebuild, simctl, …).
6Cancelled: a declined prompt, or Ctrl-C while sweetpad run is building.

The distinction that matters most in CI is 3 versus 4. Code 3 means your code is broken; code 4 means the job is misconfigured and never got as far as compiling anything. Treating them the same is how a typo in a simulator name gets reported to your team as a failing test suite.

A signal that kills the process exits 128 + signo (130 for Ctrl-C, 143 for SIGTERM) after the terminal is restored and child processes are reaped.

JSON output​

-o json (or --json) returns the whole command as one envelope on stdout:

$ sweetpad scheme list -o json
{
"data": {
"container": "/path/to/MyApp/MyApp.xcodeproj",
"schemes": [
{ "name": "MyApp", "selected": true },
{ "name": "MyAppMac", "selected": false }
],
"selected": "MyApp"
},
"ok": true,
"schema": 1
}

Three fields, always: schema for the envelope version, ok for whether the command executed, and data for the payload. Errors take the same shape on stderr, with error in place of data:

$ sweetpad build --on "nope-does-not-exist" -o json
{"error":{"code":"target_resolution","message":"--on \"nope-does-not-exist\" matches nothing (try one of: …)"},"ok":false,"schema":1}

error.code is a name, not a number: generic, usage_error, build_failure, target_resolution, tool_missing, or user_cancel. It mirrors the exit-code taxonomy, so a script can branch on either.

usage_error goes with exit 2 when SweetPad parses a flag but won't take it, such as --failed on test build or --on together with --destination. It also covers an argument no project could make valid, such as archive --on toaster or a pbxproj membership add that names no file. SweetPad checks these before it looks for a project, so you get the same answer from any directory. A flag that doesn't parse at all also exits 2, but prints the argument parser's plain-text error, even under -o json.

A command that would ask a question at a terminal also exits 2 when it can't ask, and names the flag that answers it. Examples are derived-data purge or simulator delete without --yes, and dependency add without --product and --target. A missing scheme or destination is different. It exits 4, because whether you need --scheme depends on how many schemes the project has.

warning

"ok": true means "the command executed", not "the outcome was good". A red test suite exits 3 and reports its failures inside data with a perfectly valid envelope. Check the payload's own status, which for tests is data.passed, rather than the envelope.

Streaming with NDJSON​

A build can take minutes, and a single envelope at the end tells you nothing while it runs. -o ndjson emits one JSON object per line as things happen, ending with a result event:

$ sweetpad build -o ndjson
{"event":"task","kind":"compile","name":"ContentView.swift"}
{"event":"task","kind":"copy","name":"MyApp.swiftsourceinfo"}
{"data":{"built":true,"configuration":"Debug","destination":"platform=iOS Simulator,id=F92801F8-…","durationMs":2451,"errors":0,"productPath":"/…/MyApp.app","scheme":"MyApp","warnings":0},"event":"result","ok":true}

Every line has an event field. The last one is always "event":"result" and carries the same ok/data pair the one-shot envelope would have, so a consumer can stream progress and still get a single authoritative answer at the end.

Use ndjson for the long-running verbs (build, test, app logs) and plain json for anything that returns immediately.

Quiet mode​

-o quiet prints nothing at all and communicates only through the exit code. It's the right mode for a check whose output you'd throw away:

sweetpad build -o quiet || echo "build is broken"

There's also -q, which suppresses progress chatter but keeps results, and -v, which prints the raw tool output. -q wins if you pass both.

Never prompting​

Interactive commands ask questions: which scheme, which simulator, whether to delete. --non-interactive turns every such question into an error that names the flag to pass instead:

sweetpad build --non-interactive

You rarely need to pass it. SweetPad enables it automatically when CI is set, which every CI runner does. Set SWEETPAD_NONINTERACTIVE=1 to force it anywhere else.

Pinning the context​

A script shouldn't depend on what someone last picked at a prompt. Two ways to be explicit:

Per command, with flags:

sweetpad test --scheme MyApp --destination 'platform=iOS Simulator,name=iPhone 16 Pro'

Once for the whole job, with environment variables:

export SWEETPAD_SCHEME=MyApp
export SWEETPAD_DESTINATION='platform=iOS Simulator,name=iPhone 16 Pro'
sweetpad build && sweetpad test

Prefer --destination, the raw specifier, over --on in CI. Fuzzy name matching against whatever simulators a runner happens to have installed is a liability. A pinned specifier fails loudly instead of quietly building for something else. See Configuration for the full list of variables.

-C <dir> runs as if started somewhere else, like git -C, which saves a subshell when a job builds more than one project:

sweetpad -C ./ios build

A sandbox that sets HOME to a scratch directory doesn't move DerivedData. xcodebuild reads your account's home from the system, and so does SweetPad, so both keep using ~/Library/Developer/Xcode/DerivedData. A custom TMPDIR doesn't move Xcode's cache directory either. To give a job its own DerivedData, pass -- -derivedDataPath <dir>, or set CFFIXED_USER_HOME, which moves the home for both Xcode's tools and SweetPad.

GitHub Actions​

--gh-annotations turns build and test diagnostics into workflow commands, so failures land on the diff instead of only in the log:

$ sweetpad build --gh-annotations
::error file=/path/to/Sources/App/ContentView.swift,line=12,col=14::cannot find 'greeting' in scope

It writes those to stdout, which is where -o json and -o ndjson put their own output, so the two are mutually exclusive, and SweetPad says so rather than interleaving them.

A workflow that builds, tests, and reports both inline and as a JUnit report:

.github/workflows/ios.yml
name: iOS

on: [push, pull_request]

jobs:
build-and-test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4

- name: Install SweetPad
run: brew install sweetpad-dev/tap/sweetpad

- name: Build
run: sweetpad build --gh-annotations
env:
SWEETPAD_SCHEME: MyApp
SWEETPAD_DESTINATION: platform=iOS Simulator,name=iPhone 16 Pro

- name: Test
run: sweetpad test --gh-annotations --junit ./reports/tests.xml
env:
SWEETPAD_SCHEME: MyApp
SWEETPAD_DESTINATION: platform=iOS Simulator,name=iPhone 16 Pro

- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report
path: ./reports/

--non-interactive isn't needed, because the CI variable GitHub sets already implies it.

A pre-commit hook​

The cheapest useful hook checks formatting and that the project still compiles, and says which one failed:

.git/hooks/pre-commit
#!/bin/sh
set -e

sweetpad format --check || {
echo "unformatted Swift files — run 'sweetpad format'" >&2
exit 1
}

sweetpad build -o quiet || {
echo "build failed — run 'sweetpad build' to see why" >&2
exit 1
}

Bear in mind sweetpad build compiles the app target and not your test targets, so a hook like this won't catch a test that no longer compiles. Add sweetpad test build -o quiet for that. It compiles the test targets without running them.

Reading results in a script​

Anything the CLI knows, it will hand over as JSON. A few patterns worth having:

# The path to the built .app (null, with a "note" saying why, when it can't be found)
sweetpad build -o json | jq -r '.data.productPath'

# One build setting, as a bare string — no jq needed
sweetpad settings show --key PRODUCT_BUNDLE_IDENTIFIER

# Every booted simulator's UDID
sweetpad devices -o json | jq -r '.data.destinations[] | select(.booted) | .udid'

# Did the tests pass?
sweetpad test -o json | jq -e '.data.passed' >/dev/null

For an AI coding agent, there's a better starting point than raw JSON: the agent skills teach an agent this whole surface directly, so it drives the CLI instead of guessing at xcodebuild.