A cloud Mac may already accept SSH connections, and xcodebuild -version may return successfully, yet its first unattended job can still stall on license acceptance, additional component installation, or toolchain path selection. These failures are especially troublesome because they often appear only after adding a new node, upgrading Xcode, or rebuilding a runner. Manually opening the GUI once can also hide the problem temporarily. A more reliable approach is to treat first-launch initialization as a separate node provisioning step instead of letting production build jobs repair the environment as they run.
Separate node bootstrap from build jobs
Node bootstrap may change system state, requires administrator privileges, and should run only once for each Xcode version. Build jobs, by contrast, should be repeatable, non-interactive, and avoid privilege escalation wherever possible. Combining both processes in one CI script creates three problems: multiple jobs may install components concurrently, unattended sudo commands may wait for input, and failures become difficult to attribute to either the code or an incomplete environment.
Divide the runner lifecycle into three stages:
- Install or switch Xcode, then complete license acceptance and first-launch component initialization.
- Run read-only preflight checks and record the developer directory and tool versions actually in use.
- Allow the runner to accept jobs only after the preflight checks pass.
“Xcode can be opened” is not an acceptance criterion. The real requirement is that the runner user can locate the compiler tools, query available SDKs, and complete a minimal project inspection from a non-interactive Shell.
Pin the Xcode path actually in use
When multiple Xcode versions are installed on one machine, do not rely solely on the global xcode-select setting. It affects other jobs on the same physical node, and switching versions can easily create the illusion that the logs report one version while the job actually invokes another. For CI, pinning DEVELOPER_DIR at the job level is a better approach.
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
printf 'developer_dir=%s
' "$DEVELOPER_DIR"
xcodebuild -version
xcrun --find clang
xcrun --find simctl
xcodebuild -showsdks
If the application directory includes a version number, store the path in the runner configuration or a version-controlled environment file instead of duplicating it across scripts. When Xcode is updated, change only that single source and have the preflight checks print the path. This makes it possible to confirm the toolchain identity in the logs before investigating the project itself.
Do not substitute GUI state for Shell validation
The environment variables, default Shell, and permission context of a remote desktop session may differ from those of the runner service. Run every check as the same user that performs the build. In particular, verify that the results of xcrun --find reside under the expected DEVELOPER_DIR; merely checking that the application directory exists is not sufficient.
Complete first-launch setup once
During node provisioning, check the first-launch status and then use an administrator-authorized bootstrap process to accept the license and prepare the required components:
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
if ! xcodebuild -checkFirstLaunchStatus; then
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch
fi
xcodebuild -checkFirstLaunchStatus
The important point is not the commands themselves, but where they run. They should not appear in a build script triggered by every commit. runFirstLaunch may modify shared components; if two concurrent jobs execute it, they waste time and one of them may observe an incomplete state.
License acceptance should not depend on clicking through a dialog either. An automated environment must run the command explicitly, inspect its exit code, and prevent the node from accepting jobs if it fails. If an administrator command requires interactive input, handle it during node provisioning rather than leaving a background runner waiting for input.
Protect concurrent bootstrap with an atomic lock
With automatic scaling or multiple services starting simultaneously, a bootstrap script intended to run only once may still be invoked concurrently. The standard macOS tools are sufficient to implement an atomic lock by creating a directory:
set -euo pipefail
lock_dir="/tmp/xcode-bootstrap.lock"
if ! mkdir "$lock_dir" 2>/dev/null; then
echo "Xcode bootstrap is already running" >&2
exit 75
fi
cleanup() {
rmdir "$lock_dir" 2>/dev/null || true
}
trap cleanup EXIT
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch
xcodebuild -checkFirstLaunchStatus
mkdir is atomic within the same file system, so only one process can succeed. The scheduling layer can interpret exit code 75 as a temporary failure and retry later instead of permanently marking the node as faulty.
Stale locks left by abnormal termination also require attention. Remove them during node startup only after confirming that no bootstrap process is running, or place the lock directory somewhere that is cleared after a restart. Do not delete a lock merely because it exists, as doing so could interfere with initialization that is still in progress.
Add a preflight gate before accepting production jobs
After the first-launch status check passes, perform an acceptance check that does not modify the system. At a minimum, it should verify the Xcode identity, SDK discovery, simulator tooling, and project entry point:
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
xcodebuild -checkFirstLaunchStatus
xcodebuild -version
xcodebuild -showsdks >/tmp/xcode-sdks.txt
xcrun simctl list runtimes
xcodebuild -list -workspace "Example.xcworkspace"
Replace the final line with the repository’s actual workspace or project. This check is faster than running a complete archive, yet it can detect a missing project entry point, unmet prerequisites for dependency resolution, or an incorrect runner working directory before a full build begins.
What evidence to retain after a failure
Diagnostic logs should retain at least DEVELOPER_DIR, xcodebuild -version, xcrun --find clang, xcrun simctl list runtimes, and the exit code of the failed command. Do not print the entire environment variable set, because it may contain tokens, signing passwords, or repository credentials.
A practical diagnostic order is to verify the path first, check first-launch status next, confirm the required SDKs and runtimes, and only then investigate project configuration. After switching Xcode versions, repeat this process for the new Developer directory even if the previous version was already initialized. Finally, record the bootstrap script version alongside the Xcode version. A rebuilt runner can then be restored in the same sequence without relying on someone having manually opened a particular interface in the past.
Frequently asked questions
Why can Xcode open interactively while a CI job still fails its first-launch check?
The interactive session and runner may resolve different Xcode installations or environments. Run checkFirstLaunchStatus with the exact DEVELOPER_DIR used by the CI job.
Should every CI job run sudo xcodebuild -runFirstLaunch?
No. Run it once during node provisioning or after changing Xcode versions. Build jobs should perform read-only checks because concurrent setup and unattended sudo can block the queue.
Must the bootstrap run again after switching Xcode versions?
Yes. Validate the new Developer directory independently because licenses, installed components, and simulator runtimes can differ between Xcode releases.
Need a dedicated Apple Silicon build machine?
Rent a dedicated physical node by the day, week, month, or quarter for Xcode builds, iOS CI, remote development, and automation.