⚙️

マルチプラットフォーム対応のCI/CD戦略 - 戦略編

に公開

はじめに

前回はFastlaneを使ったモバイルアプリの配信自動化について学びました。今回は、マルチプラットフォーム対応のCI/CD戦略について詳しく見ていきます!

ここまでで「GitHub Actionsの基本」と「Fastlaneによる配信自動化」を押さえました。でも実際にチームで運用し始めると、こんな問題にぶつかりませんか?

  • 「ステージングのつもりが本番のAPIキーでビルドしてた...」
  • 「証明書の更新、誰がやるんだっけ?」
  • 「このバージョン、どのビルドがストアに上がってるの?」

今回は、こうしたチーム開発で必ずぶつかる運用課題を戦略レベルで整理します。具体的なワークフローの実装は次回の実践編で扱うので、ここでは「どう設計するか」「なぜそうするか」に集中します。

環境分離(ステージング/本番)の設計

環境分離の重要性

React Nativeアプリ開発では、複数の環境を適切に管理することが重要です:

環境の種類:

  • 開発環境(Development): 開発者が日常的に使用
  • ステージング環境(Staging): 本番前の最終テスト
  • 本番環境(Production): ユーザーが実際に使用

環境分離のメリット:

  • リスクの軽減: 本番環境への影響を最小化
  • 品質の向上: 段階的なテストと検証
  • チーム開発の効率化: 並行開発のサポート

ブランチ戦略

環境とブランチを1対1で対応させるのが、一番事故が少ないです:

# ブランチと環境の対応
mainブランチ     → 本番環境(Production)
developブランチ  → ステージング環境(Staging)
feature/*ブランチ → 開発環境(Development)

この対応さえチームで合意しておけば、「どのブランチにマージすれば、どの環境にデプロイされるか」が明確になります。新しいメンバーが入っても迷いません。

環境ごとの設定管理

React Nativeでは、環境ごとに異なるAPI URLやサービスキーが必要です。.envファイルを環境別に用意しておくのが一般的です:

.env.development   → 開発環境用
.env.staging       → ステージング環境用
.env.production    → 本番環境用

環境別の設定で分けるべき項目:

項目 開発 ステージング 本番
API URL localhost:3000 staging-api.example.com api.example.com
ログレベル debug info error
分析ツール 無効 有効(テスト用) 有効
プッシュ通知 サンドボックス サンドボックス 本番

CI/CDでの環境切り替え:

- name: 環境設定を準備
  run: |
    if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
      cp .env.production .env
    elif [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then
      cp .env.staging .env
    else
      cp .env.development .env
    fi

ブランチに応じて.envファイルをコピーするだけ。これだけで「ステージングのつもりが本番キーだった」事故を防げます。

ちなみに最初は echo "API_URL=..." >> .env で1行ずつ書き出す方式でやってたんですが、設定項目が増えるたびにYAMLを修正するのが面倒になって、.env.{環境名}ファイルを事前に用意しておく方式に落ち着きました。

証明書とキーストアの管理方法

なぜ証明書管理が重要なのか

モバイルアプリのCI/CDで一番ハマるのがここです。Webアプリだと証明書を意識する場面は少ないですが、モバイルは署名がないとストアにアップロードすらできません。

しかもAndroidとiOSで仕組みが全然違う。ここを雑に管理してると、リリース当日に「証明書が期限切れで配信できない!」みたいな悲劇が起きます(実体験)。

Android: キーストアのCI/CD管理

戦略: Base64エンコードでSecretsに保存

キーストアはバイナリファイルなので、GitHubのSecretsにそのまま保存できません。Base64にエンコードして保存し、CI実行時にデコードする方法が定番です。

# ローカルでBase64化
$ base64 -i android/app/my-release-key.jks | pbcopy
# → クリップボードにコピーされる
# → GitHub Secretsに KEYSTORE_BASE64 として保存
# CI/CDでの復元
- name: キーストアを復元
  run: |
    echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/my-release-key.jks

キーストア関連のSecrets一覧:

Secret名 用途
KEYSTORE_BASE64 キーストアファイル(Base64)
KEYSTORE_PASSWORD キーストアのパスワード
KEY_ALIAS キーのエイリアス名
KEY_PASSWORD キーのパスワード

ツール編でFastlaneのensure_keystore_existsを紹介しましたが、あれはまさにこのBase64デコード処理をFastlane側でやっているものです。CI側でやるかFastlane側でやるかはチームの好みですが、どちらか一箇所に統一しておきましょう。

注意: echoでのデバッグ出力は絶対に避けてください。CIログにパスワードやBase64文字列が残ると、リポジトリにアクセスできる全員に見えてしまいます。

iOS: Matchによる証明書管理戦略

iOSの証明書管理はAndroidより格段に複雑です。ツール編でMatchの基本的な使い方は解説したので、ここではチーム運用の観点から戦略を整理します。

Matchの運用戦略:

┌─────────────────────────────────────────┐
│  証明書リポジトリ(プライベート)         │
│  ├── certs/                             │
│  │   ├── development/                   │
│  │   └── distribution/                  │
│  └── profiles/                          │
│      ├── development/                   │
│      └── appstore/                      │
└─────────────────────────────────────────┘
        ↑ 暗号化して保存
        ↑ MATCH_PASSWORDで復号

チームでの運用ルール(ここが重要):

操作 誰がやるか readonly タイミング
証明書の取得 CI / 全メンバー true 毎回のビルド
証明書の更新 管理者のみ false 年1回(期限切れ前)
新メンバー同期 本人 true 参加時に1回
# CI環境では取得のみ(安全)
match(type: "appstore", readonly: true)

# 管理者が手動で更新(証明書の更新時のみ)
# $ fastlane certificates
match(type: "appstore", readonly: false)

ポイントはreadonly: trueをデフォルトにすること。CIが誤って証明書を上書きする事故を防げます。証明書の更新は年1回程度なので、そのタイミングで管理者が手動実行する運用にしておくと安全です。

逆に言うと、readonly: falseでCIを回すのは「誰かが気づかないうちにチーム全員の証明書が変わってた」という事故の元なので、PRレビューでも気をつけたいポイントです。

バージョン管理の自動化戦略

バージョン番号の設計

React Nativeアプリでは、複数箇所でバージョンを管理する必要があります:

package.json         → "version": "1.2.3"
android/build.gradle → versionName "1.2.3", versionCode 42
ios/Info.plist       → CFBundleShortVersionString "1.2.3"
                       CFBundleVersion "42"

これが全部バラバラだと何が起きるか?

  • ストアへのアップロードが拒否される
  • ユーザーに表示されるバージョンが環境ごとに違う
  • デバッグ時にどのバージョンか特定できない

一度やらかすと、「あれ、このバグってどのバージョンで入ったんだっけ?」が追跡不能になって地獄です。

自動化の戦略

戦略1: package.jsonを「唯一の正」にする(Single Source of Truth)

バージョン名の正はpackage.jsonの1箇所だけ。CIでAndroidとiOSに自動同期します。

- name: バージョンを同期
  run: |
    VERSION=$(node -p "require('./package.json').version")
    
    # Androidのbuild.gradleを更新
    sed -i '' "s/versionName \"[^\"]*\"/versionName \"$VERSION\"/" android/app/build.gradle
    
    # iOSのInfo.plistを更新
    /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" ios/MyApp/Info.plist

注意: sed -iの挙動はOSによって異なります。macOS(BSD sed)ではsed -i ''(空文字列が必要)、Linux(GNU sed)ではsed -i(引数なし)です。上記はmacOSセルフホストランナー向けの書き方です。Linuxランナーに応用する場合は''を外してください。

戦略2: ビルド番号の自動インクリメント

バージョン名(1.2.3)は手動で管理し、ビルド番号(42, 43, 44...)はCIで自動インクリメントするのが実用的です。

# Androidのビルド番号をGitHub Actionsの実行番号で管理
- name: ビルド番号を設定
  run: |
    BUILD_NUMBER=${{ github.run_number }}
    sed -i '' "s/versionCode [0-9]*/versionCode $BUILD_NUMBER/" android/app/build.gradle

github.run_numberはワークフローの実行ごとに自動でインクリメントされるので、手動管理が不要になります。ただし、ワークフローを再作成するとリセットされるため、ストアのバージョンコードとの整合性には注意が必要です。

戦略3: ストアの最新バージョンコードから+1する

一番堅牢な方法です。ツール編で紹介したFastlaneのgoogle_play_track_version_codeslatest_testflight_build_numberを使って、ストア側の最新値を取得して+1します。

  • 複数のCIジョブが並行しても番号が衝突しにくい
  • ワークフローの再作成でリセットされない
  • ストア側の実態に常に合っている

デメリットはストアAPIへのアクセスが必要なことと、API障害時にビルドが止まること。戦略2とのフォールバック併用がおすすめです。

どの戦略を選ぶか?

チーム規模 おすすめ 理由
1-2人 戦略2 シンプルで十分
3人以上 戦略3 + 戦略2フォールバック 並行ビルドでの衝突防止

ビルド成果物の管理と保持期間

なぜ成果物の管理が必要なのか

「3日前のステージングビルド、もう一回テストしたい」
「本番で問題が出た、前のバージョンに戻したい」

こういう場面で、ビルド成果物が残っていないと最初からビルドし直しになります。React Nativeのビルドは10分以上かかるので、「今すぐ戻したい」ときにビルド待ちは致命的です。

保持期間の設計

成果物を永久に残すとストレージを圧迫するので、環境ごとに保持期間を設計します:

環境 保持期間 理由
開発(feature) 7日 PRマージ後は不要
ステージング 30日 テスト期間+バッファ
本番 90日 ロールバックに備える
# 環境ごとに保持期間を変える例
- name: 成果物を保存(ステージング)
  if: github.ref == 'refs/heads/develop'
  uses: actions/upload-artifact@v4
  with:
    name: staging-build-${{ github.run_number }}
    path: android/app/build/outputs/bundle/release/*.aab
    retention-days: 30

- name: 成果物を保存(本番)
  if: github.ref == 'refs/heads/main'
  uses: actions/upload-artifact@v4
  with:
    name: production-build-${{ github.run_number }}
    path: android/app/build/outputs/bundle/release/*.aab
    retention-days: 90

成果物の命名規則

後から探しやすいように、命名規則を統一しておきましょう:

{プラットフォーム}-{環境}-{バージョン}-{ビルド番号}
例: android-production-1.2.3-42
    ios-staging-1.2.3-41

GitHub Actionsのアーティファクト一覧から、いつの・どの環境の・どのバージョンか一目でわかるようになります。地味ですが、障害対応時に「あのビルドどこ?」で5分無駄にするのを防げます。

エラーハンドリングとロールバック戦略

CI/CDで起きやすいエラーのパターン

モバイルアプリのCI/CDは、Webアプリと比べてエラーが発生しやすいです。ビルドツールが多い分、壊れるポイントも多い:

エラー 原因 頻度
Gradleビルド失敗 依存関係の競合、メモリ不足
Pod install失敗 バージョン競合、キャッシュ不整合
証明書エラー 期限切れ、プロファイル不一致
ストアアップロード失敗 APIキー無効、バージョン重複
タイムアウト ネットワーク遅延、ビルド肥大化

体感として、Gradleとpod installの失敗が全体の7割くらいを占めます。

エラーハンドリングの戦略

戦略1: 一時的エラーへのリトライ

ネットワーク起因のエラーは、リトライで解決することが多いです:

- name: 依存関係をインストール(リトライ付き)
  uses: nick-fields/retry@v3
  with:
    timeout_minutes: 10
    max_attempts: 3
    retry_wait_seconds: 30
    command: npm ci

戦略2: キャッシュ起因のエラーへの自動クリーンアップ

- name: Androidビルド
  id: android-build
  run: |
    cd android
    ./gradlew assembleRelease
  continue-on-error: true

- name: キャッシュクリアして再ビルド
  if: steps.android-build.outcome == 'failure'
  run: |
    cd android
    ./gradlew clean
    ./gradlew assembleRelease --no-build-cache

最初のビルドが失敗したら、キャッシュをクリアして再実行。Gradleのキャッシュ不整合が原因のエラーはこれで大体解決します。「とりあえずcleanして再ビルド」、CI/CDでも人間と同じことをやるわけです。

ロールバック戦略

ここがモバイルアプリCI/CDの一番の悩みどころです。

Webアプリとの決定的な違い:

  • Webアプリ: サーバー側でバージョンを切り替えるだけ。秒で戻せる
  • モバイルアプリ: ストアに再アップロードが必要。ユーザーの端末には古いバージョンが残る。App Storeは審査が必要なので即時ロールバックは不可能

つまり、モバイルアプリでは「壊れてから戻す」より「壊れないようにリリースする」方が圧倒的に重要です。

現実的なロールバック手段:

プラットフォーム ロールバック方法 所要時間
Google Play 段階的ロールアウトの停止 + 前バージョン再公開 数時間
App Store 緊急修正ビルドを優先審査で提出 1-2日

段階的ロールアウトで予防する:

いきなり全ユーザーにリリースするのではなく、段階的にリリースすることでリスクを軽減できます:

10% → 1日様子見 → 25% → 1日様子見 → 50% → 100%

問題が見つかった場合、ロールアウトを停止すれば影響範囲を最小限に抑えられます。Google Playでは管理画面から簡単に設定できます。

ロールバック用ワークフロー(Android):

万が一に備えて、保存しておいた成果物を使って再配信するワークフローも用意しておきます:

name: Rollback Android
on:
  workflow_dispatch:
    inputs:
      run_id:
        description: 'ロールバック先のワークフロー実行ID(run_id)'
        required: true
      run_number:
        description: 'ロールバック先のビルド番号(run_number)'
        required: true
      reason:
        description: 'ロールバック理由'
        required: true

jobs:
  rollback:
    runs-on: [self-hosted, macOS, ARM64]
    steps:
    - uses: actions/checkout@v4

    - name: 前バージョンの成果物をダウンロード
      uses: actions/download-artifact@v4
      with:
        name: production-build-${{ github.event.inputs.run_number }}
        run-id: ${{ github.event.inputs.run_id }}
        path: rollback-aab

    - name: Play Storeにアップロード
      run: |
        AAB_FILE=$(ls rollback-aab/*.aab | head -1)
        bundle exec fastlane supply \
          --aab "$AAB_FILE" \
          --track production \
          --json_key "$GOOGLE_PLAY_JSON_KEY_PATH"
      env:
        GOOGLE_PLAY_JSON_KEY_PATH: ${{ secrets.GOOGLE_PLAY_JSON_KEY_PATH }}

いくつかポイントがあります:

  • download-artifactrun-idにはrun_id(GitHub全体でユニークな大きな数字)を指定します。一方nameはアップロード時の命名に合わせる必要があるのでrun_number(連番)です。この2つは別物なので注意してください
  • --aabには1ファイルのパスを明示的に渡す必要があります。ワイルドカード*.aabがシェルで複数ファイルに展開されるとエラーになるため、ls | head -1で1つに絞っています
  • fastlane productionはGradleビルドから実行するレーンなので、ダウンロード済みAABを直接アップロードするにはfastlane supply(Fastlaneのアップロード専用コマンド)を使います

セキュリティ考慮事項

CI/CDパイプラインのセキュリティ

CI/CDパイプラインは、シークレット(APIキー、証明書パスワードなど)を大量に扱うため、セキュリティの弱点になりがちです。

「CIが動けばいいや」で雑に設定すると、シークレットがログに漏れたり、ステージング用のワークフローから本番のAPIキーにアクセスできたりします。

守るべき3つの原則:

  1. 最小権限の原則: 必要なSecretsだけをワークフローに渡す
  2. シークレットのスコープ管理: リポジトリ全体ではなく環境(Environment)単位で管理
  3. ログへの漏洩防止: シークレットがCIログに出力されないことを確認

GitHub Environmentsを使った権限分離

これが一番効果的なセキュリティ対策です:

jobs:
  deploy-production:
    environment: 
      name: production
    runs-on: [self-hosted, macOS, ARM64]

Environment(環境)を使うメリット:

  • 承認フロー: 本番デプロイ前に指定レビュアーの承認を必須にできる
  • Secretsの分離: ステージングのワークフローから本番のSecretsにアクセスできない
  • デプロイ履歴: どのコミットがいつどの環境にデプロイされたか追跡できる
# 本番環境には承認を必須に(GitHub Settingsで設定)
# Settings → Environments → production → Required reviewers

特に「承認フロー」は地味に重要で、「mainにマージしたら即本番配信」だとtypoや設定ミスがそのまま本番に行ってしまいます。2名承認を挟むだけで事故率がかなり下がります。

依存関係のセキュリティ

- name: セキュリティ監査
  run: npm audit --audit-level=high

npm auditをCIに組み込んでおけば、脆弱な依存関係がマージされるのを自動的に防げます。--audit-level=highで重大な脆弱性のみをチェックし、低リスクの警告でCIが止まるのを避けています。

ジョブ終了時のクリーンアップ

第1回で導入したセルフホストランナーでは、ジョブ間でファイルが残る可能性があります。シークレットを含むファイルは確実に削除しましょう:

- name: クリーンアップ
  if: always()
  run: |
    rm -f .env
    rm -f android/app/my-release-key.jks

if: always()を付けることで、ビルドが成功しても失敗しても必ずクリーンアップが実行されます。GitHubホステッドランナーなら毎回クリーンな環境が使われるので不要ですが、セルフホストの場合は必須です。

まとめ

今回はマルチプラットフォーム対応のCI/CD戦略について学びました:

  • 環境分離(ステージング/本番)の設計とブランチ戦略
  • 証明書とキーストアの管理方法(Base64化、Match運用)
  • バージョン管理の自動化(Single Source of Truth、自動インクリメント)
  • ビルド成果物の管理と保持期間の設計
  • エラーハンドリングとロールバック戦略(段階的ロールアウト)
  • セキュリティ考慮事項(Secrets管理、クリーンアップ)

戦略のポイントまとめ:

  • 環境分離: ブランチと環境を1対1で対応させてミスを防ぐ
  • 証明書管理: CIではreadonly、更新は管理者が手動で
  • バージョン管理: package.jsonを唯一の正とし、CIで同期
  • 成果物管理: 本番90日、ステージング30日、開発7日
  • ロールバック: モバイルはWebと違う。「壊れてから戻す」より「壊れないようにリリースする」
  • セキュリティ: Environment単位でSecretsを分離、承認フローを必須に

次回は、本格的なCI/CDパイプラインの構築と運用について詳しく見ていきます!

ここまでの戦略を実際のワークフローに落とし込んで、実際のプロジェクトでの運用経験を基に、よくある問題とトラブルシューティングも解説します。


次回予告: 「本格的なCI/CDパイプラインの構築と運用」- 実践編

  • 全体CI/CDフローの設計思想
  • ステージング環境と本番環境の分離戦略(今回の戦略を実装に落とし込む)
  • エラーハンドリングとモニタリング
  • チーム開発でのCI/CD運用のコツ
  • よくある問題とトラブルシューティング
  • 今後の改善点と発展的な話題

お疲れ様でした!

VeriCerts Tech Blog

Discussion