一套 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”。建议记录主仓库提交、失败路径、期望对象、实际对象、解析后的主机名、检出深度和失败阶段,但不记录用户名、令牌或完整认证地址。
排查顺序可以固定为:
- 用
git ls-tree HEAD <path>确认主仓库期望的对象。 - 用
.gitmodules与本地 Git 配置确认最终地址。 - 用
git cat-file -e判断对象是否已在本地。 - 用只读认证验证远端访问权限。
- 检查浅克隆边界和嵌套子模块状态。
- 清理工作区后重新执行同一检出脚本。
最后把 git submodule status --recursive 作为构建元数据归档。它体积很小,却能回答本次构建究竟使用了哪些依赖提交。SDKMac 节点上的任务也应遵循同一原则:先固定源码输入,再启动 Xcode 或其他工具链,让检出故障和编译故障保持清晰边界。
常见问题
Submodule 已经指向固定提交,为什么 CI 仍会检出失败?
主仓库只记录子模块提交,不保证远端仍可访问该对象。应检查子模块地址、凭据权限、浅克隆深度以及目标提交是否仍在远端对象库中。
CI 中应该使用 git submodule update --remote 吗?
可复现构建不应使用该参数。它会按配置分支获取新提交,使同一个主仓库提交在不同时间得到不同依赖;构建阶段应检出主仓库记录的精确提交。
怎样避免子模块凭据留在云端 Mac 节点上?
把凭据写入任务专属的临时配置或受限文件,完成后用 trap 删除,并检查全局 Git 配置、远端地址和工作区日志中没有残留敏感值。
为持续构建选择一台云端 Mac
比较 SDKMac M4 与 SDKMac M4 Pro 的配置、区域和四种计费周期,再创建订单。