
Most CI build scripts start with a minimal xcodebuild invocation and only accumulate flags after a painful failure. The result works but leaves minutes of build time and several silent failure modes on the table. This guide gives an annotated command, explains each flag in one line, and ends with a short cost worksheet you can run against your own runner bill.
Copy this as a starting point; remove flags that do not apply to your project.
#!/usr/bin/env bash
# xcodebuild-ci.sh — pass workspace/scheme/UDID through env; never hardcode
set -euo pipefail
xcodebuild test \
-workspace "${XCODE_WORKSPACE:?}" \
-scheme "${XCODE_SCHEME:?}" \
-configuration Debug \
-destination "id=${SIM_UDID:?}" \
-derivedDataPath "${DERIVED_DATA:-build/DerivedData}" \
-resultBundlePath "build/results.xcresult" \
-enableCodeCoverage YES \
-quiet \
-jobs "$(sysctl -n hw.logicalcpu)" \
-maximum-concurrent-test-simulator-destinations 1 \
ONLY_ACTIVE_ARCH=YES \
| tee build/xcodebuild.log
BUILD_EXIT=${PIPESTATUS[0]} # capture xcodebuild's exit code, not tee's
exit "${BUILD_EXIT}"
The xcodebuild tool's option set is documented in Apple's xcodebuild technote TN2339 and in the man xcodebuild page shipped with Xcode. Behaviour of the underlying build system is governed by Apple's build settings reference.
Flag reference — one line each:
| Flag / Setting | Purpose |
|---|---|
-workspace path | Use .xcworkspace when CocoaPods or SPM packages are involved |
-scheme name | Explicit scheme; xcodebuild -list prints the available names |
-configuration Debug | Debug for simulator tests; Release for archive and device builds |
-destination "id=UDID" | Pin by UDID; name lookups break after simulator runtime updates |
-derivedDataPath | Stable directory you can cache between CI jobs |
-resultBundlePath | Writes a structured .xcresult for xcresulttool and dashboards |
-enableCodeCoverage YES | Adds a coverage report inside the result bundle |
-quiet | Suppresses per-file progress; errors still print |
-jobs N | Parallel compile tasks; match the runner's logical CPU count |
-maximum-concurrent-test-simulator-destinations N | Caps simultaneous simulator instances to control RAM |
ONLY_ACTIVE_ARCH=YES | Compile only the host architecture for faster simulator builds |
-jobs N sets the number of concurrent compile tasks. The default is the logical CPU count, which is right for a dedicated machine but can starve other processes on shared runners. Set it explicitly so the value is visible in logs:
-jobs "$(sysctl -n hw.logicalcpu)" # dynamic: all CPUs on this machine
-jobs 4 # static: safe for a 4-vCPU runner
-maximum-concurrent-test-simulator-destinations N limits simultaneous simulators when you pass multiple -destination flags. Two simulators on a 16 GB machine can swap during test runs; keep it at 1 unless you have measured headroom.
Name-based strings such as platform=iOS Simulator,name=iPhone 16,OS=18.0 fail when the runtime updates. Resolve the UDID first and pin it:
SIM_UDID=$(xcrun simctl list devices available -j | python3 -c '
import sys, json
runtimes = json.load(sys.stdin)["devices"]
for _, devs in runtimes.items():
for d in devs:
if "iPhone" in d["name"] and d["state"] != "unavailable":
print(d["udid"]); raise SystemExit')
If the lookup returns nothing, the script fails at that step rather than producing a cryptic error from deep inside xcodebuild. For archive jobs, use -destination 'generic/platform=iOS'.
xcodebuild clean build discards derived data and forces a full rebuild. On a cold runner that is unavoidable; on a warm runner with a persistent -derivedDataPath, omitting clean can cut compile time on large projects. A practical split:
clean; use the cache.clean; absorb the full cost once per merge.Cache the derived data path keyed on Podfile.lock + Package.resolved so dependency changes invalidate it.
Pipe xcodebuild through tee or xcpretty for readable logs, but always capture ${PIPESTATUS[0]}. If you read the pipe's own exit code, you will read the formatter's, which is usually 0 even when xcodebuild exited 65 (test failure) or 70 (internal error). A green build that was actually broken is the single most common silent-failure pattern in iOS CI.
As a secondary check, parse the result bundle after the run:
xcresulttool get --path build/results.xcresult --format json \
| python3 -c 'import sys,json; r=json.load(sys.stdin); \
s=r.get("metrics",{}).get("testsFailedCount",{}).get("_value","0"); \
sys.exit(1 if int(s)>0 else 0)'
Never time a failed build and call it a benchmark. Record wall time only on green runs with the same scheme, destination, and derived-data state.
Pick a representative job and measure three runs:
T_full — wall time with clean build.T_warm — wall time with warm derived data and -quiet.R — runs per working day (PRs + main).Monthly saved minutes ≈ (T_full − T_warm) × R × 21. At a $95/month Figment M-series entry price, a flat month is 730 hours of wall clock; a dedicated runner you control does not meter minutes, so the saving shows up as queue headroom rather than invoice reduction.
Price check (2026-11-10): CloudMac lists its base Mac mini M4 (16 GB / 256 GB) at $99.99/month, and the CloudMac home page advertises a $999/year option on the same base config — effectively $83.25/month, or about $141 less than 12 × $95. Figment's M-series entry price is $95/month; the specific chip generation, RAM, and disk at that price are confirmed in the quote, so compare entry cost rather than assuming identical hardware. Figment's $249/month Managed Build Mac adds CI setup, backups, monitoring, and 30 minutes of monthly support on one M-series Mac — the piece neither of those self-serve plans includes.
arm64 simulator natively, which is what ONLY_ACTIVE_ARCH=YES compiles to on Apple silicon.Start a configuration at Figment mac-hosting or go straight to /mac-hosting/start.
Related guides: