云端 Mac CI 中避免 xcodebuild 管道吞错

CI/CD 实践 ·约 7 分钟阅读

云端 Mac CI 中避免 xcodebuild 管道吞错

构建日志接入 tee 之后,终端输出变得完整,CI 却偶尔把明显失败的 xcodebuild 标成成功。最危险的不是日志缺失,而是流水线继续执行上传、通知或部署步骤。根因通常很简单:shell 默认返回管道最后一个命令的退出码,tee 写入成功便覆盖了前面 xcodebuild 的失败状态。

先确认错误是怎样被吞掉的

下面这种写法很常见:

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -destination "$CI_DESTINATION" \
  2>&1 | tee build.log

管道包含两个进程。左侧运行测试,右侧把输出同时写入文件和标准输出。在未启用 pipefail 的 Bash 中,整条命令默认采用 tee 的状态。即使测试返回非零值,只要日志写入成功,脚本看到的仍可能是 0。

先在云端 Mac 上做一个最小验证:

/bin/bash -c 'exit 65' | tee /tmp/pipeline-check.log
printf 'pipeline=%s\n' "$?"

若输出 pipeline=0,就证明当前脚本存在误报条件。不要只检查日志中有没有 error:,因为编译器、测试框架和自定义脚本的输出格式都会变化。退出码才是 CI 判断任务成败的稳定接口。

日志用于解释失败,退出码用于宣布失败。两者必须分别保存,不能依赖文本搜索代替进程状态。

固定 Shell,再处理管道状态

macOS 的交互式默认 shell 与 CI 脚本实际解释器可能不同。Bash 使用 PIPESTATUS,zsh 使用 pipestatus,数组下标也不同。最稳妥的做法是在仓库脚本中声明解释器,并让流水线直接执行该文件,而不是把多行命令交给不确定的 shell。

#!/bin/bash
set -uo pipefail

-u 会拦截未定义变量,pipefail 会让管道中任一命令失败时整条管道返回非零。不过,单靠 pipefail 仍不够精确:如果 tee 因磁盘或权限问题失败,而 xcodebuild 本身成功,最终的 $? 只能说明管道失败,不能说明哪一段失败。

因此应在管道结束后立刻复制 PIPESTATUS。任何赋值、条件判断或日志命令都可能刷新它,晚一步读取就会得到另一条命令的状态。

编写可复用的 xcodebuild 包装脚本

下面的脚本同时保存原始日志、结果包和 xcodebuild 的真实退出码。它暂时关闭 errexit,避免 shell 在状态尚未保存前直接退出。

#!/bin/bash
set -uo pipefail

run_id="${CI_RUN_ID:-local-$(date +%s)}"
artifact_dir="${CI_ARTIFACT_DIR:-$PWD/artifacts/$run_id}"
result_path="$artifact_dir/TestResults.xcresult"
log_path="$artifact_dir/xcodebuild.log"

mkdir -p "$artifact_dir"

set +e
xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Debug \
  -destination "$CI_DESTINATION" \
  -resultBundlePath "$result_path" \
  2>&1 | tee "$log_path"

pipeline_status=("${PIPESTATUS[@]}")
xcode_status="${pipeline_status[0]}"
tee_status="${pipeline_status[1]}"
set -e

printf 'xcodebuild_exit=%s\n' "$xcode_status"
printf 'tee_exit=%s\n' "$tee_status"

if (( tee_status != 0 )); then
  printf 'Log capture failed\n' >&2
fi

exit "$xcode_status"

CI_DESTINATION 应由任务配置明确传入,例如绑定到已经验收过的模拟器目标。不要在脚本中模糊匹配第一个可用设备,否则运行时更新后可能选到不同系统版本。路径变量全部加引号,避免工作目录包含空格时参数被拆开。

这里选择把 xcodebuild 状态作为任务最终状态。如果日志落盘失败也必须阻断任务,可在退出前增加判断:任一状态非零就返回失败,同时把两个状态分别写入任务摘要。

把结果包当作诊断证据

控制台文本适合快速阅读,xcresult 则保留测试层级、失败附件、活动记录和诊断信息。两者用途不同,建议同时归档。

产物 主要用途 失败时是否保留
xcodebuild.log 搜索编译器输出与脚本日志 是
TestResults.xcresult 查看测试、附件与活动记录 是
exit-status.txt 记录各管道阶段状态 是
临时 DerivedData 现场复现需要时短期保留 按任务策略

若需要生成机器可读摘要,可使用系统自带的 xcresulttool。先检查结果包确实存在,再执行解析,避免解析失败覆盖原始构建状态。

if [[ -d "$result_path" ]]; then
  xcrun xcresulttool get test-results summary \
    --path "$result_path" \
    > "$artifact_dir/test-summary.json" || true
fi

printf 'xcodebuild=%s\ntee=%s\n' \
  "$xcode_status" "$tee_status" \
  > "$artifact_dir/exit-status.txt"

这里的 || true 只允许摘要生成失败后继续归档,不能放在 xcodebuild 主命令后面。辅助报告失败不应抹掉已经保存的主状态,主构建失败也不能被辅助命令改写成成功。

用异常场景验收,而不是只跑绿灯任务

包装脚本合入前,至少执行四类验收:

编译失败

临时引用一个不存在的符号。预期 xcodebuild 非零、CI 失败、日志存在,结果包是否生成则取决于失败阶段。

测试失败

加入一个确定失败的断言。预期编译完成但测试任务失败,xcresult 中能定位失败用例,流水线不得继续执行成功分支。

日志写入失败

把产物目录指向不可写位置,确认 tee_status 非零。若团队要求诊断产物完整才能交付,应让这个场景同样阻断任务。

中断与超时

由 CI 发出终止信号,确认没有后续发布步骤运行,并检查子进程是否被回收。不要把超时包装成普通测试失败,两者在重试策略和故障分析上含义不同。

最后检查任务定义本身:上传诊断产物的步骤可以设置为失败后仍执行,但部署、分发和成功通知必须依赖包装脚本返回的真实状态。这样即使输出过滤器、归档工具或摘要生成器发生变化,xcodebuild 的失败仍会沿着唯一、可验证的路径传回 CI。

常见问题

已经启用 set -o pipefail,为什么还要读取 PIPESTATUS?

pipefail 只能保证整条管道返回某个非零状态,无法明确区分 xcodebuild、tee 或后续过滤器是谁失败。立即读取 PIPESTATUS[0],才能把 xcodebuild 的真实退出码交还给 CI。

如何验证包装脚本不会再把失败构建报告为成功?

分别制造编译错误、测试断言失败和不可写日志目录三种情况,确认任务均为失败,同时检查原始日志与 xcresult 是否按预期保留。

独享物理 Mac

把下一项开发任务放到云端 Mac 上

选择 Berry M4 或 Berry M4 Pro,核对五个节点、租用周期和附加选项后提交订单。

选择配置并订购