クラウドMac CIのシンボリックリンク監査

クラウドMac CIのシンボリックリンク監査

開発者のMacでは同じプロジェクトを正常にアーカイブできるのに、クラウド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 から始まりますが、/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搭載の専有物理マシンを注文しましょう。

モデルを選んで注文する