iOSデプロイの自動化:App Store ConnectとTestFlight
FastlaneのセットアップからTestFlightへの配布、App Storeへの申請まで、iOSアプリのデプロイを自動化するための完全ガイド。

どのiOS開発者も、この儀式を経験したことがあるでしょう。Xcodeを開き、ビルド番号を上げ、アーカイブスキームを選択し、15分待ち、Organizerをクリックし、App Store Connectにアップロードし、さらに待ち、TestFlightでテスターを手動で追加する。これを複数のアプリで週に2回行うと、コードを書くべき時間を何時間も失い始めます。最悪なのは時間ではなく、その一貫性のなさです。1つの手順を忘れたり、間違ったプロビジョニングプロファイルを使用したりすると、ビルドはサイレントに失敗するか、数時間後にAppleの自動チェックによって拒否されます。
私はFlutterベースのiOSアプリのためにこのパイプライン全体を自動化し、それ以来、他のプロジェクトにも同じパターンを適用してきました。この投資は、3回目か4回目のリリースサイクル後には元が取れます。
手動デプロイが破綻する理由
手動プロセスには、明らかな時間の無駄以外に3つの根本的な問題があります。
第一に、知識の集中です。チーム内でデプロイ方法を知っているのが一人だけだと、その人がボトルネックになります。休暇、病欠、あるいは単にタイムゾーンが異なるだけで、リリースが停滞します。ドキュメントは役立ちますが、15ステップのデプロイガイド自体が負債となります。誰かがビルド設定を変更した瞬間に、それは古くなってしまいます。
第二に、最悪のタイミングでのヒューマンエラーです。リリースはプレッシャーの中で行われる傾向があります。重大なバグ修正、クライアントからの締め切り、競合他社よりも早く出荷する必要がある機能。これらはまさに、ビルド番号を増やすのを忘れたり、間違ったスキームを選択したり、リリースビルドの代わりにデバッグビルドをアップロードしたりするような状況です。
第三に、監査可能性の欠如です。リリースで問題が発生した場合、どのコミットから、どの構成で、何がビルドされたのかを正確に知りたいものです。手動プロセスでは、信頼できる記録が残りません。
Fastlane: その基盤
FastlaneはiOSデプロイ自動化の事実上の標準であり、それには正当な理由があります。コード署名、ビルド、アップロード、メタデータ管理を、デプロイチェックリストのように読めるRubyベースのDSLを通じて処理します。
初期設定
既存のプロジェクトにFastlaneをセットアップするのは簡単です。
# Install Fastlane
brew install fastlane
# Initialize in your project directory (for Flutter, run in ios/)
cd ios
fastlane init
初期化中に、Fastlaneは何を自動化したいか尋ねます。手動セットアップを選択し、すべてを明示的に設定することをお勧めします。自動セットアップは、特にFlutterアプリの場合、プロジェクト構造と常に一致しない仮定を置くことがあります。
結果として、AppfileとFastfileを含むfastlane/ディレクトリが作成されます。
# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("your@email.com")
itc_team_id("123456789")
team_id("ABCD1234EF")
Appfileには、Apple Developerアカウントの詳細が保存されます。Apple IDが複数のチームに属している場合、チームIDは重要です。これがないと、Fastlaneが対話的にプロンプトを表示し、CIが中断されます。
Matchによるコード署名
コード署名は、ほとんどのiOS自動化の取り組みが頓挫する場所です。プロビジョニングプロファイル、証明書、エンタイトルメント — システム全体が自動化を罰するように設計されていると感じられます。Fastlaneのmatchツールは、証明書とプロファイルをプライベートなGitリポジトリまたはクラウドストレージに保存することでこれを解決し、ビルドが必要なあらゆるマシンからアクセスできるようにします。
Matchのセットアップ
fastlane match init
これにより、ストレージバックエンドを設定するMatchfileが作成されます。
# fastlane/Matchfile
git_url("https://github.com/your-org/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.yourapp"])
次に、証明書を生成して保存します。
# Generate development certificates and profiles
fastlane match development
# Generate App Store distribution certificates and profiles
fastlane match appstore
Matchは、リポジトリにプッシュする前にすべてを暗号化します。セットアップ中にパスフレーズを設定しますが、これはCIを含むすべてのマシンで必要になります。
手動署名ではなくMatchを使用する理由
代替案である、Apple Developerポータルを通じて証明書を手動で管理し、.p12ファイルを配布する方法は、機能しなくなるまで機能します。一般的な失敗モードには以下が含まれます。
- 開発者の証明書が期限切れになり、リリースビルドが失敗するまで誰も気づかない。
- 誰かが署名の問題をデバッグ中に証明書を失効させ、それに依存するすべてのプロファイルを無効にする。
- 新しいCIマシンにプロビジョニングが必要になり、元のマシンをセットアップした人がチームを去っている。
Matchは、単一の信頼できる情報源となることで、これらすべてを排除します。証明書が期限切れになった場合、fastlane match nukeを実行して再生成します。すべてのマシンが同じリポジトリからプルします。
CIでのコード署名
CIでは、GUIでロックを解除できないため、macOSのキーチェーンを処理する必要があります。Fastlaneはこれのためのヘルパーを提供します。
lane :ci_setup do
create_keychain(
name: "ci_keychain",
password: ENV["KEYCHAIN_PASSWORD"],
default_keychain: true,
unlock: true,
timeout: 3600,
lock_when_sleeps: false
)
match(
type: "appstore",
keychain_name: "ci_keychain",
keychain_password: ENV["KEYCHAIN_PASSWORD"],
readonly: true
)
end
readonly: trueフラグはCIにとって非常に重要です。これがないと、matchは既存の証明書が見つからない場合に新しい証明書を作成しようとする可能性があり、対話的な認証なしでは失敗し、既存の証明書を失効させる可能性があります。
Gymによるビルド
GymはFastlaneのビルドツールです。xcodebuildを適切なデフォルト設定とより良いエラー出力でラップします。
lane :build do
gym(
scheme: "Runner",
workspace: "Runner.xcworkspace",
export_method: "app-store",
output_directory: "./build",
output_name: "YourApp.ipa",
clean: true,
include_bitcode: false,
export_options: {
provisioningProfiles: {
"com.yourcompany.yourapp" => "match AppStore com.yourcompany.yourapp"
}
}
)
end
Flutterプロジェクトの場合、2つの選択肢があります。flutter build ipaを実行して出力を処理するか、Flutterが生成するXcodeワークスペースで直接gymを使用するかです。私は署名とエクスポートオプションをより細かく制御できるためgymを好みますが、どちらも機能します。
# Flutter-specific build lane
lane :build_flutter do
Dir.chdir("..") do
sh("flutter build ios --release --no-codesign")
end
gym(
scheme: "Runner",
workspace: "Runner.xcworkspace",
export_method: "app-store",
clean: false, # Flutter already built, no need to clean
include_bitcode: false
)
end
Flutterビルドステップの--no-codesignフラグは意図的なものです。Xcodeの自動署名に頼るのではなく、matchで管理されたプロファイルを使用してgymに署名を処理させます。
PilotによるTestFlightへのアップロード
PilotはTestFlightへのアップロードとベータテスターの管理を処理します。
lane :beta do
ci_setup
build_flutter
pilot(
skip_waiting_for_build_processing: true,
apple_id: "1234567890",
distribute_external: false,
notify_external_testers: false
)
end
skip_waiting_for_build_processingフラグはCIにとって非常に重要です。これがないと、FastlaneはAppleがビルドの処理を終えるまでApp Store Connectをポーリングし続けます。これには15〜45分かかることがあります。その間、CIランナーはアイドル状態になり、計算クレジットを消費します。アップロードして、処理を非同期で行わせる方が良いでしょう。
TestFlightテスターの管理
テスターグループの管理も自動化できます。
lane :distribute_to_testers do
pilot(
distribute_external: true,
groups: ["External Beta Testers"],
changelog: "Bug fixes and performance improvements",
notify_external_testers: true
)
end
外部テスターの場合、Appleは外部グループに送信される最初のビルド、および新しい外部テストグループを追加するたびにベータアプリレビューを要求することを覚えておいてください。これは自動化できない手動のゲートです。最初の外部配布では24〜48時間の遅延を計画してください。
DeliverによるApp Storeへの申請
アプリが本番環境の準備ができたら、deliverがApp Storeへの申請を処理します。
lane :release do
ci_setup
build_flutter
deliver(
submit_for_review: true,
automatic_release: false,
force: true, # Skip HTML preview verification
submission_information: {
add_id_info_uses_idfa: false
},
precheck_include_in_app_purchases: false
)
end
Deliverは、App Storeのメタデータ(スクリーンショット、説明、キーワード、リリースノート)も管理でき、これらはすべてリポジトリ内のファイルとして保存されます。
fastlane/metadata/en-US/
├── description.txt
├── keywords.txt
├── name.txt
├── release_notes.txt
├── subtitle.txt
└── privacy_url.txt
fastlane/screenshots/en-US/
├── iPhone 6.5" Display/
│ ├── 01_home.png
│ ├── 02_detail.png
│ └── 03_settings.png
└── iPad Pro 12.9" Display/
├── 01_home.png
└── 02_detail.png
これはFastlaneの最も過小評価されている機能の1つです。App Storeのリスティングをバージョン管理下に置くことで、プルリクエストでメタデータの変更をレビューしたり、説明がいつ更新されたかを追跡したり、コピーライターがエラーを犯した場合にロールバックしたりすることができます。
バージョン管理戦略
バージョン管理は驚くほど議論の的になります。私が採用しているアプローチは、マーケティングバージョンにはセマンティックバージョニングを、ビルド番号には自動インクリメントを使用する組み合わせです。
lane :bump_patch do
increment_version_number(bump_type: "patch")
increment_build_number
commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end
lane :bump_minor do
increment_version_number(bump_type: "minor")
increment_build_number(build_number: 1)
commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end
特にビルド番号については、単純なインクリメント整数ではなく、CIビルド番号またはタイムスタンプを使用することをお勧めします。これにより、複数のブランチが同時にビルドされる際の競合を回避できます。
lane :set_ci_build_number do
build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
increment_build_number(build_number: build_number)
end
Flutterプロジェクトの場合、pubspec.yamlをXcodeプロジェクトと同期させる必要もあります。
lane :sync_flutter_version do
version = get_version_number
build = get_build_number
Dir.chdir("..") do
sh("sed -i '' 's/^version: .*/version: #{version}+#{build}/' pubspec.yaml")
end
end
完全なFastfile
ここに、すべてを組み合わせた本番環境のFastfileを示します。
default_platform(:ios)
platform :ios do
before_all do
setup_ci if ENV["CI"]
end
desc "Setup CI environment"
lane :ci_setup do
create_keychain(
name: "ci_keychain",
password: ENV["KEYCHAIN_PASSWORD"],
default_keychain: true,
unlock: true,
timeout: 3600,
lock_when_sleeps: false
)
match(
type: "appstore",
keychain_name: "ci_keychain",
keychain_password: ENV["KEYCHAIN_PASSWORD"],
readonly: true
)
end
desc "Build the Flutter iOS app"
lane :build_flutter do
Dir.chdir("..") do
sh("flutter build ios --release --no-codesign")
end
gym(
scheme: "Runner",
workspace: "Runner.xcworkspace",
export_method: "app-store",
output_directory: "./build",
clean: false,
include_bitcode: false
)
end
desc "Deploy to TestFlight"
lane :beta do
ci_setup if ENV["CI"]
set_ci_build_number
build_flutter
pilot(
skip_waiting_for_build_processing: true,
distribute_external: false
)
slack(
message: "New TestFlight build uploaded!",
slack_url: ENV["SLACK_WEBHOOK"]
) if ENV["SLACK_WEBHOOK"]
end
desc "Deploy to App Store"
lane :release do
ci_setup if ENV["CI"]
set_ci_build_number
build_flutter
deliver(
submit_for_review: true,
automatic_release: false,
force: true,
precheck_include_in_app_purchases: false
)
end
desc "Set build number from CI"
private_lane :set_ci_build_number do
build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
increment_build_number(build_number: build_number)
end
end
GitHub Actionsとの統合
CIワークフローがすべてをまとめます。TestFlightデプロイのための完全なGitHub Actionsワークフローを以下に示します。
name: Deploy to TestFlight
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: macos-14
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.27.x'
channel: stable
cache: true
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
working-directory: ios
- name: Flutter dependencies
run: flutter pub get
- name: Deploy to TestFlight
working-directory: ios
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: bundle exec fastlane beta
App Store Connect APIキー
Apple IDの認証情報ではなく、APIキーを使用していることに注意してください。これはCIにとって重要です。Apple ID認証には2要素認証が必要であり、非対話型環境では機能しません。APIキーはApp Store Connectの「ユーザーとアクセス」で作成され、取り消さない限り期限切れになることはありません。
# In your Appfile or Fastfile
app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
issuer_id: ENV["APP_STORE_CONNECT_API_ISSUER_ID"],
key_content: ENV["APP_STORE_CONNECT_API_KEY"],
is_key_content_base64: true,
in_house: false
)
.p8キーファイルの内容をGitHub Actionsでbase64エンコードされたシークレットとして保存します。これにより、ファイルパスの問題を回避し、キーをリポジトリから除外できます。
よくある落とし穴とその解決策
プロビジョニングプロファイルの不一致
最も一般的なビルド失敗は、プロビジョニングプロファイルとアプリのエンタイトルメントの不一致です。「no provisioning profile matching」や「code signing entitlements not valid」のようなエラーが症状として現れます。
解決策は、ほとんどの場合、matchプロファイルを再生成することです。
fastlane match nuke appstore
fastlane match appstore
次に、Xcodeプロジェクトのバンドル識別子がMatchfileおよびAppfileの内容と完全に一致していることを確認します。
エンタイトルメントの問題
アプリがプッシュ通知、関連ドメイン、Appleでサインイン、またはその他の機能を使用している場合、これらはApple DeveloperポータルとRunner.entitlementsファイルの両方で設定する必要があります。両者の不一致は、誤解を招くエラーメッセージを生成する署名失敗の原因となります。
<!-- ios/Runner/Runner.entitlements -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:yourdomain.com</string>
</array>
</dict>
</plist>
ビルド番号の拒否
App Store Connectは、同じバージョンに対して以前のアップロードよりもビルド番号が厳密に大きくないアップロードを拒否します。CIビルド番号を使用している場合、これは通常問題ありません。しかし、CIをリセットしたり、CIプロバイダーを切り替えたりすると、この問題に遭遇することがあります。解決策は、app_store_build_numberを使用して最新のビルド番号を取得し、そこからインクリメントすることです。
lane :smart_build_number do
current = app_store_build_number(live: false)
increment_build_number(build_number: current + 1)
end
Xcodeバージョンの不一致
Appleは定期的に古いXcodeバージョンで作成されたビルドのサポートを終了します。CIランナーは、開発に使用しているXcodeバージョンと一致している必要があります。GitHub Actionsでは、これを指定できます。
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
App Storeへの申請に必要な最小Xcodeバージョンについては、Appleのドキュメントを確認してください。これは主要なiOSリリースごとに変更されます。
環境固有の設定
ほとんどのアプリは、異なる環境(異なるAPIエンドポイント、異なるFirebaseプロジェクト、異なる機能フラグ)に対して異なる設定を必要とします。これをFastfileで処理します。
lane :beta_staging do
Dir.chdir("..") do
sh("flutter build ios --release --no-codesign --dart-define=ENV=staging")
end
# ... rest of build and upload
end
lane :beta_production do
Dir.chdir("..") do
sh("flutter build ios --release --no-codesign --dart-define=ENV=production")
end
# ... rest of build and upload
end
Firebaseの場合、環境ごとに異なるGoogleService-Info.plistファイルを使用し、ビルド中に適切なファイルをコピーします。
lane :configure_firebase do |options|
env = options[:env] || "production"
sh("cp ../firebase/#{env}/GoogleService-Info.plist ../Runner/GoogleService-Info.plist")
end
ビルドの健全性の監視
パイプラインが稼働したら、その健全性を可視化したいものです。私はすべてのデプロイパイプラインにいくつかの項目を追加しています。
ビルド時間の追跡 — ビルドに時間がかかり始めたら、タイムアウト制限に達する前に知りたいものです。
Slack通知 — 成功および失敗の通知により、誰もCIダッシュボードを監視する必要なく、チームに情報が提供されます。
error do |lane, exception|
slack(
message: "Build failed: #{exception.message}",
success: false,
slack_url: ENV["SLACK_WEBHOOK"]
)
end
アーティファクトの保持 — クラッシュシンボリケーションのためにdSYMファイルを保持します。それらをクラッシュレポートサービスに自動的にアップロードします。
lane :upload_symbols do
upload_symbols_to_crashlytics(
dsym_path: "./build/YourApp.app.dSYM.zip",
gsp_path: "./Runner/GoogleService-Info.plist"
)
end
投資対効果
このパイプライン全体をセットアップするには、経験者で約1日、初めての人で2〜3日かかります。ほとんどの時間はFastlaneの設定を書くことではなく、コード署名の問題と格闘することに費やされます。この初期投資の後、その後のすべてのリリースは、30〜60分の手動プロセスからgit pushへと変わります。
本当の価値は、時間の節約だけではありません。それは自信です。デプロイが単一のコマンドまたは自動トリガーで行われるようになると、より頻繁にデプロイするようになります。小規模なデプロイは、リリースごとのリスクを減らし、テスターからのフィードバックを速め、機能のイテレーションを迅速化します。このパイプラインは、節約された時間ではなく、根本的に改善された開発ワークフローによって元が取れるのです。