流水线显示成功,但下载到的 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 的完整退出状态和输出,同时避免把敏感环境打印出来。提交支持请求前,只截取相关时间范围,并移除仓库令牌、签名材料路径中的内部标识及连接凭据。
交付前检查清单
在流水线标记成功前,逐项确认:
- 归档中的 App 已通过严格代码签名验证。
- IPA 由当前任务的归档导出,而非复用旧目录。
GIT_COMMIT是完整提交哈希,且与触发记录一致。SHA256SUMS在暂存目录和最终目录各验证一次。- 最终目录以唯一任务标识命名,不被重试覆盖。
- 消费方只读取已发布目录或带完成标记的对象。
- 日志可定位失败阶段,但不包含凭据和签名材料内容。
这套流程不会消除所有构建失败,却能把“编译是否成功”和“交付物是否可信”拆成两个可验证问题。归档、校验、上下文和发布边界固定后,重试与回滚都只处理明确对象,不再依赖某个不断变化的共享目录。
常见问题
只有 SHA-256 校验值,能证明 iOS 产物来自可信构建机吗?
不能。SHA-256 只能证明文件在两个检查点之间是否发生变化,不能单独证明来源。还应验证 App 代码签名,并把提交版本、Xcode 版本、任务标识和清单一起保存。
为什么发布目录要先写临时目录再重命名?
同一文件系统内的目录重命名可以让读取方只看到完整版本,避免上传或复制到一半时被误取。跨文件系统时应先复制、重新校验,再在目标卷内完成重命名。
Dedicated cloud Mac
在独享物理节点上运行构建任务
按天、周、月或季选择 MiniRents M4 与 MiniRents M4 Pro,并按团队和代码仓库位置选择节点。设备为独享物理机、非虚拟机。