雲端 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 卻找不到」的常見原因。主儲存庫記錄的子模組物件可能早於目前的淺層邊界,也可能只能透過已刪除分支的歷史記錄存取。

情境 建議策略 失敗時的處理方式
子模組提交接近分支頂端 使用有限深度檢出 增加深度後重試
歷史跨度不固定 完整檢出子模組 不依賴固定深度進行猜測
多層巢狀子模組 逐層驗證提交 輸出失敗路徑與物件雜湊
遠端物件已無法存取 修正儲存庫參照 不以切換分支掩蓋問題

如果必須保留淺層複製,可以先嘗試:

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 的配置、地區和四種計費週期,然後建立訂單。

選擇租用方案