Engineering guide

云端 Mac 的 iOS 构建产物完整性校验与原子发布

云端 Mac 的 iOS 构建产物完整性校验与原子发布

流水线显示成功,但下载到的 IPA 无法对应触发它的提交;另一种更危险的情况是上传中断后,后续任务拿走了只写入一半的文件。云端 Mac 上的编译、导出和上传通常由不同脚本负责,如果只把“命令退出码为 0”当成交付标准,这两类问题很难在当次任务中暴露。

解决办法不是增加一个压缩命令,而是把产物定义为一个不可拆分的集合:IPA、SHA-256 清单、构建上下文和验证结果必须一起生成,并且只在全部检查通过后公开。

先定义可交付产物

每次任务使用唯一的 BUILD_ID。交付目录至少包含以下内容:

文件 用途 验收方式
App.ipa 安装与分发产物 哈希一致,导出命令成功
SHA256SUMS 内容完整性清单 shasum -a 256 -c 返回成功
build-context.tsv 追溯构建来源 提交、Xcode 与任务标识非空
export.log 定位导出失败 保存完整输出并限制访问权限

构建上下文应来自流水线已经确定的变量,而不是在脚本中临时猜测分支。至少记录完整提交哈希、任务标识、Scheme、Xcode 版本和生成时间。时间只用于关联日志,判断产物身份仍以提交哈希和校验值为准。

哈希可以证明文件在两个检查点之间没有变化,但不能证明文件最初来自可信流程。代码签名验证和构建上下文缺一不可。

导出前验证归档

先检查归档是否存在,再定位其中的 .app 并验证代码签名。不要使用 --deep 重新签名;这里的目标只是检查现有签名结构。若应用包含扩展,应让验证命令覆盖嵌套代码,并把失败视为阻断条件。

归档、导出目录和最终目录应放在同一工作卷中。这样最后的目录重命名才具备原子性。MiniRent 云端 Mac 上执行时,也应让每个并发任务拥有独立根目录,避免两个任务共用 out/latest 之类的固定路径。

一段可直接改造的脚本

set -euo pipefail

: "${BUILD_ID:?BUILD_ID is required}"
: "${GIT_COMMIT:?GIT_COMMIT is required}"
: "${SCHEME:?SCHEME is required}"

ROOT="${ARTIFACT_ROOT:-$PWD/out}"
ARCHIVE="$ROOT/input/App.xcarchive"
EXPORT_OPTIONS="$ROOT/input/ExportOptions.plist"
STAGE="$ROOT/.stage-$BUILD_ID"
FINAL="$ROOT/releases/$BUILD_ID"

rm -rf "$STAGE"
mkdir -p "$STAGE" "$(dirname "$FINAL")"
trap 'rm -rf "$STAGE"' EXIT

test -d "$ARCHIVE"
test -f "$EXPORT_OPTIONS"
test ! -e "$FINAL"

APP_PATH="$(find "$ARCHIVE/Products/Applications" -maxdepth 1 -name '*.app' -print -quit)"
test -n "$APP_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"

xcodebuild -exportArchive \
  -archivePath "$ARCHIVE" \
  -exportPath "$STAGE/export" \
  -exportOptionsPlist "$EXPORT_OPTIONS" \
  >"$STAGE/export.log" 2>&1

IPA_PATH="$(find "$STAGE/export" -maxdepth 1 -name '*.ipa' -print -quit)"
test -n "$IPA_PATH"
cp "$IPA_PATH" "$STAGE/App.ipa"

DIGEST="$(shasum -a 256 "$STAGE/App.ipa" | awk '{print $1}')"
printf '%s  %s
' "$DIGEST" "App.ipa" >"$STAGE/SHA256SUMS"

XCODE_VERSION="$(xcodebuild -version | paste -sd ' ' -)"
printf 'build_id	%s
commit	%s
scheme	%s
xcode	%s
' \
  "$BUILD_ID" "$GIT_COMMIT" "$SCHEME" "$XCODE_VERSION" \
  >"$STAGE/build-context.tsv"

(
  cd "$STAGE"
  shasum -a 256 -c SHA256SUMS
)

mv "$STAGE" "$FINAL"

(
  cd "$FINAL"
  shasum -a 256 -c SHA256SUMS
)

脚本中的 ExportOptions.plist 应由仓库受控配置提供。不要在日志或上下文文件中写入口令、私钥、临时令牌及环境变量全集。

用原子发布隔离半成品

关键点是先在隐藏的临时目录中完成全部写入和校验,再通过一次 mv 暴露最终目录。读取方只扫描 releases/,不访问 .stage-*,因此不会拿到正在生成的文件。

这个保证只在同一文件系统内成立。如果发布目录挂载在另一个卷,mv 可能退化为复制与删除。正确做法是在目标卷创建临时目录,复制全部文件,在目标端重新运行哈希检查,最后于目标卷内部重命名。对象存储或制品服务没有目录重命名语义时,可先上传带任务标识的临时键,校验后再写一个很小的完成标记;消费方必须先检查标记,再读取产物。

不要覆盖 latest

latest 容易让重试任务覆盖较新的成功结果。稳定做法是以不可变的 BUILD_ID 保存产物,再单独维护一个只包含目标任务标识的指针文件。更新指针前先确认目标目录通过校验,回滚时也只改指针,不修改历史产物。

把失败留在正确阶段

签名错误应停在导出前,导出错误应停在临时目录,哈希错误应停在发布前。上传后的再次校验则用于发现传输、磁盘或脚本选错文件造成的变化。

常见误区包括从导出目录取“第一个文件”却不限制扩展名、多个 Scheme 共用同一输出路径,以及重试时沿用上一次留下的临时目录。脚本应在开始时清理本任务的暂存目录,但不得用宽泛通配符删除其他任务目录。

对日志也要设边界。保留 xcodebuild 的完整退出状态和输出,同时避免把敏感环境打印出来。提交支持请求前,只截取相关时间范围,并移除仓库令牌、签名材料路径中的内部标识及连接凭据。

交付前检查清单

在流水线标记成功前,逐项确认:

  1. 归档中的 App 已通过严格代码签名验证。
  2. IPA 由当前任务的归档导出,而非复用旧目录。
  3. GIT_COMMIT 是完整提交哈希,且与触发记录一致。
  4. SHA256SUMS 在暂存目录和最终目录各验证一次。
  5. 最终目录以唯一任务标识命名,不被重试覆盖。
  6. 消费方只读取已发布目录或带完成标记的对象。
  7. 日志可定位失败阶段,但不包含凭据和签名材料内容。

这套流程不会消除所有构建失败,却能把“编译是否成功”和“交付物是否可信”拆成两个可验证问题。归档、校验、上下文和发布边界固定后,重试与回滚都只处理明确对象,不再依赖某个不断变化的共享目录。

常见问题

只有 SHA-256 校验值,能证明 iOS 产物来自可信构建机吗?

不能。SHA-256 只能证明文件在两个检查点之间是否发生变化,不能单独证明来源。还应验证 App 代码签名,并把提交版本、Xcode 版本、任务标识和清单一起保存。

为什么发布目录要先写临时目录再重命名?

同一文件系统内的目录重命名可以让读取方只看到完整版本,避免上传或复制到一半时被误取。跨文件系统时应先复制、重新校验,再在目标卷内完成重命名。

Dedicated cloud Mac

在独享物理节点上运行构建任务

按天、周、月或季选择 MiniRents M4 与 MiniRents M4 Pro,并按团队和代码仓库位置选择节点。设备为独享物理机、非虚拟机。

选择机型并订购