先不要删除 DerivedData,也不要直接重建远程节点。xcodebuild Exit Code 65 只是构建或测试命令失败后的终止状态,不对应一条通用修复命令;本周先保留完整命令、原始日志和 xcresult,再锁定第一个失败动作。只有当同一仓库、同一命令在节点重启或干净工作区后仍无法稳定复现,才进入节点隔离或重建。
这篇内容适合 3 类人:本地 Xcode 构建成功、远程 Mac CI 却失败的应用开发者;维护共享 macOS 构建节点和 Xcode 工具链的 DevOps 工程师;需要划分编译、测试、签名和归档责任边界的发布工程师。
先固定故障时间线,再处理退出状态
把一次失败拆成 4 个时间点,而不是只看流水线最后一行:
- 命令启动时间:记录执行账户、工作目录、Xcode 路径、Git 提交、依赖锁定文件和完整
xcodebuild命令。 - 第一个异常时间:找出最早出现的
error:、依赖解析失败、脚本非零退出、Destination 不可用或签名错误。 - 动作结束时间:确认故障发生在
build、test、archive还是exportArchive。 - 流水线包装时间:最后出现的
Exit Code 65、任务失败摘要或 CI 平台错误,只作为结果记录,不作为根因。
Apple 的命令行测试说明显示,xcodebuild test 需要同时指定 Scheme 和 Destination;测试失败时会返回非零退出码。因此,退出码本身无法告诉你究竟是编译、启动模拟器还是测试断言失败。你应保存Apple 的 xcodebuild 命令行说明,并把命令和输出作为一次可复现样本。
建议在 CI 脚本开头输出这些环境证据:
set -o pipefail
date
whoami
pwd
xcode-select -p
xcodebuild -version
xcodebuild -showsdks
env | sort
xcodebuild \
-workspace "<WORKSPACE_PATH>" \
-scheme "<SCHEME_NAME>" \
-configuration "<CONFIGURATION>" \
-destination 'platform=iOS Simulator,id=<SIMULATOR_UDID>' \
-resultBundlePath "<RESULT_BUNDLE_PATH>" \
test 2>&1 | tee "<RAW_LOG_PATH>"
-resultBundlePath 生成的 xcresult 不只是测试摘要。Apple 文档说明,测试结果包可以包含会话结果、代码覆盖率和其他日志;你可以在 Xcode 中打开,也可以用 xcresulttool 导出 JSON。详见Apple 的测试结果文档。
xcrun xcresulttool get \
--format json \
--path "<RESULT_BUNDLE_PATH>" \
> "<RESULT_JSON_PATH>"
这里要区分 3 种信息:
- 首个真实错误:最早导致后续动作无法继续的失败。
- 连锁报错:因为前一步没有产物,后面出现的文件不存在、模块找不到或测试无法安装。
- CI 包装信息:平台把命令非零退出包装成统一的 Exit Code 65。
如果你只复制流水线末尾的 10 行,最关键的首个错误往往已经被截断。
Workspace、Scheme 与参数:本地成功不等于 CI 使用了同一套配置
本地 Xcode 点击运行时,IDE 会替你选择项目、Scheme、Configuration、SDK 和运行目标。远程 Mac CI 则完全依赖脚本中的显式参数或默认值,任何一个差异都可能让相同源码走上另一条构建路径。
先执行:
xcodebuild -list \
-workspace "<WORKSPACE_PATH>"
xcodebuild \
-workspace "<WORKSPACE_PATH>" \
-scheme "<SCHEME_NAME>" \
-showBuildSettings \
-configuration "<CONFIGURATION>"
重点核对以下内容:
- CI 使用的是
.xcworkspace还是.xcodeproj; - Scheme 是否已共享并提交到仓库;
Debug、Release或自定义 Configuration 是否一致;- SDK、架构、签名方式和部署目标是否一致;
- Destination 是否属于当前 Scheme 支持的平台;
- 脚本是否额外传入了
CODE_SIGN_STYLE、SDKROOT、ARCHS等参数。
Apple 明确说明,使用 xcodebuild 时,命令行传入的 Build Settings 具有最高优先级。也就是说,项目文件里看起来正确的配置,可能已经被 CI 脚本覆盖。你可以参考Apple 的 Build Settings 优先级说明,再检查 CI 配置文件、环境变量和脚本参数。
不要一开始就执行 clean。先做一次最小化重跑,把复杂流水线拆成:
xcodebuild \
-workspace "<WORKSPACE_PATH>" \
-scheme "<SCHEME_NAME>" \
-configuration "<CONFIGURATION>" \
-destination 'generic/platform=iOS' \
build
如果最小构建成功,而原始测试命令失败,问题就不应继续归类为“编译失败”。如果最小构建也失败,再回到首个错误检查项目、Scheme 和参数。
条件分支:什么时候修参数,什么时候回退
- 若
-showBuildSettings与本地关键值不同,先修 CI 参数,不清缓存。 - 若 Scheme 不在
xcodebuild -list输出中,先共享 Scheme 并提交对应文件。 - 若 build 成功、test 失败,转向 Destination、测试目标和模拟器会话。
- 若 archive 成功、export 失败,转向签名身份、描述文件和导出配置。
- 若命令行显式参数覆盖项目设置,先移除冲突参数,再做重复验证。
- 若同一命令在干净工作区和残留工作区表现不同,只清理该工作区,不要立即重建整台 Mac。
Swift Package 与 Run Script:依赖失败常被误判为编译错误
远程 Mac CI 中,依赖解析和构建脚本通常发生在真正编译之前。只要私有 Swift Package 无法访问、Package.resolved 未提交,或者脚本没有生成预期输入文件,后面的编译错误都可能只是连锁结果。
Apple 建议将 Package.resolved 提交到 Git,并在直接调用 xcodebuild 的 CI 中使用 -disableAutomaticPackageResolution,让构建使用锁定版本,而不是在节点上临时解析依赖。私有依赖还需要为执行 CI 任务的 macOS 用户配置凭据、SSH 密钥和 known_hosts。详见Apple 的持续集成依赖指南。
检查顺序应当是:
- 确认
Package.resolved在仓库中,且没有被.gitignore排除。 - 确认 CI 用户的
HOME与你以 SSH 登录时使用的用户一致。 - 检查
~/.ssh/config、私钥权限和known_hosts。 - 用同一执行账户测试私有仓库连通性。
- 检查网络代理、URL 重写和 Git 工具链是否与本地一致。
- 再运行带锁定依赖参数的最小命令。
示例:
xcodebuild \
-workspace "<WORKSPACE_PATH>" \
-scheme "<SCHEME_NAME>" \
-destination 'generic/platform=iOS' \
-disableAutomaticPackageResolution \
build
如果项目依赖私有 Git 地址,Apple 说明 xcodebuild 默认使用 Xcode 内置 Git 工具;需要应用 Mac 系统 Git 配置时,可以使用 -scmProvider system。这对代理、URL 重映射和高级 SSH 配置尤其重要。
Run Script Phase 则要看 4 项证据:
- 脚本的实际退出状态;
- 脚本启动时的工作目录和 Shell;
- 输入文件是否真实存在;
- 脚本是否依赖交互式环境、未导出的变量或本地路径。
不要因为日志最后出现“CompileSwift failed”就认定 Swift 编译器是根因。若最早错误来自脚本下载、生成代码或复制资源,应先修脚本的输入和执行账户。
Simulator 与测试目标:构建通过仍可能返回 65
Simulator 故障的关键是区分“应用没有编译出来”和“应用编译出来但测试无法启动”。
先检查节点上可用的设备和运行时:
xcrun simctl list devices
xcrun simctl list runtimes
再确认 CI 中的 Destination 与 Scheme 支持关系:
xcodebuild \
-workspace "<WORKSPACE_PATH>" \
-scheme "<SCHEME_NAME>" \
-showdestinations
Apple 的测试文档要求 xcodebuild test 指定测试目标;测试可以运行在不同 Simulator、设备或 Mac 目标上。Destination 中的设备名称、系统版本或 UDID 只要有一项不匹配,测试动作就可能在启动阶段失败,而不是在编译阶段失败。
你的判断顺序可以这样安排:
- 日志没有编译错误,但出现设备不可用:检查运行时是否安装、设备是否已创建、UDID 是否仍有效。
- 应用已生成,但安装或启动失败:查看
xcresult中的测试会话、目标设备和安装日志。 - 部分测试目标运行,部分目标跳过:检查 Scheme 的 Test Action、测试计划和目标平台。
- Simulator 能手动打开,但 CI 仍失败:比较图形会话与 SSH 执行账户,确认测试进程是否在可用的用户会话中。
- 修改 Destination 后成功:不要马上把新设备写死;先确认它确实属于项目支持矩阵。
远程 SSH 场景尤其容易忽略用户会话。Apple 的自动化测试说明指出,Simulator 本身是 macOS 应用,某些通过 SSH 或后台服务启动的测试需要正确的图形用户会话。你可以参考Apple 关于 SSH 与测试会话的说明,核对远程登录方式和执行账户。
修复后必须用同一个 Destination重复运行,而不是改成一个更容易成功的设备。否则你验证的只是另一条测试路径。
签名、归档与导出:不要用关闭签名掩盖发布问题
只有当第一个有效错误明确指向签名时,才检查 Team、证书私钥、描述文件、钥匙串和 Bundle ID。签名错误常见于本地图形会话成功、远程 CI 用户却无法访问数字身份的情况。
先把流程拆成两个动作:
xcodebuild \
-workspace "<WORKSPACE_PATH>" \
-scheme "<SCHEME_NAME>" \
-configuration "Release" \
-archivePath "<ARCHIVE_PATH>" \
archive
归档成功后,再单独导出:
xcodebuild \
-exportArchive \
-archivePath "<ARCHIVE_PATH>" \
-exportPath "<EXPORT_PATH>" \
-exportOptionsPlist "<EXPORT_OPTIONS_PLIST>"
Apple 将归档与导出定义为两个阶段:先通过 archive 生成包含构建信息的 Xcode Archive,再通过 exportArchive 按分发配置导出应用。导出选项的可用键可以通过 xcodebuild -help 查看。
按以下顺序取证:
security find-identity -v -p codesigning是否能看到期望的身份。- 执行账户是否能访问导入证书的钥匙串。
- 私钥是否同时存在,而不是只有公钥证书。
- 描述文件是否匹配 Team、Bundle ID、用途和设备类型。
- Archive 阶段失败,还是 Export 阶段失败。
exportOptionsPlist是否被 CI 中另一份文件覆盖。
不要把 CODE_SIGNING_ALLOWED=NO 当成发布任务的通用修复。它可以帮助你判断错误是否发生在签名阶段,但会改变构建语义;一旦任务目标是归档、TestFlight 或正式分发,关闭签名只会把问题推迟到更后面。
如果你要保存可追溯证据,还应保留 Archive、导出日志和对应的 dSYM。Apple 说明,归档会保存应用二进制和调试信息;不同 Xcode 版本或 Build Settings 生成的二进制,其 UUID 可能不同,不能随意混用符号文件。详见Apple 的调试信息构建说明。
Xcode 路径与节点状态:修复还是重建要看复现矩阵
远程 Mac CI 的最后一层不是“这台机器今天能不能跑通”,而是“重启后、换工作区后、由同一账户执行时,结果是否仍然一致”。
先核对当前命令行工具指向:
xcode-select -p
xcodebuild -version
xcrun --find xcodebuild
如果节点安装了多个 Xcode,还要检查 CI 是否设置了 DEVELOPER_DIR:
echo "$DEVELOPER_DIR"
env DEVELOPER_DIR="/Applications/<XCODE_APP>.app/Contents/Developer" \
xcodebuild -version
Apple 文档说明,可以在 Xcode 设置或命令行中选择命令行工具所使用的 Xcode;多版本共存时,当前 Shell、系统默认值和 CI 环境变量可能并不相同。详见Apple 的命令行工具配置说明。
节点复测建议记录 4 次结果:
- 原命令失败:保留原始日志和
xcresult。 - 最小命令结果:去掉无关脚本、测试目标或导出动作。
- 修复后重复结果:使用相同仓库、相同参数和相同 Destination。
- 重启后结果:确认修复不是临时会话、钥匙串解锁或模拟器残留造成的假成功。
条件分支:该修项目、隔离节点还是重建
- 若同一节点的最小命令稳定失败,而其他节点成功:先隔离该节点,检查 Xcode 路径、运行时、磁盘空间、账户权限和工作区残留。
- 若只有某个工作区失败,干净检出后成功:清理或重建该工作区,不必重建整台 Mac。
- 若重启后失败,手动登录后成功:优先修复会话、钥匙串或后台任务的启动方式。
- 若不同节点都在同一个首个错误处失败:问题更可能在项目参数、依赖、Simulator 或签名配置。
- 若节点状态无法稳定复现,且 Xcode、运行时和账户配置已漂移:再考虑重建或重新交付节点。
如果你需要一台拥有完整权限、可按周期使用并能反复重置的真实 Mac 来复测同一仓库,可以先查看VMSPIN 的远程 Mac 使用入口。这类环境更适合验证“问题来自项目还是节点”,但它不能替你修复错误的 Scheme、私有依赖或签名配置。
交付前的远程 Mac CI 验收节点
在把节点加入长期流水线前,至少完成下面这组验收:
- [ ] 固定
xcode-select或DEVELOPER_DIR,并记录 Xcode 版本。 - [ ] 用 CI 实际执行账户运行
xcodebuild -list。 - [ ] 用固定 Scheme 和固定 Destination 完成一次最小构建。
- [ ] 用同一 Destination 完成
build-for-testing与test-without-building。 - [ ] 保存原始日志、
xcresult、Archive 和导出日志。 - [ ] 验证私有 Swift Package、SSH 凭据和
known_hosts。 - [ ] 验证证书、私钥、描述文件和钥匙串在无人值守环境可访问。
- [ ] 重启节点后重复至少一次关键命令。
- [ ] 检查工作区残留、磁盘状态和模拟器运行时。
- [ ] 为失败命令设置唯一的结果目录,避免多个任务互相覆盖
xcresult。
完成这份验收后,你才有足够证据判断 Exit Code 65 是项目问题,还是节点问题。若还没有完成最小复现和重启复测,直接换机器通常只是把故障带到下一台 Mac。
如果你当前依赖的是临时云端 Linux、个人 Mac 或一台长期无人维护的 Mac mini 服务器,常见缺点是无法稳定提供 macOS 专属工具链、权限与钥匙串状态不一致、节点重启后环境漂移,以及多人共享时难以保留独立工作区。对于需要短期排障、版本隔离或复测签名流程的场景,租赁一台具备完整权限、可重置的真实 Mac,通常比继续修改一台状态不明的机器更容易得到可信结论。你可以先对照VMSPIN 的方案与周期信息,确认它是否适合临时算力、迁移验证或独立 CI 节点;长期稳定重负载、必须接入专用物理设备的任务,则应继续评估自购 Mac 或专用机房方案。