
A watchOS release is really two binaries: the iOS companion and the Watch extension, packaged inside one .ipa. CI has to compile both, run the Watch extension test bundle against a paired simulator, and exit non-zero on any failure. Two things trip most pipelines up: the destination syntax and losing xcodebuild's exit code when output is piped through a formatter. Everything below is the minimum correct wiring, with a worked Figment cost example at the end.
Since watchOS 7 (Xcode 12) Watch apps can ship independently, and Xcode 14 made independent the default for new projects. Most shipping apps still carry an iOS companion. When both targets share one scheme, xcodebuild compiles the Watch extension as a dependency of the iOS app, so you do not need a second invocation.
Compile once, then run tests from the cached artefact (Apple's recommended CI pattern):
xcodebuild \
-workspace YourApp.xcworkspace \
-scheme "YourApp" \
-configuration Debug \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
build-for-testing
build-for-testing writes an .xctestrun bundle without running tests. A later test-without-building step reuses that artefact, so compile time is paid once per commit regardless of how many destinations you fan out to.
List what the runner has, then pick a UDID. Name-based destinations break every time Xcode or a runtime updates:
xcrun simctl list devices available | grep -E "Watch|iPhone"
WATCH_UDID=$(xcrun simctl list devices available -j \
| python3 -c "
import sys, json
devs = json.load(sys.stdin)['devices']
print(next(
d['udid']
for runtimes in devs.values()
for d in runtimes
if 'Apple Watch' in d['name'] and d['state'] != 'unavailable'
))") || { echo "No watchOS simulator found"; exit 1; }
Pass -destination "id=${WATCH_UDID}" to every subsequent xcodebuild call. Without a valid destination, xcodebuild exits before any test executes, and a sloppy pipe-through-formatter pipeline can report that failure as success.
Boot the simulator before invoking xcodebuild test; a cold boot inside the test command can blow past CI timeouts. The script below is the artefact to copy into your repo. The crucial line is PIPESTATUS[0]: it preserves xcodebuild's real exit code when stdout is piped through xcpretty.
#!/usr/bin/env bash
# ci-watchos-test.sh
# Required: XCODE_SCHEME, XCODE_WORKSPACE
# Optional: WATCH_RESULT_BUNDLE (default build/watchos-tests.xcresult)
set -euo pipefail
SCHEME="${XCODE_SCHEME:?Set XCODE_SCHEME in CI environment}"
WORKSPACE="${XCODE_WORKSPACE:?Set XCODE_WORKSPACE in CI environment}"
RESULT_BUNDLE="${WATCH_RESULT_BUNDLE:-build/watchos-tests.xcresult}"
echo "==> Locating watchOS simulator"
WATCH_UDID=$(xcrun simctl list devices available -j \
| python3 -c "
import sys, json
devs = json.load(sys.stdin)['devices']
print(next(
d['udid']
for runtimes in devs.values()
for d in runtimes
if 'Apple Watch' in d['name'] and d['state'] != 'unavailable'
))") || { echo "ERROR: no available watchOS simulator" >&2; exit 1; }
echo "Using simulator: ${WATCH_UDID}"
echo "==> Booting simulator"
xcrun simctl bootstatus "${WATCH_UDID}" -b
echo "==> Running WatchKit Extension tests"
set +e
xcodebuild test \
-workspace "${WORKSPACE}" \
-scheme "${SCHEME}" \
-destination "id=${WATCH_UDID}" \
-resultBundlePath "${RESULT_BUNDLE}" \
-enableCodeCoverage YES \
2>&1 | xcpretty --color
TEST_EXIT=${PIPESTATUS[0]}
set -e
if [ "${TEST_EXIT}" -ne 0 ]; then
echo "ERROR: xcodebuild exited ${TEST_EXIT} - inspect ${RESULT_BUNDLE}" >&2
exit "${TEST_EXIT}"
fi
echo "==> All tests passed"
Treat this as an unexecuted example to adapt - run it once locally against your real scheme name before trusting the signal in CI. Without PIPESTATUS[0], a successful formatter exit will overwrite a failed test exit and the pipeline reports green on a broken build.
When archiving for TestFlight, xcodebuild archive produces a .xcarchive. If the scheme's Archive action excludes the Watch app target, the archive contains only the iOS binary and TestFlight rejects the upload because the Watch entitlement references a bundle that is not present.
Check the scheme list, then open Product > Scheme > Edit Scheme > Archive in Xcode and confirm the WatchKit App target is listed with Build checked:
xcodebuild -list -workspace YourApp.xcworkspace
No flag compensates for a misconfigured scheme - fix the .xcscheme file in source control and commit it.
xcodebuild -exportArchive produces a single .ipa. The Watch app is embedded at Payload/YourApp.app/Watch/WatchApp.app, so TestFlight accepts one upload for both platforms. With Fastlane:
fastlane pilot upload \
--ipa "build/export/YourApp.ipa" \
--api_key_path "${API_KEY_PATH}"
API_KEY_PATH should point to a file written at runtime from a CI-managed secret - decoded into a job-scoped temp directory, never committed. See the iOS CI secrets guide for a safe decode step. Update only your own CI, artifact store and firewall allowlists; there is no documented App Store Connect ingress IP range to allowlist.
The watchOS SDK ships inside Xcode. If a runner auto-updates Xcode, simulator runtime names change and destinations break. Pin the toolchain and log the version on every run:
sudo xcode-select --switch /Applications/Xcode_26.1.app/Contents/Developer
xcodebuild -version
For the hardware under the runner, Apple states plainly: Intel Macs must use Xcode 26 or an earlier release (Apple developer docs). visionOS tooling needs Apple silicon regardless, so match the host to the toolchain your project is pinned on.
build-for-testing step against the iOS + watchOS pair while you evaluate. No GPU acceleration and no inbound SSH; agents reach out to your CI service.Compare like-for-like entry prices against the primary vendor pages. CloudMac publishes $99.99/month for a Mac mini M4/16 GB/256 GB and a $999/year option on cloudmac.org - that annual rate is $83.25/month effective, which is lower than 12 x $95 = $1,140 for Figment's M-series entry. CloudMac's 256 GB row shows "Reserve now"; the 512 GB and 1 TB rows show "Fully reserved". MacStadium lists M2.S at $109, M4.S at $149 and M4.M at $249, with support and a 99.99% uptime claim on that page.
If you only need to evaluate the pipeline:
build-for-testing and the paired-destination script: $29.If you want Figment to run the watchOS wiring for you, Managed Build Mac at $249/mo bundles the M-series host, CI setup, backups, monitoring and 30 minutes of monthly support. Figment's managed CI work is the material difference against hardware-first vendors - not the raw monthly rate.
Request a configuration at /mac-hosting or start a build host now at /mac-hosting/start.
Related guides: