fastlaneでのTestFlight自動配信備忘

# 概要
fastlaneを用いてTestFlight配信の自動化を目指します。
以下はその手順の美貌になります。

# 環境
- Xcode: ver16
- CI: GitHub Actions
- Tool: fastlane
- パッケージ管理: SPM

# 前提
- Xcodeがインストールされていること
- TestFlight配信したいアプリがGitHubリポジトリにPushされていること
- fastlaneがインストールされていること
- Apple Developer Programに登録していること

# ざっくり手順
1. Distribution用のProvisioning Profileの用意
2. App Store Connect APIキーの作成
3. fastlaneの初期化
4. Fastfileの実装
5. GitHub Actionsのworkflow実装
6. 証明書用のGitHubのPrivateリポジトリを作成
7. fastlaneのmatchで証明書をリポジトリにPush
8. GitHubにシークレット変数を定義
9. GitHub Actionsのworkflowを実行!

# 1. Provisioning Profileの用意
下記記事を参考にAppStore用の証明書を作成し、ローカルでipaファイルを作成できるようにします。

https://zenn.dev/iwatos/articles/b131cf60d20131513bcb

# 2. App Store Connect APIキーの作成
下記ドキュメントの手順よりAPIキーを作成します。

https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api

下記画面でAPIキーファイル(.p8ファイル)のダウンロードと赤枠のIssuerID、キーIDを控えておきます。
![スクリーンショット 2024-09-24 12.56.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/e8a8e360-3e79-01c5-f60d-070075da02d6.png)

※APIキーファイルは一度ダウンロードすると再度ダウンロードできなくなるので注意

# 3. fastlaneの初期化
以下記事のfastlaneセットアップの手順を参考に初期化を行います。

https://dev.classmethod.jp/articles/prepare-for-ios-app-distribution-part-2/#:~:text=path%20vendor/bundle-,fastlane%E3%82%92%E3%82%BB%E3%83%83%E3%83%88%E3%82%A2%E3%83%83%E3%83%97%E3%81%99%E3%82%8B,-%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AE%E3%83%AB%E3%83%BC%E3%83%88

fastlaneディレクトリがプロジェクトディレクトリ直下に生成されていればOKです。

# 4. Fastfileの実装
fastlane/Fastfileを以下のように実装します。

```ruby
default_platform(:ios)

before_all do |lane, options|
  if lane == :beta && ENV["ENVIRONMENT"] == "CI"
    setup_ci(provider: ENV["SETUP_CI_PROVIDER"])
  end
end

platform :ios do

  desc "Upload App for TestFlight"
  lane :beta do
    scan # TestFlight前のビルド & テスト確認
    fetch_appstore_profiles # provisioning profileの取得
    increment_build_number # ビルドバージョンの自動化
    gym # ipaファイル作成
    upload_beta # TestFlightの配信要求
    commit_version_bump
    push_to_git_remote
  end

  desc "Upload ipa for TestFlight"
  lane :upload_beta do |options|
    asc_api_key
    upload_to_testflight(
      ipa: "./.build/TimeWatcher.ipa",
      demo_account_required: false,
      skip_waiting_for_build_processing: "true",
      beta_app_review_info: {
        contact_email: "taichis844@gmail.com",
        contact_first_name: "Taichi",
        contact_last_name: "Satou",
        contact_phone: "080-9754-8211",
        notes: "TimeWatcherの内部テスト"
      }
    )
  end

  desc "fetch appstore profiles and cert"
  lane :fetch_appstore_profiles do
    api_key = asc_api_key
    match(
      api_key: api_key,
      type: "appstore",
      readonly: ENV["MATCH_FETCH_READ_ONLY_MODE"]
    )
  end

  desc "Select Build Number"
  private_lane :select_build_number do |options|
    increment_build_number(
      xcodeproj: 'TimeWatcherPrj/TimeWatcher.xcodeproj',
      build_number: latest_testflight_build_number + 0.1
    )
  end

  def asc_api_key
    app_store_connect_api_key(
      key_id: ENV['ASC_KEY_ID'],
      issuer_id: ENV['ASC_ISSUER_ID'],
      key_content: ENV['ASC_KEY_CONTENT'],
      is_key_content_base64: true
    )
  end
```

#### 実装の説明
##### lane開始前の処理(before_all)
```ruby
before_all do |lane, options|
  if lane == :beta && ENV["ENVIRONMENT"] == "CI"
    setup_ci(provider: ENV["SETUP_CI_PROVIDER"])
  end
end
```
上記実装は、Fastfileで定義したlane実行時に動作する箇所です。
実装内容としては、"beta"が実行されたかつ環境変数"ENVIRONMENT"が"CI"のときに
`setup_ci`を実行するという内容になります。
`setup_ci`は後続で説明する証明書取得をCIで行ったときにキーチェーンとして証明書を保存するための設定を行なってくれる。
CI環境で取得したProvisioning Profileを使用できるようにするためのおまじないです。

ただローカルでこれを行うと、ローカルのキーチェーンに影響が出てよくないことが起きるため、
CI環境でしか行わないようにします。

#### TestFlight申請のlane
```ruby
  desc "Upload App for TestFlight"
  lane :beta do
    scan # TestFlight前のビルド & テスト確認
    fetch_appstore_profiles # provisioning profileの取得
    select_build_number # ビルドバージョンの自動化
    gym # ipaファイル作成
    upload_beta # TestFlightの配信要求
  end
```
##### ビルド & テスト(scan)

まず`scan`でビルド & テストを行うことでTestFlight配信しても良いコードかどうかを確認しています。

`scan`は本来引数設定で必要な情報を指定する必要があるのですが、上記例のように引数なしで実行する方法もあります。
それはFastfileと同階層にScanfileを作成してそこでデフォルト値を設定する方法です。
今回の例では以下のように設定しています。

```ruby
workspace("<ワークススペース名>")
scheme("<スキーム名>")
clean(true) # ビルド前にclean buildする設定
configuration("Debug") # ビルド対象の設定
output_directory("./.build") # 生成するレポートの配置ディレクトリ
destination("platform=iOS Simulator,name=iPhone 15") # ビルド & テストするシミュレーターの指定
cloned_source_packages_path("SourcePackages") # SPMの成果物のディレクトリ
xcargs("-skipPackagePluginValidation") # SPMでPluginを設定している場合にCI環境でビルドを通すために必要
```

`scan`の挙動の詳細については以下を参照してみてください。

https://docs.fastlane.tools/actions/scan/#scan

##### Provisioning Profile取得(fetch_appstore_profiles)
続いて、`fetch_appstore_profiles`ではFastfileの以下を呼び出しています。
```ruby
  def asc_api_key
    app_store_connect_api_key(
      key_id: ENV['ASC_KEY_ID'],
      issuer_id: ENV['ASC_ISSUER_ID'],
      key_content: ENV['ASC_KEY_CONTENT'],
      is_key_content_base64: true
    )
  end

  desc "fetch appstore profiles and cert"
  lane :fetch_appstore_profiles do
    api_key = asc_api_key
    match(
      api_key: api_key,
      type: "appstore",
      readonly: ENV["MATCH_FETCH_READ_ONLY_MODE"]
    )
  end
```

ここでは後続で説明するApp Store用の証明書を格納しているGitHubリポジトリから証明書を取得し、キーチェーンに設定してipaファイルをビルドできるようにします。

App Store Connect APIを使用するため、API_KEYを`app_store_connect_api_key`で生成して設定しています。
`key_id`、`issuer_id`、`key_content`は環境変数で設定していますが、内容については後ほど設定手順については後ほど説明します。

`readonly`の設定も環境変数で設定していますが、CIで利用する分にはビルド時に読み出すだけで、証明書を生成する必要はないのでtrueで良いと思います。

上記コードでipaファイルを作成するための証明書の設定は完了しました。

##### ビルド番号更新(select_build_number)
続いて、自前のlaneである`select_build_number`でTestFlightのビルド番号を更新します。
```ruby
  desc "Select Build Number"
  private_lane :select_build_number do |options|
    increment_build_number(
      xcodeproj: 'TimeWatcherPrj/TimeWatcher.xcodeproj',
      build_number: latest_testflight_build_number + 0.1
    )
  end
```
今回の例では、TestFlihgtにUploadしている最新のビルド番号から+0.1した番号をビルド番号として設定するようにしています。

基本`increment_build_number`一行でビルド番号を+1更新してくれるのですが、
プロジェクトの構成によってはそうでない場合もあるので補足します。

・プロジェクトルートディレクトリにxcodeprojファイルが存在しない場合
`increment_build_number`はデフォルトではプロジェクトルートディレクトリのxcodeprojファイルを参照して、ビルド番号を更新します。
もしデフォルトのディレクトリにxcodeprojがない場合は、上記例のように`xcodeproj`項目にxcodeprojディレクトリパスの設定が必要です。

※参考

https://github.com/fastlane/fastlane/issues/5341

・xcodeプロジェクトビルド設定のVersioning Systemを自動更新用の設定に更新していない場合
下記記事を参考に、Versioning Systemを"Apple Generic"に設定する必要があります。

https://qiita.com/ararajp/items/d9c5d296cc6470066509#%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3%E7%AE%A1%E7%90%86

少し脱線しますが、ビルド番号とアプリバージョンの違いが筆者はよくわからなかったので、少し調べてみました。
違いとしては以下の通りです。

| 番号の種類 | 意味合い |
| ---- | ---- |
| ビルド番号 | アプリバージョン内で一意の番号 |
| アプリバージョン | App Storeで公開されるアプリのバージョン |

上記概念については、以下記事が参考になりました。

https://ulog.sugiy.com/change-build-number/

##### ipaファイル作成(gym)
続いて、`gym`でTestFlightへ配信するためのipaファイルを生成します。
`gym`も`scan`と同じく本来引数が必要ですが、`Gymfile`を用いて引数設定を省略できます。

今回の例では以下Gymfileを作成しています。
```ruby
workspace("<ワークススペース名>")
scheme("<スキーム名>")
clean(true)
configuration("Release")
output_directory("./.build")    # store the ipa in this folder
output_name("TimeWatcher")  # the name of the ipa file
include_bitcode(true)
export_method("app-store")
xcargs("-skipPackagePluginValidation")
export_options({
  method: "app-store",
  provisioningProfiles: {
      "<メインターゲットのバンドルID>" => "<Provisioning Profile名>",
      "<Widgetなどがある場合はそのバンドルIDも設定する>" => "<Provisioning Profile名>" # option
    }
})
```

`gym`の詳細は以下を参照ください。

https://docs.fastlane.tools/actions/gym/

##### TestFlight配信(upload_beta)
最後に、下記自前のlaneを呼び出してTestFlight配信を行います。

```ruby
  desc "Upload ipa for TestFlight"
  lane :upload_beta do |options|
    asc_api_key # App Store Connect API Keyの設定
    upload_to_testflight(
      ipa: "./.build/TimeWatcher.ipa", # gymで作成したipaのパス指定
      skip_waiting_for_build_processing: "true", # TestFlight Upload後のビルドは待たないようにする
    )
  end
```

`skip_waiting_for_build_processing`を有効にしている理由として、
これを設定しないとApp Store Connect側でUploadしたipaファイルをビルドするまで待ち続けてしまい、CIの起動時間を増やしてしまうためです。
起動時間の長さで課金されてしまう系統なCIだと悲しいことになるため、ここでは`skip_waiting_for_build_processing`を有効にしておきます。

# 5. GitHub Actionsのworkflow実装

```yaml
name: CD_For_TestFlight　# workflowの名前

on: # workflowのトリガー定義
  push:
    branches:
      - "release/*" # releaseディレクトリのブランチにpushした際に起動
  workflow_dispatch: # 手動で起動

env: # 環境変数の定義
  DEVELOPER_DIR: /Applications/Xcode_16.app

jobs:
  build:
    runs-on: macos-latest # workflow実行時の環境指定(macosの最新版)

    steps:
      # チェックアウト(リポジトリからソースコードを取得)
      - name: Check Out repository
        uses: actions/checkout@v3

      # Xcodeの一覧出力
      - name: Show Xcode list
        run: ls /Applications | grep 'Xcode'

      # Xcodeのバージョン指定
      - name: Select Xcode version
        run: sudo xcode-select -s $DEVELOPER_DIR

      # Xcodeのバージョン出力
      - name: Show Xcode version
        run: xcodebuild -version

      # Rudy製ライブラリのキャッシュ
      - name: Cache Gems
        uses: actions/cache@v2
        with:
          path: vendor/bundle
          key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-gems-

      # Rudy製ライブラリのインストール
      - name: Install Bundled Gems
        run: |
          bundle config path vendor/bundle
          bundle install --jobs 4 --retry 3

      # SPMのライブラリのキャッシュ
      - name: Cache Swift Packages
        uses: actions/cache@v2
        with:
          path: SourcePackages
          key: ${{ runner.os }}-spm-${{ hashFiles('*.xcodeproj/project.xcworkspace/ xcshareddata/swiftpm/Package.resolved') }}
          restore-keys: ${{ runner.os }}-spm-
      # TestFlight用にデプロイ
      - name: deploy
        run: set -o pipefail &&
          bundle exec fastlane beta
        env:
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
          MATCH_KEYCHAIN_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GITHUB_TOKEN }}
          ENVIRONMENT: "CI"
          MATCH_FETCH_READ_ONLY_MODE: true
          SETUP_CI_PROVIDER: "travis"

```

workflowで行うことは上記実装例のコメントで記載しているので、細かい説明は省略します。

上記ymlファイルはリポジトリのルートディレクトリから見て以下ディレクトリに配置することで、GitHubは自動でactionを認識してくれます。
`./github/workflows/`

# 6. 証明書用のGitHubのPrivateリポジトリを作成
ipaファイルをCI環境で作成するために、Provisioning Profileや証明書の情報をCI環境でも必要になります。
そこで、今回の例ではfastlaneのmatchというツールを使用しています。

https://docs.fastlane.tools/actions/match/

ざっくりの仕組みとして、PrivateリポジトリにProvisioning Profileと証明書の情報を格納し、
CIはmatchを通じてPrivagteリポジトリにアクセスしてProvisioning Profileと証明書の情報を取得するというものです。

ここの手順では上記説明で登場した証明書の情報などを格納するPrivateリポジトリを作成します。
作成手順は以下をご参考にしてください。（からのリポジトリを作成するだけなので、知っている方は特に参考にする必要はありません。）

https://qiita.com/jtFuruhata/items/0f28ebe5828005eae9b7

# 7. fastlaneのmatchで証明書をリポジトリにPush

fastlaneのmatchを初期化します

手順としては、下記記事の設定ステップまでを実施します。
>設定ステップ
>まずは共通アカウントとレポジトリを作ります
>
>matchをチームで使うにあたって共通アカウントを作成
>証明書管理用のプライベートGitHubレポジトリを作成
>作成したら早速matchを使っていきます

https://qiita.com/yoshitaka/items/b33a45bd5d2ccf49da9c#%E8%A8%AD%E5%AE%9A%E3%82%B9%E3%83%86%E3%83%83%E3%83%97

matchの初期化が終えたら、作成されたMachfileに少し手を入れます。
```ruby
git_url("<githubリポジトリのURL>")
storage_mode("git")
type("development")
git_branch("main")　# 追加行
```
`git_branch("main")`では証明書などをPushブランチを指定します。
matchがデフォルトでPushするブランチが`master`ですが、Githubのメインブランチのデフォルトが`main`のため、その差分を追加した設定で埋めています。

続いてFastfileに以下を追記します。
このlaneはmatchでProvisioning Profileと関連する証明書を作成して、
手順6で作成したGitHubリポジトリにPushします。

```ruby
  desc "create appstore cert and profiles"
  lane :match_force_appstore do
    api_key = asc_api_key
    match(
      api_key: api_key,
      type: "appstore",
      force: true
    )
  end
```

上記追記後、プロジェクトのルートディレクトリで以下コマンドを実行します。
```
fastlane match_force_appstore
```

上記実行時にpasswordの設定を行いますが、設定したpasswordは忘れないようにしておきましょう。

6の手順で作成したリポジトリに、以下がPushされていればOKです。

- cert/distribution
- profiles/appstore

# 8. GitHubにシークレット変数を定義
プロジェクトのGitHubリポジトリの`Setting > Secrets and variables > Actions`で
`Repository secrets`に下記を登録します。

| 変数名 | 内容 | 備考 |
| ---- | ---- | ---- |
| ASC_ISSUER_ID | App Store Connect APIのissuerId | [App Store Connect](https://appstoreconnect.apple.com/access/integrations/api)で確認 |
| ASC_KEY_CONTENT | 使用するApp Store Connect APIの.p8ファイルの内容をBase64エンコードした文字列 |
| ASC_KEY_ID | 使用するApp Store Connect APIのキーID |[App Store Connect](https://appstoreconnect.apple.com/access/integrations/api)で確認 |
| FASTLANE_USER | AppleID |
| MATCH_GITHUB_TOKEN | `echo -n "[GitHubのユーザー名]:[手順6で作成したリポジトリのPAT]" \| base64`で出力した内容 | [PATの作成方法](https://qiita.com/RyutoYoda/items/c6908025c0aef4b35cdd)|
| MATCH_KEYCHAIN_PASSWORD | 手順7で設定したmatchのパスワード |
| MATCH_PASSWORD | 手順7で設定したmatchのパスワード |

# 9. GitHub Actionsのworkflowを実行!
あとは作成したfastlaneのファイルやGitHub ActionsのworkflowファイルをGitHubリポジトリのmainブランチにPushして、GitHub Actionsを実行するだけです。

以下コマンドでmainブランチにPushしましょう。

```
git push orign main
```

Push後にリポジトリのActionsタブのページを行くと、下記画面の赤枠の部分あたりに手順5で作成したymlファイルのnameで指定した名前が表示されているかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/76fdca5c-c50d-dd32-8017-9ae0bbd74198.png)

実行したworkflowの名前をクリックすると以下のような画面が表示されるので、下記赤枠のボタンを押下するとworkflowが実行されます。
上手くいけばこれでTestFlightが自動で配信されるはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/a9447557-fefe-f051-b09f-b1b3295f46b3.png)


以上、TestFlihgt配信自動化の備忘録でした。