同一条 xcodebuild test 命令在云端 Mac 上连续运行数周后,可能突然报出 destination 不可用,或者选中了新安装运行时里的同名模拟器。工程本身没有变化,真正漂移的是 Xcode 对目标设备的解析结果。稳定做法不是把某个 UDID 永久写进 CI,而是明确区分归档、编译检查和模拟器测试,在每次任务开始时解析并记录目标。
先分清三类构建目标
Xcode 的 Destination 回答的是“这次动作面向什么平台和设备”,并不等同于 Scheme。一个共享 Scheme 可以用于通用 iOS 归档、指定模拟器测试和仅编译验收,但三者不应该共用一条模糊命令。
| 任务 | 建议目标 | 是否需要具体 UDID |
|---|---|---|
| 生成 Archive | generic/platform=iOS |
否 |
| 模拟器测试 | 具体 iOS Simulator | 是 |
| 编译验收 | 按产物平台选择通用或具体目标 | 视测试内容而定 |
归档任务若写成 name=iPhone 16,就无端引入了模拟器运行时依赖。反过来,测试任务若只写 platform=iOS Simulator,Xcode 可能找到多个候选项,最终选择受已安装运行时和设备创建顺序影响。
通用目标用于证明“这个工程能为该平台生成产物”,具体目标用于证明“测试能在这台确定的设备环境中运行”。不要让一条 Destination 同时承担两种语义。
在任务开始前检查可用目标
先固定 Xcode 路径,再让工程自己报告可用目标。这样可以区分“Scheme 不支持该平台”和“本机没有匹配设备”,避免一上来删除缓存。
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
WORKSPACE="App.xcworkspace"
SCHEME="App"
xcodebuild \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-showdestinations
输出应作为任务附件保留。若列表里完全没有 iOS Simulator,先检查 Scheme 的 Supported Destinations、目标平台和测试 Target,而不是反复执行 simctl erase all。如果设备存在但标记为 unavailable,再核对对应 runtime 是否仍被当前 Xcode 支持。
同时记录工具链身份
Destination 问题经常伴随默认 Xcode 被切换。每次运行至少记录以下信息:
xcodebuild -version
xcrun simctl list runtimes
xcrun simctl list devices available
这些输出不包含项目密钥,适合作为构建诊断信息归档。多台云端 Mac 并行执行时,也能据此确认失败是否只发生在某一套工具链上。
动态解析模拟器而不是固化 UDID
UDID 属于当前主机上的设备实例。删除并重建模拟器、替换运行时或重置工作节点后,它都可能变化。CI 应按设备名与可用状态查找,并要求结果唯一。
下面的脚本从 simctl JSON 中选择一台可用的 iPhone 16。真实流水线还应把目标 runtime 标识作为参数,避免两个系统版本下存在同名设备。
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"
关键点是候选项不唯一时立即失败。静默取数组第一项虽然能让任务继续,却会把环境歧义变成难以复现的测试结果。
分离测试命令与归档命令
得到 UDID 后,先启动并等待设备完成初始化,再执行测试。bootstatus -b 可以阻止测试在系统服务尚未就绪时抢跑。
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"
归档则完全不引用模拟器:
xcodebuild archive \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath "Artifacts/App.xcarchive"
这种拆分还能让权限边界更清晰:测试阶段管理模拟器状态,归档阶段只关心工程、签名配置和输出路径。即使两步位于同一台 SetMini 云端 Mac,也应保留两个独立日志段。
把解析结果变成可验收记录
只让命令成功还不够。流水线应把 Scheme、DEVELOPER_DIR、设备名、UDID、runtime、动作类型和结果包路径写入普通文本或 JSON。排查时先比较这些事实,再讨论代码变化。
建议在退出阶段只关闭本任务启动的设备:
cleanup() {
if [[ -n "${SIM_UDID:-}" ]]; then
xcrun simctl shutdown "$SIM_UDID" 2>/dev/null || true
fi
}
trap cleanup EXIT
不要在共享节点上无条件关闭全部模拟器,也不要把 erase all 当成常规清理。前者会打断并行任务,后者会掩盖状态污染的来源并增加后续初始化时间。
最终检查项可以压缩为五条:归档使用通用目标;测试使用本次动态解析的 UDID;候选项必须唯一;运行前记录 Xcode 与 runtime;退出时只处理本任务拥有的设备。做到这些,Destination 就从隐式环境状态变成了可检查、可复现的构建输入。
常见问题
归档 iOS 应用时需要指定某台模拟器吗?
不需要。归档应使用 generic/platform=iOS,避免把归档任务绑定到某个会随运行时更新而变化的模拟器。
CI 脚本可以长期保存模拟器 UDID 吗?
不建议。重建或更新模拟器运行时后 UDID 可能变化,应在每次任务开始时按设备名和运行时筛选,并记录本次实际使用的 UDID。
出现多个同名模拟器时应该怎样处理?
脚本应先过滤 available 设备,再按指定运行时选择;如果仍有多个候选项,应立即失败并输出候选列表,而不是静默使用第一台。
把工程步骤放进可随时接入的云端 Mac
从两档 Apple Silicon 配置和四个机房中选择,资源不与其他租户共享,实际可用状态以控制台实时返回为准。