云端 Mac CI 挂载 SMB 共享存储:避免构建污染与交付半成品

云端 Mac CI 挂载 SMB 共享存储:避免构建污染与交付半成品

CI 节点完成归档后,需要把产物交给测试、发布或制品保留任务。最容易想到的做法,是把 SMB 共享目录直接设为工作区,让多台云端 Mac 读写同一份工程。这样做在小仓库里可能暂时正常,但一旦遇到网络抖动、文件锁延迟或挂载失效,Xcode 的中间文件就可能损坏,甚至把结果写进同名的本地空目录。

更稳妥的边界是:SMB 只负责接收输入快照和最终产物,源码展开、依赖解析、DerivedData 与临时文件全部留在本地磁盘。

先划分三类目录

每个任务至少需要三个互不混用的路径:

  • 只读输入区:保存源码快照或固定版本的依赖包。
  • 本地工作区:承载检出后的源码、DerivedData、测试结果和临时文件。
  • 远端交付区:只保存已经完成校验的归档、日志摘要与清单。

不要让两个并发任务共用 DerivedData。即使它们构建同一提交,索引、模块缓存和构建数据库也可能同时改写。建议把任务标识加入路径,例如 ~/ci-work/$RUN_ID,任务结束后再按保留策略清理。

网络共享应被视为交付边界,而不是本地磁盘的透明替代品。构建成功与上传成功也必须是两个独立状态。

挂载由常驻进程管理

无人值守任务不应在命令行参数中拼接用户名和密码。更合适的方式是由受控的常驻进程预先挂载 SMB,并从单独的钥匙串或受限配置中读取凭据;普通构建任务只获得目标目录的最小写入权限。

任务开始时不能只检查目录是否存在。SMB 断开后,挂载点仍可能退化为普通目录。至少检查文件系统类型、可写性和一次小文件往返:

set -euo pipefail

SHARE_ROOT="/Volumes/ci-artifacts"
PROBE="$SHARE_ROOT/.probe-${RUN_ID}"

fs_type="$(stat -f '%T' "$SHARE_ROOT")"
test "$fs_type" = "smbfs"
test -w "$SHARE_ROOT"

printf '%s\n' "$RUN_ID" > "$PROBE"
test "$(cat "$PROBE")" = "$RUN_ID"
rm -f "$PROBE"

任何一步失败都应终止任务。不要自动创建挂载点后继续执行,否则一次共享断线就可能变成“构建成功但产物只留在本机”。

把构建活动限制在本地

先将固定提交同步到本地临时目录,再执行依赖解析和构建。任务目录、DerivedData 与结果包都应包含唯一任务标识,避免并行任务互相覆盖。

LOCAL_ROOT="$(mktemp -d "$TMPDIR/vmcache-ci.XXXXXX")"
trap 'rm -rf "$LOCAL_ROOT"' EXIT

rsync -a --delete "$SOURCE_SNAPSHOT/" "$LOCAL_ROOT/repo/"

xcodebuild \
  -workspace "$LOCAL_ROOT/repo/App.xcworkspace" \
  -scheme App \
  -configuration Release \
  -derivedDataPath "$LOCAL_ROOT/DerivedData" \
  -resultBundlePath "$LOCAL_ROOT/TestResults.xcresult" \
  build

这里的 SOURCE_SNAPSHOT 应对应不可变提交,而不是会被其他任务继续修改的活动目录。若输入来自共享卷,可先核对提交号或清单,再开始同步。

不要缓存整个 DerivedData

跨任务复制完整 DerivedData 往往得不偿失:缓存体积大,内部路径和构建设置又可能变化。更容易维护的做法是只缓存明确可复用的依赖下载目录,并把 Xcode 版本、架构、锁文件摘要写入缓存键。缓存键不匹配时重新生成,不做模糊复用。

用临时名完成原子交付

直接复制为正式文件名会暴露半成品。下游任务看到文件出现后可能立即读取,而此时数据仍在传输。正确顺序是:本地打包、本地计算摘要、复制到共享卷临时名、在共享卷校验,最后在同一目录改名。

ARTIFACT="$LOCAL_ROOT/App-release.zip"
ditto -c -k --norsrc "$LOCAL_ROOT/DerivedData/Build/Products/Release" "$ARTIFACT"

REMOTE_DIR="$SHARE_ROOT/releases/$GIT_COMMIT"
REMOTE_TMP="$REMOTE_DIR/.App-release.zip.${RUN_ID}.partial"
REMOTE_FINAL="$REMOTE_DIR/App-release.zip"

mkdir -p "$REMOTE_DIR"
cp "$ARTIFACT" "$REMOTE_TMP"

local_hash="$(shasum -a 256 "$ARTIFACT" | awk '{print $1}')"
remote_hash="$(shasum -a 256 "$REMOTE_TMP" | awk '{print $1}')"
test "$local_hash" = "$remote_hash"

mv "$REMOTE_TMP" "$REMOTE_FINAL"
printf '%s  %s\n' "$remote_hash" "App-release.zip" \
  > "$REMOTE_DIR/SHA256SUMS.${RUN_ID}"

原子改名的前提是临时文件与正式文件位于同一共享卷,最好也在同一目录。不要先写到本地再跨卷 mv,跨卷操作会退化为复制与删除。

让失败可判断也可清理

上传失败后应保留本地构建结果一段受控时间,便于重新传输,但不能把任务标记成完整交付。建议分别记录 build_completedartifact_verifiedpublish_completed,下游只接受最后一个状态。

还要定期清理超过保留期的 .partial 文件。清理任务必须确认文件名格式和修改时间,只处理临时后缀,不扫描或删除正式产物。若共享卷出现只读、校验不一致或反复断开,应停止发布队列,先保全本地结果和挂载诊断信息。

在 VMCache 的云端 Mac 上实施这套流程时,关键不在某条挂载命令,而在边界清晰:网络卷负责交换,本地盘负责构建,正式文件名只代表已经校验完成的产物。这样即使 SMB 短暂异常,构建现场和交付状态也不会混在一起。

常见问题

可以把 Xcode 工程和 DerivedData 都放在 SMB 共享目录中吗?

不建议。网络卷适合输入快照和最终产物,不适合承载大量小文件、锁文件与高频元数据更新。源码应同步到本地工作区,DerivedData、临时目录和依赖缓存也应保留在本地磁盘。

怎样避免下游任务读到尚未上传完成的构建产物?

先把产物写入共享卷上的临时文件名,生成校验和与完成清单,确认写入成功后再在同一共享卷、同一目录中改名为正式文件。下游只读取正式文件和完成清单。

SMB 挂载失败时应该自动继续构建吗?

不应该。任务应先检查挂载类型、目标目录可写性和一次小文件往返测试;任何一项失败都立即终止,避免产物误写到本地同名空目录。

独享物理节点

为构建、测试与 MLX 推理选择云端 Mac

按实际任务选择 M4、内存、存储、节点与租期。每份租用对应独享物理机,非虚拟机。

选择租用方案