クラウドMac CIでxcodebuildの失敗を確実に検出する

CI/CD ·約 10 分

クラウドMac CIでxcodebuildの失敗を確実に検出する

ビルドログの出力に tee を組み込むと、ターミナルには完全なログが表示される一方で、明らかに失敗している xcodebuild を CI が成功と判定することがあります。最も危険なのはログが欠落することではなく、パイプラインがそのままアップロード、通知、デプロイの各ステップを実行してしまうことです。原因はたいてい単純です。shell はデフォルトでパイプライン末尾のコマンドの終了コードを返すため、tee が正常に書き込みを終えると、その前段にある xcodebuild の失敗ステータスが上書きされます。

エラーがどのように隠れるかを確認する

次のような書き方はよく使われます。

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -destination "$CI_DESTINATION" \
  2>&1 | tee build.log

このパイプラインには2つのプロセスがあります。左側ではテストを実行し、右側では出力をファイルと標準出力の両方へ書き込みます。pipefail が有効になっていない Bash では、コマンド全体のステータスとしてデフォルトで tee の終了ステータスが採用されます。テストがゼロ以外を返しても、ログの書き込みに成功すれば、スクリプトから見える終了コードは 0 になる可能性があります。

まず、クラウドMac上で最小構成の検証を行います。

/bin/bash -c 'exit 65' | tee /tmp/pipeline-check.log
printf 'pipeline=%s\n' "$?"

pipeline=0 と出力される場合、現在のスクリプトには失敗を成功と誤判定する条件が存在します。ログに error: が含まれているかだけで判定してはいけません。コンパイラ、テストフレームワーク、カスタムスクリプトでは、出力形式が変わる可能性があるためです。終了コードこそが、CIでジョブの成否を判定するための安定したインターフェースです。

ログは失敗の原因を説明するために使い、終了コードは失敗を通知するために使います。両方を個別に保存し、プロセスのステータスをテキスト検索で代用してはいけません。

Shellを固定してからパイプラインの状態を処理する

macOSの対話型環境で使われるデフォルトshellと、CIスクリプトを実際に解釈するshellが同じとは限りません。Bashでは PIPESTATUS、zshでは pipestatus を使用し、配列の添字も異なります。最も確実なのは、リポジトリ内のスクリプトでインタープリターを明示し、不確定なshellへ複数行のコマンドを渡すのではなく、パイプラインからそのファイルを直接実行する方法です。

#!/bin/bash
set -uo pipefail

-u は未定義変数の使用を検出し、pipefail はパイプライン内のいずれかのコマンドが失敗した場合に、パイプライン全体がゼロ以外を返すようにします。ただし、pipefail だけでは十分に正確とはいえません。ディスクや権限の問題で tee が失敗し、xcodebuild 自体は成功した場合、最終的な $? から分かるのはパイプラインが失敗したことだけで、どの段階が失敗したかまでは判断できません。

そのため、パイプラインの終了直後に PIPESTATUS をコピーする必要があります。代入、条件判定、ログ出力など、どのようなコマンドでも内容が更新される可能性があります。読み取りが一手遅れるだけで、別のコマンドのステータスを取得することになります。

再利用可能なxcodebuildラッパースクリプトを作成する

次のスクリプトは、元のログ、結果バンドル、xcodebuild の実際の終了コードを同時に保存します。ステータスを保存する前にshellが終了しないよう、errexit を一時的に無効にしています。

#!/bin/bash
set -uo pipefail

run_id="${CI_RUN_ID:-local-$(date +%s)}"
artifact_dir="${CI_ARTIFACT_DIR:-$PWD/artifacts/$run_id}"
result_path="$artifact_dir/TestResults.xcresult"
log_path="$artifact_dir/xcodebuild.log"

mkdir -p "$artifact_dir"

set +e
xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Debug \
  -destination "$CI_DESTINATION" \
  -resultBundlePath "$result_path" \
  2>&1 | tee "$log_path"

pipeline_status=("${PIPESTATUS[@]}")
xcode_status="${pipeline_status[0]}"
tee_status="${pipeline_status[1]}"
set -e

printf 'xcodebuild_exit=%s\n' "$xcode_status"
printf 'tee_exit=%s\n' "$tee_status"

if (( tee_status != 0 )); then
  printf 'Log capture failed\n' >&2
fi

exit "$xcode_status"

CI_DESTINATION は、検証済みのシミュレータターゲットに固定するなど、ジョブ設定から明示的に渡します。スクリプト内で最初に見つかった利用可能なデバイスを曖昧に選択してはいけません。実行環境の更新後に、異なるOSバージョンが選ばれる可能性があるためです。作業ディレクトリに空白が含まれていても引数が分割されないよう、パス変数はすべて引用符で囲みます。

ここでは、xcodebuild のステータスをジョブ全体の最終ステータスとして採用しています。ログの保存に失敗した場合もジョブを停止する必要があるなら、終了前に判定を追加し、どちらか一方でもゼロ以外なら失敗を返します。その際、2つのステータスを個別にジョブのサマリーへ記録します。

結果バンドルを診断の証拠として保存する

コンソールテキストはすばやい確認に適しています。一方、xcresult にはテスト階層、失敗時の添付ファイル、アクティビティログ、診断情報が保持されます。用途が異なるため、両方をアーカイブすることを推奨します。

成果物 主な用途 失敗時に保存するか
xcodebuild.log コンパイラ出力とスクリプトログの検索 はい
TestResults.xcresult テスト、添付ファイル、アクティビティログの確認 はい
exit-status.txt パイプライン各段階のステータス記録 はい
一時的なDerivedData 現象の再現に必要な場合に短期間保存 ジョブのポリシーに従う

機械可読なサマリーが必要な場合は、システム付属の xcresulttool を使用できます。解析の失敗によって元のビルドステータスが上書きされないよう、結果バンドルが実際に存在することを確認してから解析します。

if [[ -d "$result_path" ]]; then
  xcrun xcresulttool get test-results summary \
    --path "$result_path" \
    > "$artifact_dir/test-summary.json" || true
fi

printf 'xcodebuild=%s\ntee=%s\n' \
  "$xcode_status" "$tee_status" \
  > "$artifact_dir/exit-status.txt"

ここでの || true は、サマリー生成に失敗してもアーカイブ処理を続行するためだけに使用します。xcodebuild のメインコマンドの後ろに付けてはいけません。補助レポートの生成失敗によって、すでに保存したメイン処理のステータスを消してはならず、メインビルドの失敗が補助コマンドによって成功へ書き換えられることも許されません。

正常終了だけでなく異常系で検証する

ラッパースクリプトをマージする前に、少なくとも次の4種類の検証を行います。

コンパイル失敗

一時的に存在しないシンボルを参照します。期待される結果は、xcodebuild がゼロ以外を返し、CIが失敗し、ログが保存されることです。結果バンドルが生成されるかどうかは、失敗した段階によって異なります。

テスト失敗

確実に失敗するアサーションを追加します。コンパイルは完了してもテストジョブは失敗し、xcresult から失敗したテストケースを特定できることを確認します。パイプラインが成功時の分岐へ進んではいけません。

ログ書き込み失敗

成果物ディレクトリを書き込み不可の場所に設定し、tee_status がゼロ以外になることを確認します。診断用の成果物がすべてそろわなければリリースできないという運用方針なら、このケースでもジョブを停止させます。

中断とタイムアウト

CIから終了シグナルを送信し、後続のリリースステップが実行されないこと、および子プロセスが回収されることを確認します。タイムアウトを通常のテスト失敗として扱ってはいけません。両者では、再試行ポリシーと障害分析上の意味が異なります。

最後に、ジョブ定義そのものも確認します。診断用成果物をアップロードするステップは失敗後も実行するよう設定できますが、デプロイ、配布、成功通知は、ラッパースクリプトが返す実際のステータスに依存させる必要があります。これにより、出力フィルター、アーカイブツール、サマリー生成ツールが変更されても、xcodebuild の失敗は単一かつ検証可能な経路を通じてCIへ確実に返されます。

よくある質問

pipefail を有効にしても PIPESTATUS が必要なのはなぜですか?

pipefail はパイプライン内の失敗を検出しますが、xcodebuild 自身の終了コードを特定するものではありません。直後に PIPESTATUS[0] を読むことで最初のコマンドの状態を保存できます。

ラッパーが失敗を正しく伝えるか確認する方法は?

コンパイルエラー、テスト失敗、書き込み不能なログディレクトリを個別に再現します。すべてのジョブが失敗し、必要なログと xcresult が保存されることを確認します。

専用の物理Mac

次の開発タスクをクラウドMacで進める

Berry M4またはBerry M4 Proを選び、5つのノード、利用期間、追加オプションを確認して注文します。

構成を選んで注文