Deterministic Xcode Destinations for Cloud Mac CI

Deterministic Xcode Destinations for Cloud Mac CI

After the same xcodebuild test command has run on a cloud Mac for weeks, it may suddenly report that its destination is unavailable or select a simulator with the same name from a newly installed runtime. The project itself has not changed. What has drifted is the way Xcode resolves the target device. The reliable approach is not to hard-code a particular UDID in CI indefinitely, but to distinguish clearly between archiving, compile checks, and simulator tests, then resolve and record the destination at the start of every job.

Distinguish the three types of build destination

An Xcode Destination answers which platform and device an action targets. It is not the same thing as a Scheme. A shared Scheme can be used for a generic iOS archive, tests on a specific simulator, and compile-only validation, but those three tasks should not share one ambiguous command.

TaskRecommended destinationSpecific UDID required?
Create an Archivegeneric/platform=iOSNo
Simulator testsSpecific iOS SimulatorYes
Compile validationGeneric or specific destination based on the artifact platformDepends on the test scope

Using name=iPhone 16 for an archive job introduces an unnecessary dependency on a simulator runtime. Conversely, if a test job specifies only platform=iOS Simulator, Xcode may find multiple candidates. The final selection can then depend on the installed runtimes and the order in which devices were created.

A generic destination proves that the project can produce an artifact for the platform. A specific destination proves that the tests can run in a known device environment. Do not make one Destination carry both meanings.

Inspect available destinations before the job starts

First pin the Xcode path, then let the project report its own available destinations. This distinguishes “the Scheme does not support this platform” from “this host has no matching device” and avoids deleting caches as a first response.

set -euo pipefail

export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
WORKSPACE="App.xcworkspace"
SCHEME="App"

xcodebuild \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -showdestinations

Retain the output as a job artifact. If the list contains no iOS Simulator destinations at all, check the Scheme’s Supported Destinations, the target platform, and the test Target before repeatedly running simctl erase all. If the device exists but is marked unavailable, verify that the corresponding runtime is still supported by the current Xcode version.

Record the toolchain identity as well

Destination issues often coincide with a change to the default Xcode version. Record at least the following information on every run:

xcodebuild -version
xcrun simctl list runtimes
xcrun simctl list devices available

These outputs do not contain project secrets, so they are suitable for archiving as build diagnostics. When several cloud Macs run jobs in parallel, the records also show whether a failure occurs only with one particular toolchain.

Resolve the simulator dynamically instead of pinning a UDID

A UDID belongs to a device instance on the current host. It can change when a simulator is deleted and recreated, a runtime is replaced, or a worker is reset. CI should search by device name and availability, and require exactly one match.

The following script selects one available iPhone 16 from the simctl JSON output. A production pipeline should also accept the target runtime identifier as a parameter to avoid matching devices with the same name under two OS versions.

SIM_NAME="${SIM_NAME:-iPhone 16}"

SIM_UDID="$(
  xcrun simctl list devices available -j |
  python3 -c '
import json, sys
name = sys.argv[1]
data = json.load(sys.stdin)
matches = []
for runtime, devices in data["devices"].items():
    for device in devices:
        if device.get("isAvailable") and device.get("name") == name:
            matches.append((runtime, device["udid"]))
if len(matches) != 1:
    for runtime, udid in matches:
        print(f"{runtime} {udid}", file=sys.stderr)
    raise SystemExit(f"expected one simulator named {name}, found {len(matches)}")
print(matches[0][1])
' "$SIM_NAME"
)"

printf 'Resolved simulator: %s (%s)\n' "$SIM_NAME" "$SIM_UDID"

The key is to fail immediately unless the candidate is unique. Silently taking the first array element may let the job continue, but it turns an ambiguous environment into test results that are difficult to reproduce.

Separate test and archive commands

After resolving the UDID, boot the device and wait for initialization to finish before running the tests. bootstatus -b prevents the test process from racing ahead while system services are not yet ready.

xcrun simctl boot "$SIM_UDID" 2>/dev/null || true
xcrun simctl bootstatus "$SIM_UDID" -b

xcodebuild test \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -destination "platform=iOS Simulator,id=$SIM_UDID" \
  -resultBundlePath "Artifacts/TestResults.xcresult"

The archive command, by contrast, does not reference a simulator at all:

xcodebuild archive \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -configuration Release \
  -destination "generic/platform=iOS" \
  -archivePath "Artifacts/App.xcarchive"

This separation also makes responsibility boundaries clearer: the test stage manages simulator state, while the archive stage deals only with the project, signing configuration, and output path. Even when both steps run on the same SetMini cloud Mac, keep them in separate log sections.

Turn the resolved destination into an auditable record

A successful command is not enough. The pipeline should write the Scheme, DEVELOPER_DIR, device name, UDID, runtime, action type, and result bundle path to plain text or JSON. During troubleshooting, compare these facts before discussing code changes.

During teardown, shut down only the device started by the current job:

cleanup() {
  if [[ -n "${SIM_UDID:-}" ]]; then
    xcrun simctl shutdown "$SIM_UDID" 2>/dev/null || true
  fi
}
trap cleanup EXIT

Do not unconditionally shut down every simulator on a shared worker, and do not use erase all as routine cleanup. The former disrupts parallel jobs; the latter hides the source of state contamination and increases initialization time for subsequent jobs.

The final checklist can be reduced to five points: archives use a generic destination; tests use the UDID dynamically resolved for the current job; the candidate must be unique; Xcode and runtime details are recorded before execution; and teardown touches only devices owned by the current job. With these controls in place, the Destination stops being implicit environment state and becomes a verifiable, reproducible build input.

Frequently asked questions

Does an iOS archive need a specific simulator destination?

No. Use generic/platform=iOS for archive jobs so the archive is not tied to a simulator that may change after a runtime update.

Should a CI configuration store a simulator UDID permanently?

No. Resolve the UDID from the required model and runtime at the start of each job, then log the exact value selected for that run.

What should CI do when several simulators have the same name?

Filter for available devices and the required runtime. If multiple candidates remain, fail with the candidate list instead of silently choosing the first one.

Dedicated physical node

Put your engineering workflow on a cloud Mac you can access anytime

Choose from two Apple Silicon configurations across four data centers. Resources are dedicated to you, with real-time availability shown in the control panel.

Choose a plan