云端 Mac CI 的 Xcode Destination 固定实战

云端 Mac CI 的 Xcode Destination 固定实战

同一条 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 配置和四个机房中选择,资源不与其他租户共享,实际可用状态以控制台实时返回为准。

选择租用方案