アーティスト・クリエイターの廣川政樹です。普段は壁紙や癒し系の iOS アプリを2014年から個人で運用しています。累計ダウンロードが5,000万を越えたあたりから、CI まわりに少しずつ手を入れる時間が取れなくなり、Fastlane と GitHub Actions の組み合わせを「動いているからとりあえずそのまま」で長く回していました。
ところが今年に入って、開発機を Apple Silicon に移したタイミングで Fastlane match の証明書同期が不安定になり、TestFlight への配信が週に1回は止まる状態になってしまいました。原因の半分は私のローカル環境、もう半分は GitHub Actions の macOS runner 側の更新サイクルです。個人で運用している以上、平日朝7時に Slack 通知で起こされる運用は持続可能ではありません。
そこで2026年5月のゴールデンウィーク明けに、思い切って Xcode Cloud へ全面移行しました。期間は1週間。判断と実装の伴走役として Claude Code を CLI から呼び続け、ci_scripts/ の3つのフックを書き上げ、TestFlight 自動配信と Crashlytics dSYM アップロードまでを動かすまでの記録です。Xcode Cloud に踏み切るか迷っている個人開発者の方の参考になればと思い、詰まった落とし穴と回避手順を中心に残します。
なぜ今あらためて Xcode Cloud か
Xcode Cloud を選んだ理由は3つあります。
第一に、Apple の証明書まわりが「自動署名」のまま完結する点です。Fastlane match を使っていると、署名証明書を専用リポジトリに暗号化して保管し、CI 上で復号する手順を組む必要があります。動いている間は意識しなくて済むのですが、Apple Developer ID の更新や Provisioning Profile の自動再生成で年に何度か止まるのが定常運用の負担でした。Xcode Cloud は App Store Connect 上のチーム証明書を直接利用するため、この経路自体が消えます。
第二に、Apple Developer Program に含まれる無料枠(25時間/月のコンピュート時間)で個人アプリ4本の運用が現実的に収まる点です。私の運用では、ビルド1本あたり平均6〜10分、週20本前後のビルドが発生します。GitHub Actions の macOS runner は同等の処理に1本あたり12〜18分かかっていたため、コンピュート時間ベースでも収まる計算でした。
第三に、TestFlight 配信が Xcode Cloud のワークフロー定義から直接トリガーできる点です。Fastlane の pilot を呼ぶ経路に比べてエラー切り分けが減り、upload_to_testflight で詰まる場面が物理的になくなります。
一方で、Xcode Cloud は「自由度の高い CI 全般」ではありません。GitHub Actions や Bitrise のように任意のコマンドを長時間流すには向きません。逆に言えば、iOS/macOS のビルド・テスト・TestFlight 配信に用途を絞ったときの手数が極端に少ない CI です。私のように「個人運用で iOS アプリ4本だけを安定して動かしたい」というスコープにはよく合います。
ci_scripts/ という仕組みの正体
Xcode Cloud のフックポイントは、Xcode プロジェクトの直下に置く ci_scripts/ ディレクトリで設定します。ここに3つのスクリプトを置くと、Xcode Cloud が次のタイミングで自動的に呼び出します。
ci_post_clone.sh — リポジトリの clone 直後(依存解決の前)
ci_pre_xcodebuild.sh — xcodebuild 実行の直前
ci_post_xcodebuild.sh — xcodebuild 完了の直後(成功/失敗どちらでも)
スクリプトはすべて bash で書き、シェバン #!/bin/sh -e と実行権限が必要です。失敗するとビルド自体が止まる仕様なので、想定外の終了コードを返さないよう注意します。
最初の挫折ポイントは、ローカルでこのスクリプトを試す方法がない点です。Xcode Cloud は GUI から起動する都合上、xcrun simctl のような対話的なテストが効きません。私は Claude Code に「ci_post_clone.sh を書くから、ローカルの bash で CI_WORKSPACE=$(pwd) bash -e ci_scripts/ci_post_clone.sh として乾式実行できる作りに整えてほしい」と頼みました。すると、Xcode Cloud から渡される環境変数を :- で安全にフォールバックさせる形のテンプレートを返してきました。
#!/bin/sh -e
# Xcode Cloud は CI_WORKSPACE をプロジェクトルートに設定する。
# ローカル実行時は呼び出し側で同名変数を渡せばそのまま動く。
WORKSPACE = "${ CI_WORKSPACE :- $( pwd )}"
cd " $WORKSPACE "
echo "[ci_post_clone] workspace= $WORKSPACE "
この「ローカルで CI_WORKSPACE=$(pwd) bash -e ci_scripts/... を流せるか」という基準を最初に決めたことで、その後の試行錯誤がだいぶ楽になりました。
ci_post_clone.sh — 依存を温める
私のプロジェクトは CocoaPods と Swift Package Manager の両方を使っています。Xcode Cloud は SwiftPM については自動で解決してくれますが、CocoaPods は明示的に pod install を走らせる必要があります。最初の ci_post_clone.sh は次のような形に落ち着きました。
#!/bin/sh -e
WORKSPACE = "${ CI_WORKSPACE :- $( pwd )}"
cd " $WORKSPACE "
echo "[ci_post_clone] node $( node --version 2> /dev/null || echo 'not installed')"
echo "[ci_post_clone] xcode-select: $( xcode-select -p )"
# CocoaPods は Xcode Cloud のイメージに含まれていない。
if ! command -v pod > /dev/null 2>&1 ; then
echo "[ci_post_clone] installing cocoapods via brew"
brew install cocoapods > /dev/null
fi
# Podfile.lock の有無で挙動を分ける。
if [ -f "Podfile.lock" ]; then
pod install --deployment --repo-update
else
pod install --repo-update
fi
echo "[ci_post_clone] done"
ここで詰まった落とし穴がひとつあります。pod install が xcrun: error: invalid active developer path で失敗するケースで、Xcode Cloud のイメージで Xcode が複数バージョン同居していると、xcode-select がどこを指しているか分からない状態が発生します。私の場合は sudo xcode-select -s /Applications/Xcode.app/Contents/Developer を入れる代わりに、Xcode Cloud のワークフロー設定で「Xcode 26.3」とバージョンを明示することで解消しました。スクリプト側で頑張って解決しようとしてはいけない、というのが当時の Claude Code との結論でした。
ci_pre_xcodebuild.sh — 機密値の流し込み
私のアプリは Firebase の GoogleService-Info.plist、AdMob の App ID、Stripe 決済の Publishable Key を .xcconfig 経由でビルド時に注入します。これらをリポジトリにそのまま入れたくない事情があるため、Xcode Cloud では「環境変数」として登録し、ci_pre_xcodebuild.sh で .xcconfig を組み立てる方式に切り替えました。
ここが Fastlane match からの移行で一番頭を抱えた箇所です。Fastlane では dotenv 形式で .env.ci に書いた値が自動的に流れてくる前提のスクリプトがあちこちにありました。Claude Code に最初に書かせた版は、その癖が残っていてうまく動きませんでした。
Before(最初に Claude Code が出した、Fastlane 流のコードが残っている版):
#!/bin/sh -e
# .env.ci から自動で読み込まれる前提(Xcode Cloud では動かない)
if [ -f " $CI_WORKSPACE /.env.ci" ]; then
set -a
. " $CI_WORKSPACE /.env.ci"
set +a
fi
cat > " $CI_WORKSPACE /Config/Secrets.xcconfig" << EOF
ADMOB_APP_ID = $ADMOB_APP_ID
STRIPE_PUBLISHABLE_KEY = $STRIPE_PUBLISHABLE_KEY
EOF
このコードは、ローカルで .env.ci を置けば動いてしまうので、PR を投げた直後はテストが通って見えました。ところが Xcode Cloud に push したとたん、ADMOB_APP_ID が空文字のまま .xcconfig が生成され、AdMob の初期化で Invalid Application ID が出てクラッシュレポートが上がりました。
After(Xcode Cloud の環境変数をそのまま使う版):
#!/bin/sh -e
WORKSPACE = "${ CI_WORKSPACE :- $( pwd )}"
cd " $WORKSPACE "
# Xcode Cloud では環境変数は実行時に既に export されている。
# 値が空のときはビルドを止め、空の .xcconfig を作らない。
require_env () {
name = " $1 "
value = $( eval echo " \$ $name " )
if [ -z " $value " ]; then
echo "[ci_pre_xcodebuild] required env not set: $name " >&2
exit 64
fi
}
require_env ADMOB_APP_ID
require_env STRIPE_PUBLISHABLE_KEY
require_env FIREBASE_DATABASE_URL
mkdir -p "Config"
cat > "Config/Secrets.xcconfig" << EOF
// Generated by ci_pre_xcodebuild.sh - DO NOT EDIT
ADMOB_APP_ID = $ADMOB_APP_ID
STRIPE_PUBLISHABLE_KEY = $STRIPE_PUBLISHABLE_KEY
FIREBASE_DATABASE_URL = $FIREBASE_DATABASE_URL
EOF
echo "[ci_pre_xcodebuild] secrets.xcconfig generated"
ポイントは require_env の追加です。Fastlane match から Xcode Cloud に乗り換える際は、.env.ci を読み込む処理を全部消し、「環境変数が空のときに明示的に exit 64 で止める」防御を入れます。空の .xcconfig を作ってしまうと、ビルド自体は通って TestFlight にも上がり、ユーザー端末で初めて気付くという最悪のシナリオに繋がります。
Apple の .xcconfig 仕様では // 以降がコメント扱いになるため、Stripe Publishable Key のような URL を含む値を書くときは // がそのまま残らないよう注意します。私は URL を直接 .xcconfig に書かず、Bundle ID と組み合わせるベースキーだけを保持して、Swift 側で組み立てる方針に変えました。
ci_post_xcodebuild.sh — dSYM と通知の統合
ここまでで TestFlight への配信は通るようになります。残る課題は Crashlytics の dSYM アップロードです。Firebase Crashlytics は dSYM ファイル(シンボル情報)が無いとクラッシュの行番号が解読できず、せっかくのレポートが「アドレスの数字の羅列」になってしまいます。
Xcode Cloud は bitcode 廃止後の世代なので、dSYM は xcodebuild の成果物として $CI_ARCHIVE_PATH/dSYMs/ に出力されます。ci_post_xcodebuild.sh から upload-symbols スクリプトを呼ぶ流れを Claude Code と一緒に組みました。
#!/bin/sh -e
WORKSPACE = "${ CI_WORKSPACE :- $( pwd )}"
cd " $WORKSPACE "
# 失敗時のビルドでは dSYM が無い場合もある。明示的にスキップ。
if [ " $CI_XCODEBUILD_EXIT_CODE " != "0" ]; then
echo "[ci_post_xcodebuild] xcodebuild failed (exit= $CI_XCODEBUILD_EXIT_CODE ), skipping dSYM upload"
exit 0
fi
# TestFlight 配信ワークフロー以外では dSYM をアップロードしない。
if [ " $CI_WORKFLOW " != "TestFlight Beta" ] && [ " $CI_WORKFLOW " != "Production" ]; then
echo "[ci_post_xcodebuild] workflow= $CI_WORKFLOW , skipping dSYM upload"
exit 0
fi
DSYMS_DIR = " $CI_ARCHIVE_PATH /dSYMs"
if [ ! -d " $DSYMS_DIR " ]; then
echo "[ci_post_xcodebuild] dSYMs directory not found at $DSYMS_DIR "
exit 0
fi
UPLOAD_SYMBOLS = " $WORKSPACE /Pods/FirebaseCrashlytics/upload-symbols"
if [ ! -x " $UPLOAD_SYMBOLS " ]; then
echo "[ci_post_xcodebuild] upload-symbols not found, did pod install run?"
exit 0
fi
" $UPLOAD_SYMBOLS " \
-gsp " $WORKSPACE /Apps/Wallpaper/GoogleService-Info.plist" \
-p ios \
" $DSYMS_DIR "
echo "[ci_post_xcodebuild] dSYM upload completed"
注意点は2つあります。1つ目は exit 0 を多用していること。ci_post_xcodebuild.sh で非ゼロ終了すると、せっかく成功した TestFlight 配信が「失敗」と判定され、再ビルドが走ってしまいます。dSYM アップロードはあくまで補助なので、失敗時はログを残してビルドそのものは成功扱いにします。
2つ目は CI_WORKFLOW 環境変数による分岐です。私は「Pull Request チェック」「TestFlight Beta」「Production」の3ワークフローを定義しています。Pull Request チェックでは TestFlight 配信もしないし dSYM も上げません。この分岐を入れていない最初の版では、PR ビルドが走るたびに dSYM がアップロードされ、Firebase 側のシンボル登録履歴が PR の数だけ汚れてしまいました。
ワークフロー定義 — Pull Request / TestFlight / Production の3本立て
Xcode Cloud のワークフローは App Store Connect 上の GUI で定義しますが、結果は Xcode プロジェクトの xcshareddata/xcschemes/ には書き込まれません。プロジェクトの外側のメタデータとして Apple のサーバー側にしか保存されない点に注意します。バックアップを取りたい場合は、GUI のスクリーンショットを Notion なり Dropbox なりに保管しておく運用が無難です。
3つのワークフローの分担はこのようにしています。
Pull Request — トリガー: PR 作成・更新。アクション: ビルド + ユニットテスト。配信なし
TestFlight Beta — トリガー: develop ブランチへの push。アクション: ビルド + TestFlight (Internal Group) 配信 + dSYM アップロード
Production — トリガー: タグ v* の push。アクション: ビルド + TestFlight (External Group) 配信 + dSYM アップロード + Slack 通知
このうち Production だけは Slack 通知を入れています。理由は単純で、TestFlight External Group への配信は Apple の Beta App Review を通る必要があり、通知から数時間〜1日のタイムラグがあるためです。Internal Group であれば即時配信されるので、TestFlight アプリの通知だけで十分でした。
1 週間運用してみた失敗と学び
1週間動かしてみて、3つの失敗を経験しました。記録のために残しておきます。
第一の失敗は、Xcode Cloud の「Start Condition」を「Auto」にしたまま deploy ブランチ以外でも TestFlight 配信が走ったことです。最初の3日間は「Any Branch」のままになっていたため、feature/* の作業ブランチを push するたびに TestFlight Internal が走り、社内の TestFlight アプリ上で見ない方が良いビルドが並びました。「Branch Starts With: develop」のフィルターを Claude Code と一緒に整理して解消しました。
第二の失敗は、ci_pre_xcodebuild.sh でビルドを止めたつもりが、Xcode Cloud のワークフロー設定で「Continue on script failure」が ON になっていたため、空の .xcconfig で TestFlight 配信まで通ってしまったことです。配信先は Internal Group だったので外部影響はありませんでしたが、初期化失敗のクラッシュレポートが Crashlytics に到達するまで気付けませんでした。Xcode Cloud のワークフロー設定で「Continue on script failure」は必ず OFF にし、スクリプト失敗時はビルド全体を止める方が安全です。
第三の失敗は、ビルド時間がじりじり伸びていったことです。最初は14分かかっていた TestFlight Beta ワークフローを6分まで縮めるのに、いくつかの工夫を Claude Code と試しました。効いた順に挙げると、pod install --repo-update の --repo-update を毎回外し、必要なときだけ手動で走らせるようにしたこと(ビルド時間 -3分)、SwiftPM のローカルキャッシュを ~/Library/Developer/Xcode/DerivedData/ のキャッシュにヒットさせる Xcode Cloud のオプションを ON にしたこと(-2分)、テスト対象を「Smoke スイートのみ」と「Full スイート」に分けてビルドのトリガーに応じて切り替えるようにしたこと(-3分)の3点です。
移行が終わってみての所感
Fastlane match は10年近く触ってきた道具なので、捨てるかどうかは正直最後まで迷いました。結論としては、Fastlane を残しているのは「ローカルからの緊急配信」と「Screenshot 自動撮影」の2用途だけで、本番の配信動線はすべて Xcode Cloud に寄せました。CI を運用負担で語る場面が大幅に減ったのは、平日朝の頭の使い道として大きな違いです。
両家の祖父が宮大工だったこともあり、組み上げの精度が一段揃った状態はそれだけで気持ちが落ち着きます。CI に時間を取られないぶん、本来の「アプリで届けたい体験」を考える時間が戻ってきました。これは個人開発で運用を続けるうえで、思っていたよりずっと大きな違いだと感じています。
次のアクションとして、もし Xcode Cloud への移行を検討されている方は、ci_scripts/ の3つのスクリプトを「ローカルの bash で乾式実行できる作り」で書くところから始めてみてください。GUI ではなくテキストで CI を語れるようになると、Claude Code との会話も一段噛み合うようになり、1週間あれば十分に動くところまで届くと思います。
最後までお読みいただき、ありがとうございました。同じく iOS アプリを個人で運用されている方の参考になれば嬉しいです。