云端 Mac CI 的 Git Submodule 可复现检出实战

云端 Mac CI 的 Git Submodule 可复现检出实战

一套 iOS 工程在开发机上可以正常克隆,放进云端 Mac CI 后却可能卡在子模块:主仓库已经检出,私有组件报权限错误,嵌套依赖停在旧提交,或者浅克隆找不到指定对象。问题通常不在 Xcode,而在检出过程没有明确区分“主仓库当前分支”和“主仓库记录的子模块提交”。

先确认可复现边界

Git Submodule 在主仓库中保存的是路径、远端地址和一个精确提交。可复现构建的目标,是相同主仓库提交始终得到相同子模块对象,而不是每次追踪子模块分支的最新状态。

先在开发环境记录基线:

git submodule status --recursive
git config --file .gitmodules --get-regexp 'submodule\..*\.path'
git config --file .gitmodules --get-regexp 'submodule\..*\.url'

状态行开头的 - 表示尚未初始化,+ 表示工作区提交与主仓库记录不一致,U 表示存在合并冲突。进入 CI 前,这三种状态都不应被当作正常结果。

构建任务不要运行 git submodule update --remote。它会跟随配置分支移动,使同一个主仓库提交在不同时间取得不同依赖。

相对地址适合同一代码托管边界内的仓库迁移,但它会根据主仓库远端地址解析。若任务临时改写了 origin,应在初始化前执行 git submodule sync --recursive,并打印脱敏后的实际地址核对解析结果。

建立统一的检出入口

不要把 clone --recurse-submodules、手工进入目录拉取和构建脚本中的补救命令混在一起。建议让 CI 先取得主仓库,再用一个入口处理所有层级:

set -euo pipefail

git submodule sync --recursive
git submodule update --init --recursive --jobs 4

expected_file="$(mktemp)"
actual_file="$(mktemp)"
trap 'rm -f "$expected_file" "$actual_file"' EXIT

git ls-tree -r HEAD |
  awk '$2 == "commit" { print $3, $4 }' |
  sort > "$expected_file"

git submodule status --recursive |
  sed -E 's/^[ +\-U]([0-9a-f]+) ([^ ]+).*/\1 \2/' |
  sort > "$actual_file"

diff -u "$expected_file" "$actual_file"

这里的 --jobs 4 只是限制并行网络请求,不是固定性能结论。节点网络、远端限流和子模块数量不同,应从较低并发开始,再根据失败率调整。

脚本还应在构建前执行 git diff --submodule=log --exit-code。如果某个安装步骤偷偷修改了子模块工作区,任务应立即失败,而不是带着未知源码继续归档。

谨慎使用浅克隆

浅克隆能减少传输量,却也是“远端明明有提交,CI 就是找不到”的常见原因。主仓库记录的子模块对象可能早于当前浅层边界,也可能只通过已删除分支的历史可达。

场景 建议策略 失败时处理
子模块提交接近分支顶部 使用有限深度检出 增加深度后重试
历史跨度不固定 子模块完整检出 不依赖固定深度猜测
多层嵌套子模块 逐层验证提交 输出失败路径与对象 ID
远端对象已不可达 修复仓库引用 不用切换分支掩盖问题

若必须保留浅克隆,可先尝试:

git submodule update --init --recursive --depth 50

失败后不要直接改成 --remote。应进入报错路径,执行 git fetch --deepen=100,再验证 git cat-file -e <commit>^{commit}。仍不存在时,说明需要检查远端对象保留情况或主仓库记录,而不是继续增加随机重试。

隔离私有子模块凭据

子模块可能使用不同权限边界。最稳妥的做法是给每个任务建立临时 Git 配置,只在进程生命周期内传入,不修改节点的全局配置。

git_config="$(mktemp)"
chmod 600 "$git_config"
trap 'rm -f "$git_config"' EXIT

git config --file "$git_config" credential.helper ""
git config --file "$git_config" protocol.version 2

GIT_CONFIG_GLOBAL="$git_config" \
git submodule update --init --recursive

认证值应由 CI 的受保护变量或短期文件提供,不能写进 .gitmodules、远端地址、命令参数或构建日志。任务结束后至少检查:

git config --global --list
git remote -v
git submodule foreach --recursive 'git remote -v'

检查输出前要做脱敏。若凭据曾被拼进 URL,仅删除临时文件还不够,还要恢复远端地址,并检查 Shell 历史、诊断包和缓存目录。

把失败信息变成可定位证据

子模块失败不能只保留一句“退出码 128”。建议记录主仓库提交、失败路径、期望对象、实际对象、解析后的主机名、检出深度和失败阶段,但不记录用户名、令牌或完整认证地址。

排查顺序可以固定为:

  1. git ls-tree HEAD <path> 确认主仓库期望的对象。
  2. .gitmodules 与本地 Git 配置确认最终地址。
  3. git cat-file -e 判断对象是否已在本地。
  4. 用只读认证验证远端访问权限。
  5. 检查浅克隆边界和嵌套子模块状态。
  6. 清理工作区后重新执行同一检出脚本。

最后把 git submodule status --recursive 作为构建元数据归档。它体积很小,却能回答本次构建究竟使用了哪些依赖提交。SDKMac 节点上的任务也应遵循同一原则:先固定源码输入,再启动 Xcode 或其他工具链,让检出故障和编译故障保持清晰边界。

常见问题

Submodule 已经指向固定提交,为什么 CI 仍会检出失败?

主仓库只记录子模块提交,不保证远端仍可访问该对象。应检查子模块地址、凭据权限、浅克隆深度以及目标提交是否仍在远端对象库中。

CI 中应该使用 git submodule update --remote 吗?

可复现构建不应使用该参数。它会按配置分支获取新提交,使同一个主仓库提交在不同时间得到不同依赖;构建阶段应检出主仓库记录的精确提交。

怎样避免子模块凭据留在云端 Mac 节点上?

把凭据写入任务专属的临时配置或受限文件,完成后用 trap 删除,并检查全局 Git 配置、远端地址和工作区日志中没有残留敏感值。

独享物理节点

为持续构建选择一台云端 Mac

比较 SDKMac M4 与 SDKMac M4 Pro 的配置、区域和四种计费周期,再创建订单。

选择租用方案