云端 Mac 已经能通过 SSH 登录,xcodebuild -version 也能返回版本,但第一条无人值守任务仍可能停在许可证确认、附加组件安装或工具路径切换上。最麻烦的是,这类故障常在新节点、Xcode 升级或 runner 重建后才出现,手动打开一次图形界面又会暂时掩盖问题。更稳的做法是把首次启动初始化视为独立的节点交付步骤,而不是让正式构建任务边跑边修环境。
先区分节点初始化与构建任务
节点初始化可以修改系统状态,需要管理员权限,并且每个 Xcode 版本只执行一次。构建任务则应当可重复、无交互、尽量不使用提权命令。把两者混在同一个 CI 脚本里,会产生三个问题:多个任务同时安装组件、无人值守的 sudo 等待输入,以及失败后无法判断代码错误还是环境未完成。
建议把 runner 生命周期拆成三段:
- 安装或切换 Xcode,完成许可证与首次组件初始化。
- 运行只读预检,记录实际使用的开发者目录和工具版本。
- 预检通过后,runner 才进入可接收任务的状态。
“能够打开 Xcode”不是验收条件。真正的验收条件是 runner 用户在非交互式 Shell 中可以定位编译工具、查询 SDK,并完成一次最小工程解析。
固定实际使用的 Xcode 路径
机器上存在多个 Xcode 版本时,不要只依赖全局 xcode-select。它会影响同一物理节点上的其他任务,而且版本切换后容易留下“日志显示一个版本,实际调用另一个版本”的错觉。更适合 CI 的方式是在任务级固定 DEVELOPER_DIR。
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
printf 'developer_dir=%s
' "$DEVELOPER_DIR"
xcodebuild -version
xcrun --find clang
xcrun --find simctl
xcodebuild -showsdks
如果采用带版本号的应用目录,就把路径写入 runner 配置或受版本控制的环境文件,不要在多个脚本中复制。更新 Xcode 时只修改一个入口,并让预检输出路径。这样排查日志时可以先确认工具链身份,再分析项目本身。
不要用 GUI 状态代替 Shell 验证
远程桌面会话中的环境变量、默认 Shell 和权限上下文可能与 runner 服务不同。所有检查都应以运行构建的同一用户执行。尤其要检查 xcrun --find 的结果是否位于预期的 DEVELOPER_DIR 下,而不是只看应用目录是否存在。
一次性完成首次启动步骤
在节点准备阶段,先检查首次启动状态,再由具备管理员权限的初始化流程完成许可证确认和组件准备:
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
if ! xcodebuild -checkFirstLaunchStatus; then
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch
fi
xcodebuild -checkFirstLaunchStatus
这里的关键不是命令本身,而是执行位置。它不应出现在每次提交触发的构建脚本中。runFirstLaunch 可能修改共享组件;两个并发任务同时执行,既浪费时间,也可能让其中一个任务读到未完成状态。
许可证确认同样不能靠点击对话框解决。自动化环境应明确执行、检查退出码,并在失败时停止节点接单。若管理员命令需要交互式输入,应在节点交付阶段处理,不能把等待输入的流程留给后台 runner。
用原子锁保护并发初始化
自动扩容或多个服务同时启动时,即使初始化脚本只设计为执行一次,也可能被并发调用。macOS 默认工具足以用目录创建实现原子锁:
set -euo pipefail
lock_dir="/tmp/xcode-bootstrap.lock"
if ! mkdir "$lock_dir" 2>/dev/null; then
echo "Xcode bootstrap is already running" >&2
exit 75
fi
cleanup() {
rmdir "$lock_dir" 2>/dev/null || true
}
trap cleanup EXIT
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch
xcodebuild -checkFirstLaunchStatus
mkdir 在同一文件系统中具有原子性,只有一个进程能成功。退出码 75 可以交给调度层解释为临时失败,稍后重试,而不是把节点永久标记为构建故障。
还要处理异常退出留下的旧锁。可在节点启动流程中确认没有初始化进程后清理,或把锁目录放在重启后会清空的位置。不要看到锁就直接删除,否则可能破坏仍在运行的初始化。
建立正式接单前的预检门禁
首次启动状态通过后,再做一次不修改系统的验收。最低限度应覆盖 Xcode 身份、SDK 查询、模拟器工具和项目入口:
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
xcodebuild -checkFirstLaunchStatus
xcodebuild -version
xcodebuild -showsdks >/tmp/xcode-sdks.txt
xcrun simctl list runtimes
xcodebuild -list -workspace "Example.xcworkspace"
最后一行应替换为仓库真实的 workspace 或 project。它比直接执行完整 archive 更快,却能提前发现工程入口不存在、依赖解析前置条件不满足或 runner 工作目录错误。
失败时保留哪些证据
诊断日志至少保留 DEVELOPER_DIR、xcodebuild -version、xcrun --find clang、xcrun simctl list runtimes 和失败命令的退出码。不要在日志中输出环境变量全集,因为其中可能包含令牌、签名密码或仓库凭据。
常见判断顺序是:先确认路径,再检查首次启动状态,然后检查所需 SDK 与 runtime,最后才进入工程配置。若切换了 Xcode 版本,即使旧版本已经初始化,也应针对新的 Developer 目录重新执行这套流程。完成后,把初始化脚本版本与 Xcode 版本一起记录,runner 重建时便能按同一顺序恢复,而不依赖某个人曾经手动打开过什么界面。
常见问题
为什么 Xcode 在图形界面里能打开,CI 仍会卡在首次启动检查?
图形会话与非交互式任务可能使用不同的环境和 Xcode 路径。应在 runner 使用的 DEVELOPER_DIR 下执行 xcodebuild -checkFirstLaunchStatus,并在管理员初始化阶段完成 runFirstLaunch。
可以在每个 CI 任务里执行 sudo xcodebuild -runFirstLaunch 吗?
不建议。它应在节点初始化或 Xcode 版本切换后执行一次,正式任务只做只读预检;否则并发任务可能争用组件安装流程,且无人值守 sudo 会造成新的阻塞。
切换 Xcode 版本后需要重新初始化吗?
需要按新的 Developer 目录重新检查。许可证状态、工具组件和模拟器运行时都可能随版本变化,不能沿用旧版本的通过标记。
需要一台独享 Apple Silicon 构建机?
按天、周、月或季租用独享物理节点,用于 Xcode 构建、iOS CI、远程开发和自动化任务。