Engineering Practice

Build an iOS Install-and-Launch Smoke Gate on a Cloud Mac

Build an iOS Install-and-Launch Smoke Gate on a Cloud Mac

An iOS pipeline can pass every compilation step yet reveal a malformed bundle only when installation on the simulator begins—or exit immediately after launch. Putting the full UI test suite first slows feedback and turns basic failures into misleading timeouts. A more reliable approach is to add an install-and-launch smoke gate on the cloud Mac that completes within two or three minutes: pin the runtime, install the newly built .app, verify that the target process stays alive, and then save the launch logs.

What Should the Gate Verify?

This gate does not validate product functionality. It answers only four yes-or-no questions: Can the simulator boot successfully? Can the build artifact be installed? Can the specified Bundle ID be launched? Does the process remain alive throughout the observation period? If any answer is no, the pipeline should not consume resources on subsequent UI tests.

Checkpoint Success condition Evidence to retain on failure
Boot bootstatus completes successfully Device UDID, runtime version
Install simctl install returns 0 App path, command output
Launch simctl launch returns a PID Bundle ID, exit code
Liveness The process is still queryable after the observation period System logs, crash reports

The value of a smoke gate is not broader coverage. It is the ability to produce two distinct conclusions: “the artifact exists” and “the artifact can run.”

Pass the simulator UDID to the job explicitly instead of relying on booted. A shared cloud Mac may still have devices booted by other jobs, and an ambiguous target can mix their logs and installation results. Confirm the specific available configurations for NowMini nodes in the console; the pipeline itself only needs the installed Xcode version and the corresponding simulator runtime.

Pin the Build Artifact and Target Device First

Direct build output into the job directory instead of guessing its location inside DerivedData. The Scheme, configuration, Bundle ID, and UDID should all come from environment variables and be validated at the beginning of the job.

set -euo pipefail

: "${SIM_UDID:?SIM_UDID is required}"
: "${APP_BUNDLE_ID:?APP_BUNDLE_ID is required}"

WORK_DIR="${RUNNER_TEMP:-$PWD/.smoke}"
APP_PATH="$WORK_DIR/build/Sample.app"
LOG_DIR="$WORK_DIR/logs"

rm -rf "$WORK_DIR"
mkdir -p "$LOG_DIR"

xcodebuild \
  -workspace Sample.xcworkspace \
  -scheme Sample \
  -configuration Debug \
  -sdk iphonesimulator \
  -destination "platform=iOS Simulator,id=$SIM_UDID" \
  CONFIGURATION_BUILD_DIR="$WORK_DIR/build" \
  build

Do not use find to select the first .app. A project may generate the host app, a test host, and extensions at the same time, and their ordering is not stable. When the product name is known, construct the path directly and validate Info.plist before installation:

test -d "$APP_PATH"
BUILT_ID=$(/usr/libexec/PlistBuddy -c \
  "Print :CFBundleIdentifier" "$APP_PATH/Info.plist")
test "$BUILT_ID" = "$APP_BUNDLE_ID"

This step catches an incorrect Scheme, an improperly substituted build configuration, or an installation command that received the test host instead of the intended app.

Boot, Install, and Verify the Process

Use bootstatus -b when starting the device so the job waits until the system is actually ready. Running boot by itself and installing immediately afterward can cause intermittent failures when services are not yet available under heavy load.

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

xcrun simctl uninstall "$SIM_UDID" "$APP_BUNDLE_ID" \
  2>/dev/null || true
xcrun simctl install "$SIM_UDID" "$APP_PATH"

LAUNCH_OUTPUT=$(xcrun simctl launch \
  "$SIM_UDID" "$APP_BUNDLE_ID" \
  -SmokeTestMode YES)

printf '%s
' "$LAUNCH_OUTPUT"
PID=$(printf '%s
' "$LAUNCH_OUTPUT" | awk -F': ' 'NF == 2 {print $2}')
test -n "$PID"

Launch arguments can tell the app to skip first-run onboarding, disable animations, or use local fixtures, but the application code must explicitly implement that behavior. Do not assume arbitrary arguments will have an effect. After launch, wait for a short observation period and then query the process from inside the simulator. Checking only the return code from launch misses cases where the app exits within a second.

sleep 8
xcrun simctl spawn "$SIM_UDID" launchctl print \
  "gui/$(id -u)/$APP_BUNDLE_ID" >/dev/null

For some apps, the process label does not exactly match the Bundle ID. In that case, query the known process name instead, or have the app write a readiness marker that tests can read when startup completes. Do not hide an unstable state by simply increasing a fixed wait time.

Preserve Readable Evidence for Failures

Cleanup matters, but evidence must be saved first. The minimum evidence set includes build output, the device description, installation and launch output, and unified logs around the target process. The log window should cover the current job rather than exporting the host’s entire history.

xcrun simctl list devices -j > "$LOG_DIR/devices.json"

xcrun simctl spawn "$SIM_UDID" log show \
  --style compact \
  --last 2m \
  --predicate "process == 'Sample'" \
  > "$LOG_DIR/sample-launch.log" 2>&1 || true

A common mistake is to archive only standard error. When an app exits immediately after launch, the actual cause may appear in the simulator’s system logs—for example, a dynamic library loading failure, an invalid resource path, or an unhandled exception. If logs contain tokens, request headers, or user data, redact them before archiving and restrict access to the pipeline artifacts.

Distinguish Environment Failures from Application Failures

A bootstatus failure usually indicates a device environment problem. For an install failure, check the bundle structure, runtime compatibility, and available disk space first. If launch succeeds but the process disappears, start with the application logs. Classify the failure before deciding whether to retry. If the application repeatedly fails in the same way, the job should not rerun automatically multiple times, because that only delays useful feedback.

Clean Up State and Integrate the Gate into the Pipeline

Whether the job succeeds or fails, terminate the application and shut down the simulator used by the job. A shell trap ensures cleanup always runs:

cleanup() {
  xcrun simctl terminate "$SIM_UDID" "$APP_BUNDLE_ID" \
    2>/dev/null || true
  xcrun simctl uninstall "$SIM_UDID" "$APP_BUNDLE_ID" \
    2>/dev/null || true
  xcrun simctl shutdown "$SIM_UDID" \
    2>/dev/null || true
}
trap cleanup EXIT

If the job requires an identical initial state every time, run simctl erase after shutdown. However, this adds execution time and removes preloaded data. A more common compromise is to uninstall the app, clear the job directory, and assign a dedicated device to each pipeline. When permission state matters, explicitly reset the relevant service as well.

Finally, place this gate after compilation and before unit or UI tests. The script should return an explicit nonzero exit code, while log archival should be configured to run regardless of the result. This prevents installation failures from masquerading as test timeouts and keeps launch-time crashes from wasting an entire executor pool. Every failure also leaves enough evidence for developers to reproduce it with the same runtime and the same artifact.

Frequently asked questions

Why is a successful xcodebuild result not enough?

It proves that Xcode produced an artifact, not that the simulator can install or launch it. Bundle structure, deployment target, embedded content, and runtime resource failures can still block execution.

What evidence should a failed smoke gate preserve?

Keep the xcodebuild output, simulator UDID and runtime version, app path, exit codes from simctl install and launch, and the target process logs covering at least the period around the failure.

NowMini M4

Run your next task on a dedicated physical node

Use a cloud Mac with M4, 16GB RAM, and a 256GB SSD. Choose a node in Singapore, Tokyo, Seoul, or Hong Kong based on your project schedule.

Rent a cloud Mac now