【Flutter-iOS】【GithubActions】flutterのCD環境を整備してみた

# はじめに
普段仕事ではiOSアプリを開発していますが、
昨年5月頃からFlutterに興味を持ち始め、趣味の時間でFlutterアプリの勉強を始めました。
アプリの配信は毎回手間に感じていて自動化できないか？と調査をしていた内容を共有します。iOS編とAndroid編で分けようと思っていて、今回はiOS編です。

# 目標
「Githubにコードがpushされたときに、iOSアプリをTestFlightへのビルドのアップロードする。」
ことが目標です。
TestFlightへのアップロードが自動化できるだけでもかなり作業が楽になると思います。

# Storeにビルド配信する。
では作業を開始します。

## 最終的なフォルダ構成
今回追加するファイル、フォルダを含めた最終的なフォルダの構成です。

```
.
├── .github
│   └── workflows
│       └── cd_ios.yml・・・・iOS用githubActionsの設定ファイル
├── ios
│   └── fastlane
│       ├── Appfile・・・・Store関係の設定が書かれたファイル
│       ├── Fastfile・・・・fastlaneの設定ファイル
│       └── Matchfile・・・証明書関係の設定が書かれたファイル
└── 以下省略
```

## 注意！！ ~今回説明しないもの~
申し訳ないのですが、今回は以下の環境設定方法については省略します。
- AppStore（deveploper)の登録や設定方法
- Githubの登録や設定方法
- GithubActionsの知識

## 作業の流れ
1. fastlaneを用意する
1. Storeに配信するための設定
1. Fastfileを設定する
1. github actionsを準備
1. あとはコミット！！

## 1. fastlaneを用意する
※作業ディレクトリは、/ios/ で実施します。
今回は署名の管理や面倒なビルドコマンドの省略のためにfastlaneを使用しました。
（xcodebuildやgradleなどのコマンド、flutterのコマンドからでもビルドが作成できれば、それでも良いと思います）

fastlaneには多くの便利な"アクション"が定義されています。アプリのビルドであったり、配信であったり、単体テストなども、fastlaneに定義されているアクションを利用可能です。

### 1-0. 証明書を管理するリポジトリを用意する
証明書を管理するためのリポジトリを用意します。
もちろん証明書と開発用のソースコードを一緒に管理することも可能です。

### 1-1. fastlaneを準備する
fastlaneは、iOSアプリ開発 及び Androidアプリ開発をサポートするツールです。
https://docs.fastlane.tools/ 

fastlaneの導入は以下のコマンドより実行します。

```
xcode-select --install
sudo gem install fastlane -NV
fastlane init
```

fastlane/にファイルが作成されたでしょうか？
作成されたAppfileやFastfileに設定を書き加えていきます。

### 1-2. fastlaneでmatchを使う

fastlaneは、4つの署名方法があります。
https://docs.fastlane.tools/codesigning/getting-started/ 

今回はmatchを使います。

・[match について](https://docs.fastlane.tools/actions/match/)
matchはprivate keysと証明書をgit リポジトリー上に管理する。
端末間で証明書を共有できる（githubActions上の端末も然り、エンジニア間でも共有可能！）

なお、match以外の方法は選択しませんでした。
一応、他の方法と自分が使わないと判断した理由を記載しますが、もし判断が違うなどあればコメントください！

・certとsighを使う方法・・・この方法の場合は現行の証明書ファイルをrevokeしたくないなら使うとある。が、今回はgithub上でビルドからリリースまでを行いたい。certとsighは、どちらもlocal環境に証明書ファイル、プロビジョニングプロファイルがあれば有効な方法になるが、今回はremoteのため選択しなかった。

・XCodeのAuto Signingを使う方法・・・この方法はXCodeのAuto Siginingを使用する方法になるが、公式サイトにも記載されるように、自分が使用したいプロビジョニングプロファイルが選択されるとは限らないため選択しなかった。

・Manualで行う方法・・・この方法は、Apple Developer Portalから証明書ファイルやプロビジョニングプロファイルを操作してビルド環境を構築するが、p12ファイルを適切に管理することなどが記載されており、remote環境には不向きと判断して選択していない。

【参考】
[fastlane matchの簡易使い方](https://qiita.com/Marimoiro/items/40325da6f85a06b2d51c)
[fastlane match を使用して iOS の証明書管理を行う](https://qiita.com/kotarella1110/items/840af2cf80aaea1fb035)
[iOSアプリ開発自動テストの教科書](https://gihyo.jp/book/2019/978-4-297-10629-4)

### 1-3. Matchfileの作成
matchの設定はMatchfileに記載します。そのためのMatchfileを作成します。

```
fastlane match init
```

上記を実行します。コマンド実行後にいくつか質問されます。
　storageは何を使うか？→今回はgit管理と思うので「1.git」を選びます。
　storageのURLは？→「1-0. 【iOS】証明書を管理するリポジトリを用意する」で作成したリポジトリのURLを入力します。「https://」「git」いずれもOKです。

### 1-4. SSHの設定
githubにはSSH接続をしていると思います。github内で証明書を管理しているリポジトリから証明書情報を取得する際にSSH接続を行うためのいくつかの設定を行います。

#### basic authorizationの設定

自身の認証情報を元にCD用の認証情報を作成します。

```
echo -n your_github_username:your_personal_access_token | base64
```
上記の値をgithub secretsに定義します。「MTACH_GIT_BASIC_AUTHORIZATION」など。

またlocal環境での動作の確認のためにMatchfileに値を設定します。

```
match(git_basic_authorization: '<YOUR BASE64 KEY>')
```

上記の設定は、localでの動作確認が終わったら、リポジトリにpushするときには、secretsに定義している変数に戻します。

もし認証情報の取得（echoコマンド）に失敗する場合は[githubのドキュメント](https://docs.github.com/en/rest/overview/other-authentication-methods#basic-authentication)を確認してください

#### SSH Key（DeployKey）の生成と設定
もし証明書用のリポジトリを作成していなければ不要です！！！
fastlaneがSSH経由でgithubリポジトリから証明書情報を落とすために必要です。

matchで証明書を管理しているリポジトリでDeployKeyを生成します。
[公式手順](https://docs.github.com/ja/developers/overview/managing-deploy-keys#setup)の通りに進めます。 
この時に生成するKeyにはCIから接続するためパスフレーズを設定しないようにして下さい。

上記の手順に従って、作成された「公開鍵」を、Github上のDeployKeyとして設定します。権限はRead権限で良いと思います。

作成された「公開鍵」は、Github上のDeployKeyとして設定します。
作成された「秘密鍵」は、Github上のSecretsに設定します。例えば「SSH_PRIVATE_KEY」など。

【参考記事】[GitHub ActionsでPrivate RepositoryをSSH接続でnpm installしてハマった話](https://zenn.dev/tokiya_horikawa/articles/215637f23c8407)

### 1-5. 証明書の作成
matchで使用する証明書を作成します。

ここで作成する証明書が暗号化されてリポジトリ上で管理されます。

```
fastlane match appstore
```

上記のコマンドを実行するとmasterブランチに「/cert」「/profile」が作成、それぞれ暗号化された証明書とプロビジョニングプロファイルが作成されます。

ここでstorageのPASSWORDを設定しますが、この設定値は後ほどGithubSecrets上に定義するので控えておいて下さい。

また、AppleDeveloper上でも証明書とプロビジョニングプロファイルが作成されていることを確認できます。matchから始まるものがそれです。

### 1-6. 【iOS】Matchfileの設定の確認
ここまでの作業でMatchfileの設定不足がないか、確認します。
以下の設定があればOKです。

```
git_url("{証明書を管理しているリポジトリのURL}")

storage_mode("git")

type("appstore")

app_identifier(["{bundleIdentifer}"])
username("{APPLE ID(Developer potalにログインするやつ)}")
```

## 2. Storeに配信するための設定
いよいよfastlaneを使って、ビルドから配信を行うまでの設定を行います、fastlaneの細かな設定はFastfileで行います。
と、、その前に更にいくつかの設定を行います。Storeに配信するための設定です。

### 2-1. AppStoreConnect APIの設定
iOSの配信は[upload_to_testflight](http://docs.fastlane.tools/actions/upload_to_testflight/#upload_to_testflight)を使います。
このアクションの説明でも述べられていますが、GithubActionsでiOSを自動ビルドしたときに、課題となるのが「2FA」です。
Developerログインで2FAを設定していない場合は関係ないですが。。。そういった人は少ないと思ってます。

2FAを回避する方法は、

1. AppStoreConnect API(推奨)
1. fastlane spaceauth で FASTLANE_SESSION を発行して利用する
1. App用パスワードを使う
1. 2FAを無効にする（非推奨）

とあるようですが、「fastlane spaceauth」は上手くいかず（設定しても2FAが要求された)、
公式で推奨されている「AppStoreConnect API」を使うこととしました。

APIの作成手順ですが、かなり簡単です。
ただし、"AppleDeveloperでの権限"が"AccountHolder"でないと作成できません。

1. appstoreconnect を開きます
1. 「ユーザーとアクセス」を開きます
1. 「キー」を選択します
1. 「キータイプ」で「App Store Connect API」を選びます
1. 「＋」でキーを作成します。

作成後の画面はこんな感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1782672/342b2fb3-17b6-157b-d46e-5edfc69e290f.png)

キーID、IssuerID、APIキー(.p8)の内容をコピーしておきGithub Secretsに設定します。
※p8ファイルは大事に保管します。

```
ASC_API_KEY_ID -> キーID
ASC_API_ISSUER_ID -> Issuer ID
ASC _API _KEY_CONNECT -> APIキーの内容
```

【参考】
[Authenticating with Apple services](https://docs.fastlane.tools/getting-started/ios/authentication/)
[2FAが有効なアカウントで fastlane を使ってApp Store Connectへアプリをアップロードする方法](https://zenn.dev/kkoudev/articles/365f02c9e62ccb)

## 3. Fastfileを設定する
fastlaneでは、Fastfileに自動化タスクを「レーン」として定義することで、1コマンド(fastlane {レーン名})でタスクが実行できるようになります。

今回は以下のようなFastfileを用意していました。
「build_testflight」というlaneを用意しています。

githubActionsからは、以下のように呼び出すことができるようになります。

```
fastlane build_testflight
```

Fastfileの中では、「build_testflight」以外のprivateのlaneを用意しています。

privateのlaneには、ビルド環境準備、ビルド、配信をそれぞれ用意することにしました。

設定内容なのですが、[公式ドキュメント](http://docs.fastlane.tools/actions/upload_to_testflight/#upload_to_testflight)を参考に値を変更しながらfastfileの設定値を決めています。

今後のXCodeやfastlane、AppStoreの仕様変更によって、設定内容が変わる可能性があります。

```
default_platform(:ios)

platform :ios do
  desc "testflight配信"
  lane :build_testflight do
    prepareBuild
    buildIpa
    deployTestFlight
  end
end
###############
# private lane
###############
private_lane :prepareBuild do
  # CIにはデフォのキーチェーンがないため新規作成
  create_keychain(
    name: ENV['MATCH_KEYCHAIN_NAME'],
    password: ENV['MATCH_KEYCHAIN_PASSWORD'],
    timeout: 1800
  )
  # apikeyの設定
  api_key = app_store_connect_api_key(
    key_id: ENV['ASC_API_KEY_ID'],
    issuer_id: ENV['ASC_API_ISSUER_ID'],
    key_content: ENV['ASC_API_KEY_CONNECT'],
    in_house: false
  )
  # アクションの呼び出し
  match(api_key: api_key, type: "appstore", readonly: true, git_basic_authorization: ENV['MATCH_GIT_BASIC_AUTHORIZATION'])
  # Manualに変更https://docs.fastlane.tools/actions/update_code_signing_settings/
  update_code_signing_settings(
    path: "Runner.xcodeproj",
    use_automatic_signing: false,
    team_id: ENV['TEAM_ID'],
    code_sign_identity: "Apple Distribution",
    profile_name: "match AppStore *",
    bundle_identifier: "com.hoge.hoge"
  )
end

private_lane :buildIpa do
  # https://docs.fastlane.tools/actions/build_ios_app/
  build_ios_app(
    workspace: "Runner.xcworkspace",
    scheme: "Runner",
    configuration: "Release",
    clean: true,
    output_directory: "build",
    output_name: "hogehoge.ipa",
    export_method: "app-store"
  )
end


private_lane :deployTestFlight do
  upload_to_testflight(skip_submission: true)
end
```

以下がGithubSecretsに定義する内容です。

- MATCH_KEYCHAIN_NAME：githubで立ち上げるmacのKeychainの設定です。特に指定はないため自由に設定してください。
- MATCH_KEYCHAIN_PASSWORD：githubで立ち上げるmacのKeychainの設定です。特に指定はないため自由に設定してください。
- ASC_API_KEY_ID：AppStoreConnectAPIKeyのKeyのIDを設定します。
- ASC_API_ISSUER_ID：AppStoreConnectAPIKeyのISSUERのIDを設定します。
- ASC_API_KEY_CONNECT：AppStoreConnectAPIKeyのp8ファイルの中身を設定します。
- MATCH_GIT_BASIC_AUTHORIZATION：GithubへのPersonalAccessTokenから生成して下さい。
- TEAM_ID： Apple Developerにログインして、Membershipより、TeamIdを確認してください。

### 補足

fastlaneでキーチェーンの設定がないことを怒られました。

```
72[14:03:12]: Checking out branch master... 
73[14:03:12]: 🔓  Successfully decrypted certificates repo
74[14:03:12]: Verifying that the certificate and profile are still valid on the Dev Portal... 
75[14:03:12]: Installing certificate... 
76[14:03:13]: There are no local code signing identities found. 
77You can run `security find-identity -v -p codesigning` to get this output. 
78This Stack Overflow thread has more information: https://stackoverflow.com/q/35390072/774.
79(Check in Keychain Access for an expired WWDR certificate: https://stackoverflow.com/a/35409835/774 has more info.)
```

[こちら](https://qiita.com/kotarella1110/items/840af2cf80aaea1fb035#:~:text=scheme%3A%20%22Release%22)を参考にして以下の処理を追加することで解決しています。

```
  # CIにはデフォのキーチェーンがないため新規作成
  create_keychain(
    name: ENV['MATCH_KEYCHAIN_NAME'],
    password: ENV['MATCH_KEYCHAIN_PASSWORD'],
    timeout: 1800
  )
```

【参考】
[fastlane docs/upload_to_testflight](http://docs.fastlane.tools/actions/upload_to_testflight/#upload_to_testflight)

## 4. github actionsを準備
ルートの直下に、以下の構成でファイルを用意します。

```
.github/workflows/{githubActionsの設定ファイル名}.yml
```

yamlファイルの中身は以下を設定しました。

```
# This is a basic workflow to help you get started with Actions

name: CD_iOS

on:
  push:
    branches: [ master ]

  workflow_dispatch:

jobs:
  # This workflow contains a single job called "build"
  ios_distribution:
    runs-on: macos-11
    if: |
      contains(github.event.head_commit.message, '[Release]') == true && contains(github.event.head_commit.message, '[iOS]') == true

    name: Distribution(iOS)
    steps:
    - uses: actions/checkout@v2

    - name: Show Xcode list
      run: ls /Applications | grep 'Xcode'

    - name: Select Xcode version
      run: sudo xcode-select -s '/Applications/Xcode_13.2.app/Contents/Developer'

    - name: Setup flutter
      uses: subosito/flutter-action@v1
      with:
        flutter-version: '2.5.3'

    - name: Set up tools
      run: |
        flutter pub get

    - name: ExportPath
      run: |
        export PATH=$PATH:${FLUTTER_HOME}/bin/cache/dart-sdk/bin
        export PATH=$PATH:${FLUTTER_HOME}/.pub-cache/bin

    - name: Pod cache
      uses: actions/cache@v2
      with:
        path: Pods
        key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-pods-

    - name: Pod Install
      if: steps.cache-cocoapods.outputs.cache-hit != 'true'
      run: |
        cd ios/
        pod install

      # fastlaneで必要なため、SSHキーのセットアップをする.
    - name: Setup SSH Keys and known_hosts for fastlane match
      # Copied from https://github.com/maddox/actions/blob/master/ssh/entrypoint.sh
      run: |
        SSH_PATH="$HOME/.ssh"

        mkdir -p "$SSH_PATH"
        touch "$SSH_PATH/known_hosts"

        echo "$PRIVATE_KEY" > "$SSH_PATH/id_ed25519"

        chmod 700 "$SSH_PATH"
        ssh-keyscan github.com >> ~/.ssh/known_hosts
        chmod 600 "$SSH_PATH/known_hosts"
        chmod 600 "$SSH_PATH/id_ed25519"

        eval $(ssh-agent)
        ssh-add "$SSH_PATH/id_ed25519"
      env:
        # 秘匿情報の受け渡し
        PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}

    - name: Run build and Deploy (fastlane)
      run: |
        cd ios/
        ls
        fastlane build_testflight
      env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          TEAM_ID: ${{ secrets.TEAM_ID }}
          FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
          MATCH_KEYCHAIN_NAME: ${{ secrets.MATCH_KEYCHAIN_NAME }}
          MATCH_KEYCHAIN_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
          ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
          ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
          ASC_API_KEY_CONNECT: ${{ secrets.ASC_API_KEY_CONNECT }}
```

こちらもGithubSecrets上に環境変数を定義します。

- SSH_PRIVATE_KEY：matchで証明書管理しているリポジトリへのDeployKeyの値を設定します。
- MATCH_PASSWORD：matchの証明書をdecryptを行うときのパスワードを設定します。
- FASTLANE_PASSWORD：ApppleIDのパスワードを設定します。

### 簡単な説明
#### flutterのリポジトリのため、コミットメッセージに[Release]と[iOS]がある場合にTestFlightへのアップロードを行うこととしました。

```
    if: |
      contains(github.event.head_commit.message, '[Release]') == true && contains(github.event.head_commit.message, '[iOS]') == true
```

#### githubActions上では実行環境のOSを設定する必要がありますが、iOSアプリの場合はmacOSが必要なため、macOSを設定します。

```
runs-on: macos-11
```

#### XCodeのlistをログ出力するようにしています。githubActions上のmacOSでどのXCodeが利用できるかを確認するためです。XCodeのバージョンアップがあった時などに、ログ出力しておくと役立ちます。

```
    - name: Show Xcode list
      run: ls /Applications | grep 'Xcode'
```

#### Podに関する以下のエラーが発生したため、「Pod install」のjobを追加して解決しました。

```
エラー：
error: Unable to load contents of file list: '/Target Support Files/Pods-Runner/Pods-Runner-resources-Release-output-files.xcfilelist' (in target 'Runner' from project 'Runner')
```

```
- name: Pod cache
      uses: actions/cache@v2
      with:
        path: Pods
        key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-pods-

    - name: Pod Install
      if: steps.cache-cocoapods.outputs.cache-hit != 'true'
      run: |
        cd ios/
        pod install
```

#### flutterの設定を行なってからgithubActions上でCDを実行したところ以下のエラーが発生したため、
をpub get のjobを追加して解決できました。

```
エラー：
Invalid `Podfile` file: /Users/runner/work/hoge/huge/ios/Flutter/Generated.xcconfig must exist. If you're running pod install manually, make sure flutter pub get is executed first.
```

```
    - name: Setup flutter
      uses: subosito/flutter-action@v1
      with:
        flutter-version: '2.5.3'

    - name: Set up tools
      run: |
        flutter pub get

    - name: ExportPath
      run: |
        export PATH=$PATH:${FLUTTER_HOME}/bin/cache/dart-sdk/bin
        export PATH=$PATH:${FLUTTER_HOME}/.pub-cache/bin
```

## 5. あとはコミット。祈るだけ。

あとはコミットしてみて祈るだけです。

よくあるエラーは、

```
ERROR ITMS-90189: "Redundant Binary Upload. You've already uploaded a build with build number
```

こちらは、同一バージョンでUploadしているためエラーとなっています。バージョン番号を更新して再度実行します。

## おまけ
deploygateへの配信も試してみていました。

```
private_lane :deployToDeploygate do
  # http://docs.fastlane.tools/actions/deploygate/#deploygate
  # 配布ページのhashを入れるとそこを更新してくれる！
  deploygate(
    api_token: ENV['DEPLOYGATE_API_TOKEN'],
    user: "USERNAME",
    ipa: "./build/sample_cd_debug.ipa",     
    message: "Deploygate test",
    distribution_key: "ENV['DEPLOYGATE_DIST_KEY']"
  )
end

```

こちらもGithubSecrets上に環境変数を定義しています。
- DEPLOYGATE_API_TOKEN：Deploygateのページで自身のアカウントページにあるAPIKeyを設定してます。
- DEPLOYGATE_DIST_KEY：Deploygateで配布ページを作っている場合、そのページの末尾のハッシュ値を設定してください。もし配布ページを用意していない場合は設定不要です。

【参考】[fastlaneでアップロード](https://docs.deploygate.com/docs/fastlane)

# さいごに
お疲れ様でした。

今回の記事が皆さんのflutterのCD環境の環境構築に役立てば幸いです。

iOSの環境構築では2FAの回避のところ、Keychainの設定が必要だということ、が解決に時間がかかった箇所です。

一度環境構築の方法がわかれば、以後どのFlutter / iOS nativeのプロジェクトがあっても役に立つと思うので、まずはTestFlightのビルドアップロードまで、本記事がお役に立てば幸いです。