エンジニアリング実践

クラウドMac CIでiOSとwatchOSの同梱物を検査する

クラウドMac CIでiOSとwatchOSの同梱物を検査する

iOS AppとwatchOS Appを含むアーカイブは、メインアプリのビルドに成功しても配布できないことがあります。watchOS Appが埋め込まれていない、ビルド番号が1つ古い、拡張機能が以前のBundle IDを参照している、あるいはネストされたコンポーネントが正しく署名されていない、といった問題が原因です。この種の不具合を、エクスポートや提出の段階まで見逃すべきではありません。より確実なのは、クラウドMacでxcarchiveを生成するたびに最終パッケージの構造を直接検査し、その結果をパイプラインのゲートとして使用する方法です。

最終アーカイブを検査する理由

プロジェクトファイルが示すのは期待される構成であり、実際に配布される成果物はxcarchiveです。構成ファイル、ビルドスクリプト、環境変数によって、バージョン番号や製品識別子が上書きされる可能性があります。プロジェクト設定を読むだけでは、watchOS AppがメインアプリのWatchディレクトリに実際に格納されていることも、拡張機能と外側のアプリとの関係が正しいことも証明できません。

検証対象はDerivedDataの中間ディレクトリではなく、Releaseアーカイブに固定することを推奨します。まず、本番パイプラインと同じワークスペース、Scheme、構成を使用して成果物を生成します。

set -euo pipefail

ROOT="$PWD"
OUT="$ROOT/out"
ARCHIVE="$OUT/Client.xcarchive"

rm -rf "$ARCHIVE"
mkdir -p "$OUT"

xcodebuild \
  -workspace Client.xcworkspace \
  -scheme Client \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  -archivePath "$ARCHIVE" \
  clean archive

clean archiveは基準となる検証に適しています。通常のパイプラインではキャッシュ戦略に応じて調整できますが、検証スクリプトは必ず今回のジョブで生成されたアーカイブを読み取る必要があります。「直近に生成された」ディレクトリへフォールバックしてはいけません。

ゲートが答えるべきなのは「今回配布しようとしているパッケージに何が含まれているか」であり、「プロジェクトが理論上何を生成するはずか」ではありません。

パッケージ構造からコンポーネント一覧を作成する

一般的なパスはProducts/Applications/*.appです。その内部のWatchディレクトリにwatchOS Appが格納され、さらにwatchOS AppのPlugInsディレクトリに拡張機能が格納されます。製品名をスクリプトへハードコードしないでください。Target名を変更すると、固定パスは簡単に機能しなくなります。

IOS_APP=$(find "$ARCHIVE/Products/Applications" \
  -maxdepth 1 -type d -name '*.app' -print -quit)

test -n "${IOS_APP:-}" || {
  printf '%s
' "iOS app not found"
  exit 1
}

WATCH_APP=$(find "$IOS_APP/Watch" \
  -maxdepth 1 -type d -name '*.app' -print -quit)

test -n "${WATCH_APP:-}" || {
  printf '%s
' "watchOS app not found"
  exit 1
}

WATCH_EXTENSION=$(find "$WATCH_APP/PlugIns" \
  -maxdepth 1 -type d -name '*.appex' -print -quit)

printf 'ios=%s
watch=%s
extension=%s
' \
  "$IOS_APP" "$WATCH_APP" "${WATCH_EXTENSION:-embedded}"

一部の新しいプロジェクトには独立した.appex階層がないため、拡張機能が存在しないことだけでエラーと判断してはいけません。ゲートでは、リポジトリで宣言された製品構造に基づいて判定します。従来型の構造では3階層のコンポーネントを必須とし、より新しい単一Target構造ではiOS AppとwatchOS Appのみを必須とします。ディレクトリからプロジェクト形式を推測するのではなく、簡潔な期待構成リストをリポジトリに保存できます。

バージョンと識別子の関係を検証する

最初に各Info.plistplutil -lintを実行し、その後でCFBundleIdentifierCFBundleShortVersionStringCFBundleVersionを読み取ります。通常、同じリリースではiOSとwatchOSのマーケティングバージョンおよびビルド番号を完全に一致させます。チームで個別のビルド番号を採用している場合も、許容するルールをスクリプトへ明記する必要があります。

検査項目 推奨ルール 失敗時のリスク
マーケティングバージョン iOSとwatchOSで同一 ストア上のバージョン関係が不整合になる
ビルド番号 同じリリースジョブでは同一 watchOS側が古いビルドとして認識される
コンパニオン識別子 iOSメインアプリのIDを参照 インストール後にコンパニオン関係を確立できない
拡張機能の所属 現在のwatchOS App IDを参照 拡張機能と外側のアプリが不整合になる
read_plist() {
  /usr/libexec/PlistBuddy -c "Print :$2" "$1"
}

IOS_PLIST="$IOS_APP/Info.plist"
WATCH_PLIST="$WATCH_APP/Info.plist"

plutil -lint "$IOS_PLIST" "$WATCH_PLIST"

IOS_ID=$(read_plist "$IOS_PLIST" CFBundleIdentifier)
WATCH_ID=$(read_plist "$WATCH_PLIST" CFBundleIdentifier)
IOS_VERSION=$(read_plist "$IOS_PLIST" CFBundleShortVersionString)
WATCH_VERSION=$(read_plist "$WATCH_PLIST" CFBundleShortVersionString)
IOS_BUILD=$(read_plist "$IOS_PLIST" CFBundleVersion)
WATCH_BUILD=$(read_plist "$WATCH_PLIST" CFBundleVersion)
COMPANION_ID=$(read_plist "$WATCH_PLIST" WKCompanionAppBundleIdentifier)

test "$IOS_VERSION" = "$WATCH_VERSION"
test "$IOS_BUILD" = "$WATCH_BUILD"
test "$COMPANION_ID" = "$IOS_ID"

「watchOSのBundle IDはiOSのBundle IDで始まらなければならない」というルールを、厳密な比較の代わりに使用してはいけません。チームが明示的な識別子を使用している場合、プレフィックスが似ていても正しく関連付けられている証明にはなりません。リリース構成から期待値を読み取り、WKCompanionAppBundleIdentifierの値全体を検証してください。

拡張機能を含む構造への対応

アーカイブ内に.appexが存在する場合は、そのWKAppBundleIdentifierも読み取り、現在のwatchOS AppのBundle IDと等しいことを確認します。また、いずれかのTargetで共通バージョンスクリプトの適用が漏れていないか確認できるよう、拡張機能自体のバージョンとビルド番号も記録します。

署名と実行可能アーキテクチャを検査する

外側のアプリへの署名が成功していても、ネストされたすべてのコンポーネントを検証できるとは限りません。メインアプリだけを再帰的に検査するのではなく、実際のコンポーネントを列挙し、どの対象が失敗したかをログで明確に示します。

verify_component() {
  local item="$1"
  codesign --verify --strict --verbose=2 "$item"
  codesign -d --entitlements :- "$item" >/dev/null
}

verify_component "$IOS_APP"
verify_component "$WATCH_APP"

if test -n "${WATCH_EXTENSION:-}"; then
  verify_component "$WATCH_EXTENSION"
fi

続いて、それぞれのCFBundleExecutableからバイナリを特定し、fileまたはlipo -archsでアーキテクチャを記録します。ツールチェーンやデプロイメントターゲットは変化するため、ゲートに特定のアーキテクチャ構成を恒久的にハードコードする必要はありません。実行可能ファイルが空の場合や、意図しないシミュレータ向け成果物が含まれる場合は拒否し、現在のアーキテクチャ一覧をレビュー用に保存するほうが確実です。

さらに、ネストされたコンポーネントに、範囲外を指すシンボリックリンク、重複したBundle ID、または宣言されていない追加の拡張機能がないか確認します。こうした問題は、コピースクリプトで範囲の広すぎるワイルドカードを使用した場合によく発生します。

検証結果を安定したゲートにする

スクリプトの終了コードは処理の阻止を担当し、レポートは理由の説明を担当します。各ジョブでは、コンポーネントの相対パス、Bundle ID、バージョン、ビルド番号、アーキテクチャ、署名検証の状態、アーカイブのチェックサムを保存します。証明書の秘密鍵、環境変数一式、ユーザーディレクトリの完全なパスをレポートに収集してはいけません。

処理は次の4段階に分けることを推奨します。

  1. 一意のアーカイブディレクトリを生成し、前回の成果物の再利用を禁止する。
  2. コンポーネントを検出し、リポジトリ内の期待構成と比較する。
  3. メタデータ、関連付けフィールド、署名、実行可能アーキテクチャを検証する。
  4. テキストまたはJSONのレポートを出力してから、エクスポートジョブを実行する。

ゲートを初めて有効にする際は、まず数回にわたって記録のみを行い、処理は阻止しない運用にできます。新旧両方のプロジェクト構造を網羅できていることを確認し、ルールが安定してから、コンポーネントの欠落、バージョンの不一致、署名の失敗をハードエラーにします。同じクラウドMacで複数のアーカイブ処理を並列実行する場合は、ジョブごとに独立したarchivePathとレポートディレクトリを割り当て、あるジョブが別のジョブの結果を読み取らないようにする必要があります。

最終的な目的は、形式的な検査工程を1つ増やすことではありません。メインアプリ、watchOS App、その拡張機能が実際に同じリリースコンテキストから生成され、相互関係が明確であり、最終アーカイブをエクスポート段階へ進められることをパイプラインで証明することです。

よくある質問

プロジェクト設定だけを確認すれば十分ですか?

十分ではありません。最終アーカイブには設定の上書き、ビルドスクリプト、埋め込み処理、署名結果が反映されるためです。

iOSアプリとwatchOSアプリのバージョンはそろえるべきですか?

同じリリースでは通常、マーケティングバージョンとビルド番号をそろえます。独立運用する場合は許可する対応関係をCIに明記します。

専有物理ノード

クラウドでMac miniワークフローを継続運用

RunAMac M4、レンタル期間、デプロイ先ノードを選び、開発、ビルド、実験のタスクを専有物理ノードで実行できます。

レンタルプランを選ぶ