月末に「ビルドが順番待ちで動かない」と気づく
ある月の25日あたり、release ブランチにプッシュしたのに Xcode Cloud のビルドが一向に始まらない、という状態に出くわしたことがあります。エラーは出ていません。ワークフローは緑のまま、ただ Queued で止まっている。原因は単純で、その月の無料コンピュート枠(25時間)を使い切っていたためでした。
Xcode Cloud の厄介なところは、枠を超えても派手に警告してくれない点です。CI は今まで通り通っているように見えるのに、配信だけが遅延し、月初になると何事もなかったように動き出します。請求を有効にしていれば、超過分が静かに加算されていきます。テストが落ちて気づく失敗より、こちらの「気づかない消費」のほうが個人開発では効いてきます。
この記事は、コンピュート時間がどこで消えているのかを実測し、無駄を削り、枠を月内で守るための運用に絞って書きます。ワークフローの初期セットアップそのものではなく、「動いている CI のコストを見える化して制御する」話です。
まず消費の内訳を測る — 体感ではなく数字で
削減の前に計測です。Xcode Cloud のダッシュボードは総使用時間こそ見せてくれますが、「どのワークフローが・どのブランチで・何分使ったか」までは追いにくい。ここは App Store Connect API の ciBuildRuns を引いて、自分で集計したほうが早いです。
API は JWT 認証で、App Store Connect の Users and Access から発行した API キー(Issuer ID・Key ID・.p8 秘密鍵)を使います。鍵はリポジトリに含めず、ローカルか CI のシークレットに置きます。
#!/usr/bin/env bash
# ci_usage.sh — 直近のビルド実行を取得し、ワークフロー別に所要時間を集計する
# 必要: jq, openssl。ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8_PATH を環境変数で渡す
set -euo pipefail
KEY_ID="${ASC_KEY_ID:?}"
ISSUER_ID="${ASC_ISSUER_ID:?}"
P8_PATH="${ASC_P8_PATH:?}" # 例: ~/.private_keys/AuthKey_XXXX.p8(コミット禁止)
# --- JWT 生成(有効期限は短く) ---
now=$(date +%s); exp=$((now + 600))
header=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$KEY_ID" | openssl base64 -A | tr '+/' '-_' | tr -d '=')
payload=$(printf '{"iss":"%s","iat":%d,"exp":%d,"aud":"appstoreconnect-v1"}' "$ISSUER_ID" "$now" "$exp" | openssl base64 -A | tr '+/' '-_' | tr -d '=')
sig=$(printf '%s.%s' "$header" "$payload" | openssl dgst -sha256 -sign "$P8_PATH" | openssl base64 -A | tr '+/' '-_' | tr -d '=')
JWT="${header}.${payload}.${sig}"
# --- 直近100件のビルド実行を取得 ---
curl -s "https://api.appstoreconnect.apple.com/v1/ciBuildRuns?limit=100&fields[ciBuildRuns]=createdDate,startedDate,finishedDate,executionProgress,completionStatus" \
-H "Authorization: Bearer ${JWT}" \
| jq -r '
.data[]
| select(.attributes.finishedDate != null)
| { started: .attributes.startedDate, finished: .attributes.finishedDate, status: .attributes.completionStatus }
| "\(.status)\t\(.started)\t\(.finished)"
'
ここで得られる startedDate→finishedDate の差は「壁時計時間」であって、Apple が課金する「コンピュート分」と完全一致はしません(並列実行や課金単位の丸めで前後します)。それでも、どのワークフローが長時間枠を占めているかの相対比較には十分使えます。私は週に一度この出力を眺めて、突出して長いランの原因(多くは UI テストの再試行か依存解決の遅延)を潰すようにしています。実測すると、1回のフル UIテスト走行が10〜14分、依存解決が混むと追加で3〜5分という具合に、数字で見ると削るべき場所が一目で分かります。
集計を一段進めるなら、ciBuildRuns から builds をたどってワークフロー名で束ね、合計分をワークフロー別に出します。最初は完璧な集計を作り込むより、「いちばん時間を食っているワークフローを1つ特定する」ところまでで十分です。
時間を溶かす三つの主犯
実測すると、消費の大半は決まった場所から出ています。経験上の内訳はおおよそ次の通りです。
| 消費源 | 典型的な割合 | 効く対策 |
| UIテスト(XCUITest)の実行と再試行 | 40〜55% | 実行ブランチを限定・並列数を抑える・フレーキー除去 |
| シミュレータの起動と全ビルド再走 | 20〜30% | destination を1つに固定・不要トリガー停止 |
| Swift Package の依存解決 | 10〜20% | Package.resolved をコミット・参照を固定 |
UIテストが最大の主犯になりやすいのは、起動が重いうえに不安定で再試行が走るからです。ここを「毎コミットで全部回す」設定にしていると、コンピュート時間はあっという間に溶けます。ユニットテストは軽いので毎回回してよいのですが、UIテストは develop への統合時や release 前など、回数を絞る判断が要ります。
不要なビルドを clone 直後に止める
いちばん効くのは「そもそも動かさない」ことです。Xcode Cloud には ci_scripts/ci_post_clone.sh というフックがあり、リポジトリ取得直後に実行されます。ここで「ドキュメントしか変わっていないコミットならビルドを打ち切る」判定を入れると、無駄な走行を根元で止められます。スクリプトが非ゼロで終了すると、そのビルドは失敗扱いになりますが、配信に関係ない変更で枠を消費するよりはるかにましです。
#!/usr/bin/env bash
# ci_scripts/ci_post_clone.sh — コード変更のないコミットでビルドを早期終了する
set -euo pipefail
# 直前コミットとの差分を取得(Xcode Cloud は shallow clone のことがあるため深掘りする)
git -C "$CI_PRIMARY_REPOSITORY_PATH" fetch --deepen 1 --quiet || true
changed=$(git -C "$CI_PRIMARY_REPOSITORY_PATH" diff --name-only HEAD~1 HEAD 2>/dev/null || echo "")
# ソース・設定・テストのいずれにも触れていなければ中止
if ! echo "$changed" | grep -Eq '\.(swift|h|m|plist|xcconfig|entitlements|storekit)$|project\.pbxproj|Package\.(swift|resolved)'; then
echo "コード変更なし(変更: ${changed:-none})— ビルドをスキップします"
exit 1 # 非ゼロ終了でビルドを止める
fi
echo "コード変更を検出 — ビルドを継続します"
README やブログ用のスクリーンショットだけを更新するコミットは、個人開発でも案外多いものです。その一本一本でフルビルドと UIテストが走っていたのを止めるだけで、月の消費が体感で2〜3割減ったことがあります。差分判定の正規表現は、自分のプロジェクトでビルドに影響するファイル種別に合わせて調整してください。
テストプランを分けて、ブランチごとに回す量を変える
テストは1つの .xctestplan に全部詰めず、目的別に分けるのが運用しやすいです。軽いユニットテストは毎コミット、重い UIテストは統合時だけ、という出し分けがワークフロー単位でできるようになります。
具体的には、UnitTests.xctestplan(高速・全ブランチ)と UITests.xctestplan(低速・develop統合とrelease前のみ)に分割します。Xcode Cloud のワークフロー側で、feature ブランチのプッシュにはユニットのみ、develop へのプルリク時に UIテストを足す、という構成にすると、開発中の細かいプッシュで重いテストが走らなくなります。
UIテストを残す場合も、-parallel-testing-worker-count を無闇に増やさないことが大事です。並列数を上げると壁時計は短くなりますが、起動するシミュレータが増えるぶんコンピュート分はむしろ増えがちです。枠を守るのが目的なら、並列より「回す頻度を減らす・対象を絞る」ほうが効きます。私自身、個人開発のアプリで UIテストを develop 統合時だけに絞ったところ、UIテスト由来の消費が約50%から20%強まで下がり、月の総使用時間も25時間枠の内側に戻りました。
ビルドログを Antigravity に読ませて削減点を絞る
長いランの原因がログから即座に分からないとき、Antigravity にビルドログを貼って「このビルドで時間がかかっている工程を上位3つ挙げ、削減案を出してください」と聞くと、当たりをつけるのが速くなります。依存解決のタイムアウト、シミュレータの起動待ち、特定テストの再試行ループといった「人間が読み流しがちな箇所」を拾ってくれます。
私はワークフロー設計そのものも、最初の叩き台を Antigravity に出させてから手で詰めるようにしています。ゼロから全条件を考えるより、提示された構成案に対して「この UIテストは feature ブランチでは要らない」と削っていくほうが、結果的に無駄の少ない設定に落ち着きます。生成された案を鵜呑みにせず、実測した消費の内訳と突き合わせて判断するのが前提です。
枠を守るか、有料に切り替えるかの線引き
削れるだけ削っても枠が足りないなら、それは事業が回り始めた合図でもあります。ここで「無料枠に収めること自体」が目的化すると、かえって開発のテンポを落とします。判断の目安として、私は次のように整理しています。
| 状況 | とる選択 |
| UIテストの回しすぎ・不要ビルドで超過 | まず ci_post_clone とテスト分割で削る |
| 削っても枠ぎりぎりで配信が月末に詰まる | 有料プランへ切替(遅延の機会損失と比較) |
| クロスプラットフォームで CI を一本化したい | Android/Web は別CIに寄せ、iOSのみ Xcode Cloud に残す |
超過分の課金額と、配信が数日遅れることの損失を並べて比べると、答えはたいてい明確になります。リリース頻度が上がってきたら、無料枠への固執を手放すのも健全な判断です。
次の一手:今週は「測る」だけでいい
削減策をいくつも一度に入れる必要はありません。まずは ci_usage.sh を一度走らせて、いちばん時間を食っているワークフローを1つ特定してください。多くの場合、それは UIテストの回しすぎか、ドキュメント更新でのフルビルドです。原因が1つ見えれば、ci_post_clone.sh かテストプラン分割か、打つべき手は自然に決まります。
CI のコストは、落ちて初めて気づく失敗と違って、静かに積み上がります。月に一度、消費の内訳を眺める習慣をひとつ持っておくだけで、月末にビルドが詰まる場面はほとんど消えます。お読みいただきありがとうございました。