雲端 Mac CI 符號連結審核:攔截斷鏈與越界參照

雲端 Mac CI 符號連結審核:攔截斷鏈與越界參照

同一份專案在開發者的電腦上可以順利封存,換到雲端 Mac CI 卻顯示資源不存在,最容易被忽略的原因之一就是符號連結。儲存庫裡看似普通的檔案,實際上可能指向本機目錄、尚未提交的目標,或工作區以外的位置。Xcode 往往要到複製資源、執行指令碼或封裝相依項目時才會存取它,因此問題直到建置後半段才浮現,記錄中也常只呈現為一般的「找不到檔案」。

先定義允許哪些連結

不要一開始就撰寫掃描指令碼,應先把規則定義清楚。適用於持續建置的預設規則通常只有三項:連結必須使用相對路徑、解析後的目標必須存在,而且目標必須位於目前工作區內。少數由工具產生的內部連結可以設為例外,但每項例外都應精確指定完整的相對路徑。

符號連結門禁的目的不是消滅連結,而是排除對特定機器目錄結構的隱性依賴。

例如,Config/current.json -> release.json 可以在儲存庫簽出後穩定運作;SDK/current -> /Users/dev/SDK 則會把本機狀態帶進流水線。../Shared/file 也不能只根據文字判斷,它可能仍位於儲存庫內,也可能超出 CI 配置的工作目錄,因此必須先解析並正規化路徑,再比較其邊界。

檢查項目 應通過 應攔截
連結形式 儲存庫內的相對路徑 絕對路徑
目標狀態 目標存在且可讀取 斷鏈或循環連結
路徑邊界 解析後位於工作區內 指向工作區外
例外規則 完整相對路徑 過於寬泛的目錄前綴

從 Git 索引確認連結身分

檢查模式,而不是副檔名

Git 以模式 120000 記錄符號連結,並將連結目標儲存為檔案內容。先從索引列出這些連結,避免把一般文字檔或建置後產生的連結混在一起。

git ls-files -s | awk '$1 == "120000" { print $4 }'
git config --show-origin --get core.symlinks || true
git status --porcelain=v1

在 macOS 上,簽出後的物件應該確實是連結。如果儲存庫狀態突然顯示連結被替換成一般檔案,應先檢查封裝、解壓縮或同步步驟是否改寫了檔案類型。門禁也應在乾淨簽出後執行一次 git diff --exit-code,確認初始化指令碼沒有在未察覺的情況下修改索引中的連結文字。

找出未追蹤的目標

連結本身已提交,不代表它的目標也已提交。對每個由 Git 追蹤的連結,都應確認目標存在,並使用 git ls-files --error-unmatch 判斷儲存庫內的目標是否受到版本控制。如果目標原本就會由建置流程產生,應把檢查移到產生步驟之後,同時保留一份精確清單,列出在產生前允許暫時不存在的目標。

使用邊界指令碼攔截斷鏈與越界參照

以下指令碼會遞迴檢查指定目錄。它會拒絕絕對連結、無法解析的目標,以及解析後位於根目錄以外的目標。請將它儲存為 ci/check_symlinks.py,並在安裝相依項目之後、呼叫 xcodebuild 之前執行。

import os
import sys
from pathlib import Path

root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
failures = []

for path in root.rglob("*"):
    if not path.is_symlink():
        continue

    raw = os.readlink(path)
    relative = path.relative_to(root)

    if os.path.isabs(raw):
        failures.append((relative, "absolute", raw))
        continue

    try:
        target = path.resolve(strict=True)
    except (FileNotFoundError, RuntimeError, OSError) as error:
        failures.append((relative, "broken", str(error)))
        continue

    try:
        target.relative_to(root)
    except ValueError:
        failures.append((relative, "outside", str(target)))

for item in failures:
    print("\t".join(map(str, item)))

sys.exit(1 if failures else 0)

執行方式如下:

python3 ci/check_symlinks.py "$PWD"

不要用簡單的字串前綴比較路徑。/tmp/job-10/tmp/job-1 的字串前綴,卻不是它的子目錄;只有在正規化後依照路徑階層進行比較,才能避免這類誤判。

在建置前後各檢查一次

原始碼掃描只能發現儲存庫中既有的問題,相依套件管理器、程式碼產生器及自訂 Build Phase 仍可能建立新的連結。因此,建議設置兩個檢查點。

建置前檢查工作區

完成相依項目解析與程式碼產生後,掃描整個工作區,但排除明確屬於工具內部實作、且不會進入成品的快取目錄。排除規則也必須固定在儲存庫中,不能讓 runner 上的暫時環境變數任意擴大範圍。接著記錄連結清單,以便比較建置過程新增了哪些項目。

find "$PWD" -type l -print | LC_ALL=C sort > "$TMPDIR/symlinks-before.txt"
python3 ci/check_symlinks.py "$PWD"

建置後檢查成品

完成封存後,針對匯出的 .app 或解開後的成品根目錄,再次執行相同的指令碼。合法連結必須同時符合兩項條件:「目標仍位於成品內部」以及「路徑列在允許清單中」。如果第三方框架確實包含內部連結,應允許具體路徑,而不是放行整個 Frameworks 目錄。

接著比較建置前後的清單。若新增連結來自指令碼階段,應能對應到明確的產生命令;無法解釋的新增項目應先攔截發布,再從建置記錄中找出建立者。

處理常見誤判並建立門禁

最常見的誤判來自暫存目錄、相依套件快取與封存檔內部結構。處理順序應是先縮小掃描根目錄,再建立精確例外,最後才考慮排除整個目錄。不要只因某個相依項目會產生連結,就略過整棵相依樹,否則真正的斷鏈也會一併被隱藏。

門禁失敗時,至少應輸出連結的相對路徑、失敗類型與解析後的目標,並將掃描指令碼的結束碼直接傳給 CI。修正後必須以全新簽出的儲存庫驗證,不能只在已經執行過初始化指令碼的工作區重新執行。最終檢查項目可收斂為六項:Git 模式正確、目標受版本控制、連結使用相對路徑、解析後不越界、建置新增的連結可解釋,以及封存例外精確符合指定路徑。完成這六項後,符號連結就不再是偶發的環境問題,而會成為可稽核的工程契約。

常見問題

為什麼本機可用的符號連結到了雲端 Mac CI 會失效?

常見原因是連結依賴本機絕對路徑、目標未受 Git 追蹤,或工作區檢出位置不同。應同時檢查連結文字及解析後的實際路徑。

是否應該禁止儲存庫內所有符號連結?

不必。指向儲存庫內穩定位置的相對連結可以保留,但應拒絕斷鏈、絕對連結及越過工作區邊界的參照。

封裝內的合法符號連結要如何避免誤報?

先確認解析後的目標仍位於封裝根目錄,再以完整相對路徑加入允許清單。不要用檔名或寬泛目錄前綴放行。

執行下一項任務

依工作負載選擇雲端 Mac

確認晶片、記憶體、儲存空間、租期與節點後,訂購一台 Apple Silicon 獨享實體機。

選擇機型並訂購