Skip to main content
mobile2026年3月1日4分で読めます

iOSデプロイの自動化:App Store ConnectとTestFlight

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

iosfastlanecicd
iOSデプロイの自動化:App Store ConnectとTestFlight

どの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アプリの場合、プロジェクト構造と常に一致しない仮定を置くことがあります。

結果として、AppfileFastfileを含む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へと変わります。

本当の価値は、時間の節約だけではありません。それは自信です。デプロイが単一のコマンドまたは自動トリガーで行われるようになると、より頻繁にデプロイするようになります。小規模なデプロイは、リリースごとのリスクを減らし、テスターからのフィードバックを速め、機能のイテレーションを迅速化します。このパイプラインは、節約された時間ではなく、根本的に改善された開発ワークフローによって元が取れるのです。

DU

Danil Ulmashev

Full Stack Developer

一緒にお仕事しませんか?