クラウドMacで作るiOS Mach-Oアーキテクチャ検査ゲート

クラウドMacで作るiOS Mach-Oアーキテクチャ検査ゲート

ローカルのシミュレータでは正常に動作するiOSプロジェクトでも、HopVMのクラウドMacでArchiveを完了した後、インストール段階になって初めて問題が表面化することがあります。たとえば、ビルド済みFrameworkにx86_64スライスが混入している、Extensionが誤ったプラットフォーム向けにリンクされている、ネストされた動的ライブラリによって最低対応OSが意図せず引き上げられている、といった問題です。納品後に調査するのではなく、書き出した.appを直接スキャンし、バイナリの実態をパイプラインの検査ゲートに反映させます。

最終的な書き出しパッケージを検査する理由

Xcodeのプロジェクト設定から分かるのは「どのようにビルドする予定か」だけです。「実際に何が成果物へ含まれたか」は最終パッケージを確認しなければ分かりません。メイン実行ファイル、App Extension、Framework、.dylibは、それぞれ独立したMach-Oヘッダーを持つ場合があります。依存関係マネージャーが取得したバイナリパッケージも、メインプロジェクトの設定を継承するとは限りません。

検査ゲートでは、少なくとも次の3点を確認する必要があります。

  1. 実機のiOSデバイス向けバイナリに、許可されたアーキテクチャだけが含まれているか。
  2. 各Mach-OのプラットフォームがiOSであり、シミュレータやmacOS向けになっていないか。
  3. ネストされたバイナリの最低対応OSが、メインAppの宣言より高くなっていないか。

「Archiveが成功した」ことを納品の合格条件にしてはいけません。リンカーが検証するのは、現在のターゲットから成果物を構成できるかどうかだけです。ネストされたすべてのファイルがリリース基準を満たしているかを、チームに代わって判断するわけではありません。

DerivedDataだけをスキャンするのではなく、xcodebuild -exportArchiveで生成した最終パッケージを検査してください。書き出し処理ではFramework、Extension、署名対象の内容が再構成されるため、実際の納品状態に最も近い入力になります。

入力と合格基準を固定する

最初にArchive、書き出し先、レポートの各ディレクトリを固定し、スクリプトが前回のジョブで残ったファイルをスキャンしないようにします。複数のパイプラインがクラウドMacを順番に利用する環境では、作業ディレクトリの分離が特に重要です。

set -euo pipefail

ARCHIVE_PATH="$PWD/output/App.xcarchive"
EXPORT_PATH="$PWD/output/export"
REPORT_PATH="$PWD/output/macho-report.tsv"

rm -rf "$EXPORT_PATH"
mkdir -p "$EXPORT_PATH"

xcodebuild -exportArchive \
  -archivePath "$ARCHIVE_PATH" \
  -exportPath "$EXPORT_PATH" \
  -exportOptionsPlist "$PWD/ci/ExportOptions.plist"

APP_PATH="$(find "$EXPORT_PATH" -maxdepth 2 -type d -name '*.app' -print -quit)"
test -n "$APP_PATH"

プロダクトの基準値はCIの設定画面に分散させず、リポジトリで管理します。たとえば、許可するアーキテクチャと最低バージョンをci/macho-policy.envに保存します。

EXPECTED_ARCHS="arm64"
EXPECTED_PLATFORM="IOS"
DECLARED_MIN_IOS="17.0"

DECLARED_MIN_IOSは、プロジェクトが実際にサポートする範囲と一致させる必要があります。ここで示したバージョンはスクリプトの例にすぎず、そのままコピーしてプロダクトの要件にしてはいけません。

すべてのMach-Oを再帰的に列挙する

Frameworkのメインバイナリには通常、拡張子がありません。そのため、拡張子だけを条件に検索することはできません。より確実なのは、すべてのファイルを走査し、fileでMach-Oかどうかを判定する方法です。

: > "$REPORT_PATH"
failure=0

while IFS= read -r -d '' candidate; do
  if ! file -b "$candidate" | grep -q 'Mach-O'; then
    continue
  fi

  archs="$(lipo -archs "$candidate" 2>/dev/null || true)"
  build="$(vtool -show-build "$candidate" 2>/dev/null || true)"
  platform="$(awk '/platform/{print $2; exit}' <<<"$build")"
  minos="$(awk '/minos/{print $2; exit}' <<<"$build")"

  printf '%s	%s	%s	%s
' \
    "${candidate#"$APP_PATH"/}" "$archs" "$platform" "$minos" \
    >> "$REPORT_PATH"

  if [[ "$archs" != "$EXPECTED_ARCHS" ]]; then
    printf 'architecture mismatch: %s (%s)
' "$candidate" "$archs" >&2
    failure=1
  fi

  if [[ "$platform" != "$EXPECTED_PLATFORM" ]]; then
    printf 'platform mismatch: %s (%s)
' "$candidate" "$platform" >&2
    failure=1
  fi
done < <(find "$APP_PATH" -type f -print0)

exit "$failure"

レポートはタブ区切りにします。これならビルドの添付ファイルとして保存でき、後から表形式へ変換するのも容易です。エラーメッセージには、ファイルパスと実際の値を必ず含めてください。「アーキテクチャ検査に失敗しました」とだけ出力すると、原因を調べるためにジョブ全体を再実行しなければなりません。

検査ゲート内でパッケージを直接修正しない

x86_64を検出した直後にlipo -removeを実行すれば簡単に解決できるように見えますが、署名済みの内容が変更されるうえ、依存ライブラリの生成工程にある問題も隠してしまいます。そのファイルがソースからのビルド、バイナリ依存関係、コピー用スクリプトのどこから来たのかを特定し、上流を修正してからArchiveをやり直すのが正しい対応です。

最低対応OSを比較する

まず、メインAppに記載されたプロダクトの宣言値を読み取ります。

PLIST="$APP_PATH/Info.plist"
APP_MIN_IOS="$(/usr/libexec/PlistBuddy \
  -c 'Print :MinimumOSVersion' "$PLIST")"

printf 'declared minimum iOS: %s
' "$APP_MIN_IOS"

次に、各Mach-OのLC_BUILD_VERSIONからminosを取得します。判定にはセマンティックバージョンとしての比較を使用し、通常の文字列比較は避けてください。文字列として比較すると、17.1017.9より前に誤って並ぶ可能性があります。

検査対象 読み取り元 失敗条件
メインAppの宣言 Info.plistMinimumOSVersion リポジトリの基準値と一致しない
メイン実行ファイル LC_BUILD_VERSIONminos プロダクトの宣言値より高い
Frameworkと動的ライブラリ それぞれのLC_BUILD_VERSION プロダクトの宣言値より高い
App Extension ExtensionのInfo.plistとMach-O 宣言値または実際の値が基準を超えている

システムに標準搭載されているバージョンソートを使って比較できます。

version_gt() {
  [[ "$1" != "$2" ]] &&
    [[ "$(printf '%s
%s
' "$1" "$2" | sort -V | tail -n 1)" == "$1" ]]
}

if version_gt "$minos" "$APP_MIN_IOS"; then
  printf 'minimum iOS mismatch: %s requires %s
' \
    "$candidate" "$minos" >&2
  failure=1
fi

vtoolがプラットフォームやバージョンを返さなかった場合、何もせずにスキップしてはいけません。まずotool -lでロードコマンド全体を保存し、旧形式のバイナリなのか、異常なファイルなのか、それともスクリプトの誤判定なのかを確認します。

CIへの組み込みと一般的な誤検出への対応

検査はArchiveと書き出しの後、アップロードの前に実行し、macho-report.tsvを常にアップロードします。ジョブが失敗した場合でも、CIの失敗時アーティファクト保存機能を使ってレポートを残してください。

一般的な誤検出は、主に次の3種類です。

このため、スクリプトのポリシーはプロダクト種別ごとに管理し、同じルールでiOS、シミュレータ用テストパッケージ、macOSツールを同時に検査しないようにします。パイプラインで複数種類の成果物を生成する場合は、書き出しディレクトリごとに個別に実行し、レポートの先頭列に成果物の種類を記録してください。

最後に、署名構造も検証します。

codesign --verify --deep --strict --verbose=2 "$APP_PATH"

この検証はMach-O検査の代わりにはなりませんが、スクリプトによるバイナリの誤変更や、ネストされた署名の無効化といった後続の問題を検出できます。全体の順序は、書き出し、スキャン、バージョン比較、署名検証、レポート保存とし、その後で初めて納品を許可します。これにより、失敗するたびに対象ファイル、問題のフィールド、修正すべき箇所を具体的に特定でき、インストールに失敗してから原因を推測する必要がなくなります。

よくある質問

メインAppだけを調べるのでは不十分ですか?

Framework、App Extension、動的ライブラリはそれぞれ独立したMach-Oを持ちます。ネストされた一つのファイルの不整合でも、インストールや起動に失敗する可能性があります。

iOS成果物からx86_64が見つかった場合はどうしますか?

lipoで削って済ませず、該当ファイルを生成した依存関係やアーカイブ工程を修正します。その後、実機向けプラットフォームだけを含む成果物を再出力します。

最低対応OSはどの値を基準にしますか?

Info.plistの製品宣言とMach-OのLC_BUILD_VERSIONを併せて確認します。ネストされたバイナリのminosが製品宣言を超えないことが重要です。

専用物理ノード

プロジェクト単位で起動・停止できるクラウドMacでワークフローを再現

3種類のApple Silicon構成から必要なリソースを選び、日単位、週単位、月単位、または四半期単位でレンタルできます。専用物理マシンを使用しており、仮想マシンではありません。実際の利用可能状況は、コンソールからリアルタイムで確認できます。

構成を選んで申し込む