GitHub Actions セルフホストランナーでiOSアプリをストアアップロードする（ビルド・署名・ストアアップロード）


## はじめに

GitHub Actions セルフホストランナーでiOSアプリを「ビルド」「署名」「ストアアップロード」する手法についてお話しします。
ビルドやストアアップロードは難しくないのですが、署名は一筋縄ではいかないです。
署名と聞くだけで頭が痛くなる私ですがトライアンドエラーの末なんとか解決できました。
感無量でした。この記事が同じように困っている方のヒントになればと思い執筆しました。

## GitHub Actions セルフホストランナー とは

公式ページ : https://docs.github.com/actions

説明するまでもなく色々な人が記事を書いているので触りだけ。
GitHub Actionsは基本従量課金なんですが、自前でCIマシンを用意すればここの課金コストを抑えられます。
特にMacOSはLinuxの10倍の時間を食うので洒落にならないです。大食いキャラです。
アプリのビルドを実験していて、約2日で無料枠分を食い潰した経験あります。ごめんなさい。

## リポジトリの設定でやること

### ランナーの登録
リポジトリの設定で使用するMacをセルフホストランナーとして登録します。
[公式サイト](https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/adding-self-hosted-runners)の通りやっていくだけでOKです。

### ラベルの登録
マシンにラベルをつけることで特定の端末だけでワークフローを実行できるようになるので、必要に応じてラベルを設定しておくと便利です。
[公式サイト](https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/using-labels-with-self-hosted-runners)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/79866faf-5032-46e9-7281-3318b0b4285a.png)


### リポジトリシークレットの登録
リポジトリシークレットに署名で必要な情報を登録していきます。
今回はストア用のワークフローで解説するのでストア用に必要なシークレット例を列挙します。

| シークレット名の例 | 値 | 補足 |
| ---- | ---- | ---- |
| DISTRIBUTION_CERTIFICATE_BASE64 | 配布証明書を文字列化[^(※1)]したもの | ipaの署名に必要。 |
| DISTRIBUTION_CERTIFICATE_BASE64_NAME | 配布証明書の名前 | テンポラリーキーチェーン[^(※2)]から削除するために必要。 |
| DEVELOPMENT_CERTIFICATE_BASE64 | 開発者証明書を文字列化[^(※1)]したもの | ipaの署名に必要。 |
| DEVELOPMENT_CERTIFICATE_BASE64_NAME | 開発者証明書の名前 | テンポラリーキーチェーン[^(※2)]から削除するために必要。 |
| WWDR_CERTIFICATE_BASE64 | 中間証明書を文字列化[^(※1)]したもの | 証明書を信頼する為に必要。本来は自動で入るものだがテンポラリキーチェーン[^(※2)]には自動で入らないので必要。 |
| WWDR_CERTIFICATE_BASE64_NAME | 中間証明書の名前 | テンポラリーキーチェーン[^(※2)]から削除するために必要。 |
| DISTRIBUTION_CERTIFICATE_PASSWORD | 配布証明書のパスワード | テンポラリーキーチェーン[^(※2)]に証明書を登録するときに必要 |
| DEVELOPMENT_CERTIFICATE_PASSWORD | 開発者証明書のパスワード | テンポラリーリキーチェーン[^(※2)]に証明書を登録するときに必要 |
| TEMPORARY_KEYCHAIN_NAME | テンポラリーリキーチェーン[^(※2)]の名前 | 一時的なキーチェーンファイルの名前。CIで作り直すことになるのでなんでもOK。 |
| TEMPORARY_KEYCHAIN_PASSWORD | テンポラリーリキーチェーン[^(※2)]にアクセスするためのパスワード | 一時的なキーチェーンにアクセスするためのパスワード。CIで作り直すことになるのでなんでもOK。 |
| STORE_CONNECT_AUTH_KEY | store connect apiで使用するAUTH_KEY（P8ファイル）を文字列化[^(※1)]したもの | App store connect>ユーザとアクセス>キー>App store connect APIでキーを作成するとP8ファイルがダウンロードできます。[Apple公式サイト](https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api) |
| STORE_CONNECT_KEY_ID | store connect apiで使用するKEY_ID | AUTH_KEY（P8ファイル）を作成するとキーIDが発行されます。 |
| STORE_CONNECT_ISSUER_ID | store connect ISSUER_ID | App store connect>ユーザとアクセス>キー>App store connect APIのページ載っているISSUER_IDです。 |

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/758e4e88-3b15-c5ff-91b0-b1855d69ea87.png)

今回プロビジョニングプロファイルはfastlaneで最新をダウンロードする予定なのでシークレットには登録しないです。
にしても多いですよね。もっと簡潔に署名できるようになったら嬉しいですね。
ともあれこのようにリポジトリシークレットに登録することで簡単にCI端末を追加できるようになります。

## Macでやること

CIマシンとして使うMacで作業します。

### GitHub Actionsのランナーをインストールする
リポジトリの設定でやることのランナーの登録でやっているので割愛します。

### ランナーのサービス化
CI端末は定期的に再起動などするので、サービスとして起動時にランナーが実行されるようにサービス化を推奨します。
[公式サイト](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/configuring-the-self-hosted-runner-application-as-a-service?platform=mac)

### Xcodeをインストールする
DeveloperのサイトやXcodesを使って対象のXcodeをインストールしておきます。
ここでインストールしたパスをワークフローの環境変数に設定します。

### （CIでテストを実行する場合）テストで使うシミュレータをインストールする
テストで必要な場合はシミュレータを起動できる状態にしておきます。

### （fastlaneを使用する場合）fastlaneをインストールしておく
ここはワークフローに含めるのがあるべき姿ですが今回は妥協して端末に入れています。

## GitHub Actionsのワークフローファイル作成
いよいよ本題のワークフローファイルです。

<b>プロジェクト配下の.github/workflows</b>におく必要があるのでそこにymlを作成します。

### ワークフローの名前
```yml
name: AppStoreにipaをアップロードする
```
ここにワークフローの名前を記入します。
リポジトリのActionsタブから一覧で見るときにここの名前が出てくるので分かりやす名前にしておきましょう。

### トリガーの設定
```yml
on:
  push:
    branches:
      - 'main'
```
今回はmainブランチへプッシュで動作させます。
もし手動でも実行したい場合はworkflow_dispatchも追記すると良いです。

### コンカレンシー設定
```yml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
```
なくてもいいですが、同じブランチに複数アクションが流れてきたら後続の処理を最優先に実行してくれます。（古いアクションはキャンセルされる）

### ワークフロー内で使用する環境変数の設定
```yml
env:
  DEVELOPER_DIR: /Applications/Xcode-14.2.0.app # 実行する環境によって変える
```
CI端末にインストールされているXcodeの名前をフルパスで記入します。複数CI端末がある場合は名前を同じにしておく必要があります。

### jobの指定
jobでCI端末として動かすランナーを指定します。
```yml
jobs:
  build:
    runs-on: ランナーのラベル
```
runs-onに動かしたいランナーを記載します。

### ステップの記入
```yml
jobs:
  build:
    runs-on: ランナーのラベル
    steps:
```
ステップのところに実際に処理を追記していきます。

#### チェックアウト
```yml
    - name: チェックアウト
      uses: actions/checkout@v3
      with:
        token: ${{ secrets.あなたのリポジトリへのアクセストークン }} # プライペートリポジトリでアクセストークン使う場合必要
        submodules: 'true' # サブモジュールがある場合は必要
```
アクションに紐づいているリポジトリをチェックアウトします。

#### 証明書をシークレットからデコードしてテンポラリキーチェーンにインポートする
```yml
    - name: 証明書をシークレットからデコードしてテンポラリキーチェーンにインポートする
      run: |
        # 変数用意
        DISTRIBUTION_CERTIFICATE_PATH=$RUNNER_TEMP/distribution_certificate.p12
        DEVELOPMENT_CERTIFICATE_PATH=$RUNNER_TEMP/development_certificate.p12
        WWDR_CERTIFICATE_PATH=$RUNNER_TEMP/wwdr_certificate.cer
        # シークレットから取得した証明書をデコード
        echo -n "$DISTRIBUTION_CERTIFICATE_BASE64" | base64 --decode -o $DISTRIBUTION_CERTIFICATE_PATH
        echo -n "$DEVELOPMENT_CERTIFICATE_BASE64" | base64 --decode -o $DEVELOPMENT_CERTIFICATE_PATH
        echo -n "$WWDR_CERTIFICATE_BASE64" | base64 --decode -o $WWDR_CERTIFICATE_PATH
        # キーチェーン作成
        sh RunScripts/preparing_keychain.sh "$TEMPORARY_KEYCHAIN_NAME" "$TEMPORARY_KEYCHAIN_PASSWORD"
        # 配布証明書インポート
        sh RunScripts/import_certificate.sh "$TEMPORARY_KEYCHAIN_NAME" "$TEMPORARY_KEYCHAIN_PASSWORD" "$DISTRIBUTION_CERTIFICATE_PATH" "$DISTRIBUTION_CERTIFICATE_PASSWORD"
        # 開発者証明書インポート
        sh RunScripts/import_certificate.sh "$TEMPORARY_KEYCHAIN_NAME" "$TEMPORARY_KEYCHAIN_PASSWORD" "$DEVELOPMENT_CERTIFICATE_PATH" "$DEVELOPMENT_CERTIFICATE_PASSWORD"
        # 中間証明書インポート
        sh RunScripts/import_certificate.sh "$TEMPORARY_KEYCHAIN_NAME" "$TEMPORARY_KEYCHAIN_PASSWORD" "$WWDR_CERTIFICATE_PATH"
      env:
        DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.DISTRIBUTION_CERTIFICATE_BASE64 }}
        DEVELOPMENT_CERTIFICATE_BASE64: ${{ secrets.DEVELOPMENT_CERTIFICATE_BASE64 }}
        WWDR_CERTIFICATE_BASE64: ${{ secrets.WWDR_CERTIFICATE_BASE64 }}
        DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.DISTRIBUTION_CERTIFICATE_PASSWORD }}
        DEVELOPMENT_CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPMENT_CERTIFICATE_PASSWORD }}
        TEMPORARY_KEYCHAIN_NAME: ${{ secrets.TEMPORARY_KEYCHAIN_NAME }}
        TEMPORARY_KEYCHAIN_PASSWORD: ${{ secrets.TEMPORARY_KEYCHAIN_PASSWORD }}
```
キーチェーンの作成は色々方法ありますが、私はsecurityコマンドを用いたshを採用しました。

preparing_keychain.sh
（この名前にしているのはfastlaneのcreate_keychainと競合するから）
```sh
MY_KEYCHAIN=$1
MY_KEYCHAIN_PASSWORD=$2
# ログインを読み込むキーチェーンリストに追加
security list-keychains -d user -s login.keychain
# キーチェーンを作成
security create-keychain -p "$MY_KEYCHAIN_PASSWORD" "$MY_KEYCHAIN"
# ユーザードメインに一時キーチェーンを追加
security list-keychains -d user -s "$MY_KEYCHAIN" $(security list-keychains -d user | sed s/\"//g)
# 再ロックのタイムアウトを削除
security set-keychain-settings "$MY_KEYCHAIN"
# キーチェーンのロックを解除
security unlock-keychain -p "$MY_KEYCHAIN_PASSWORD" "$MY_KEYCHAIN"
```

import_certificate.sh
```sh
MY_KEYCHAIN=$1
MY_KEYCHAIN_PASSWORD=$2
CERT_PATH=$3
CERT_PASSWORD=$4
# キーチェーンに証明書を追加
security import $CERT_PATH -k "$MY_KEYCHAIN" -P "$CERT_PASSWORD" -A -T "/usr/bin/codesign" -T "/usr/bin/productsign"
# インタラクティブシェルからのコード署名を有効にする
security set-key-partition-list -S apple-tool:,apple:, -s -k $MY_KEYCHAIN_PASSWORD -t private $MY_KEYCHAIN
# キーチェーンのロックを解除
security unlock-keychain -p "$MY_KEYCHAIN_PASSWORD" "$MY_KEYCHAIN"
```

#### fastlaneでApp StoreにipaをUploadする
```yml
    - name: Fastlaneを使ってStoreにipaをアップロードする
      run: |
        # 変数用意
        AUTH_KEY_PATH=$RUNNER_TEMP/auth_key.p8
        # シークレットから取得した証明書をデコード
        echo -n "$STORE_CONNECT_AUTH_KEY" | base64 --decode -o $AUTH_KEY_PATH
        fastlane store_upload auth_key:$AUTH_KEY_PATH
      env:
        STORE_CONNECT_KEY_ID: ${{ secrets.STORE_CONNECT_KEY_ID }}
        STORE_CONNECT_ISSUER_ID: ${{ secrets.STORE_CONNECT_ISSUER_ID }}
        STORE_CONNECT_AUTH_KEY: ${{ secrets.STORE_CONNECT_AUTH_KEY }}
```
fastlaneで用意してるのでそのまま実行します。
今回はfastlaneの中でプロビジョニングプロファイルを取得し、引数の証明書を使って署名しています。
※faslaneの処理については後述します。

#### fastlaneでの成果物を削除する
```yml
    - name: fastlaneでの成果物を削除する
      run: |
        fastlane run clean_build_artifacts
        rm -r ~/Library/Developer/Xcode/Archives/*
```
dsymやipaを削除します。これをやっとかないとストレージを圧迫します。
ipaはサイズが大きいので残しておきたい場合は削除スクリプトなど用意してローテーションさせるなどが必要ですね。

#### テンポラリキーチェーンを削除する
```yml
    - name: テンポラリキーチェーンを削除する
      if: ${{ always() }}
      run: |
        # 配布証明書削除
        sh RunScripts/delete_certificate.sh "$TEMPORARY_KEYCHAIN_NAME" "$DISTRIBUTION_CERTIFICATE_NAME"
        # 開発者証明書削除
        sh RunScripts/delete_certificate.sh "$TEMPORARY_KEYCHAIN_NAME" "$DEVELOPMENT_CERTIFICATE_NAME"
        # 中間証明書削除
        sh RunScripts/delete_certificate.sh "$TEMPORARY_KEYCHAIN_NAME" "$WWDR_CERTIFICATE_NAME"
        # キーチェーン削除
        sh RunScripts/clean_up_keychains.sh "$TEMPORARY_KEYCHAIN_NAME"
      env:
        DISTRIBUTION_CERTIFICATE_NAME: ${{ secrets.DISTRIBUTION_CERTIFICATE_NAME }}
        DEVELOPMENT_CERTIFICATE_NAME: ${{ secrets.DEVELOPMENT_CERTIFICATE_NAME }}
        WWDR_CERTIFICATE_NAME: ${{ secrets.WWDR_CERTIFICATE_NAME }}
        TEMPORARY_KEYCHAIN_NAME: ${{ secrets.TEMPORARY_KEYCHAIN_NAME }}
```
CIがエラーになっても常に削除するように「if: ${{ always() }}」を追記しています。
残っていると次のCIアクションのときにインポートが失敗します。
作成の時と同様にsecurityコマンドを用いたshを作成しました。

delete_certificate.sh
```sh
MY_KEYCHAIN=$1
CERT_PATH=$2
# キーチェーンの証明書を削除
security delete-certificate -c "$CERT_PATH" "$MY_KEYCHAIN"
```

clean_up_keychains.sh
（この名前にしているのはfastlaneのdelete_keychainと競合するから）
```sh
MY_KEYCHAIN=$1
# 一時的なキーチェーンを削除する
security delete-keychain "$MY_KEYCHAIN"
# ユーザーログインキーチェーンをデフォルトに戻す
security list-keychains -d user -s login.keychain
```

#### プロビジョニングプロファイルを削除する
```yml
    - name: プロビジョニングプロファイルを削除する
      if: ${{ always() }}
      run: rm ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision
```
CIがエラーになっても常に削除するように「if: ${{ always() }}」を追記しています。
古いプロビジョニングプロファイルが残っていると署名に失敗する可能性があります。

## fastlaneでやること
fastlaneで署名する時ちょっとしたコツがあります。

Xcodeで「自動(Automaticaliy manage siging)」を使用している状態でCIビルドすると<b>端末によって署名に失敗する</b>可能性があります。
なぜなら<b><font color="red">テンポラリーキーチェーン以外のキーチェーンに証明書が入っている場合はそっちをXcodeが参照してしまう</font></b>ことがあるからです。
なので、fastlaneから署名するとき「手動」で署名する様にlaneを組みます。
```ruby
lane :store_upload do |options|

    app_identifier = "あなたのアプリのidentifier"
    team_id = "チームID"

    # storeにapi_key認証で接続
    api_key = app_store_connect_api_key(
      key_id: ENV["STORE_CONNECT_KEY_ID"],
      issuer_id: ENV["STORE_CONNECT_ISSUER_ID"],
      key_filepath: options[:auth_key],
      duration: 1200,
      in_house: false
    )

    # プロビジョニングプロファイルをダウンロード
    sigh(
      api_key: api_key,
      force: true,
      app_identifier: app_identifier,
      skip_certificate_verification: true,
    )
    app_profile = lane_context[SharedValues::SIGH_PROFILE_PATH]

    # 手動署名に設定変更
    update_code_signing_settings(
      use_automatic_signing: false,
      path: "あなたのアプリ.xcodeproj",
      team_id: team_id,
      code_sign_identity: "iPhone Distribution",
      sdk: "iphoneos*",
    )

    # 署名
    update_project_provisioning(
      target_filter: "あなたのアプリ",
      build_configuration: "Release",
      profile: app_profile,
      code_signing_identity: "iPhone Distribution"
    )

    # ipa作成
    gym(
      scheme: "あなたのアプリ",
      clean: true,
      configuration: "Release",
      export_method: "app-store",
      xcargs: "DEVELOPMENT_TEAM=" + team_id,
      export_options: {
        signingStyle: "manual",
        compileBitcode: false,
        uploadSymbols: false,
        provisioningProfiles: {
          app_identifier => app_profile,
        },
      },
    )

    # storeにupload
    deliver(
      api_key: api_key,
      force: true,
      skip_screenshots: true,
      skip_metadata: true,
      precheck_include_in_app_purchases: false,
    )

    # profileとipaファイルが一時ディレクトリに残ってるので削除
    sh("rm " + app_profile)
    sh("rm " + lane_context[SharedValues::IPA_OUTPUT_PATH])
  end
```
手動で署名することでXcodeや端末のキーチェーンに依存することがなくなります。
つまり全く別のアプリの証明書をCI端末に入れていてもCIが失敗することはないです。

## 実行してみる
実際に実行（ブランチにpush）してみるとActionsのタブで進行状況が確認できます。
ワークフロー名をクリックすると詳細な状況やエラーになったときのログも確認が可能です。
![actions_capture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680882/2ab1d989-0a91-d09e-d041-1de69b071a4e.png)


## おわりに

はじめての構築作業ということもあり、トライアンドエラーの繰り返しで（合間ので作業だったというのもありますが）１ヶ月近くかかりました。
とても苦労しましたが、できた時の達成感もその分大きかったので本当にやってよかったなと実感しています。
コストも抑えられるのでセルフホストランナーは是非皆様にも使っていただきたいですね。


[^(※1)]: 文字列化
そのまま登録できないので文字列に変換が必要です。
[公式ドキュメント](https://docs.github.com/ja/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development)にあるとおりbase64を使用します。

[^(※2)]: テンポラリーキーチェーン
CIからアクセスするための一時的なキーチェーンファイルです。
署名が終わったら不要になるので一時的に作成して削除します。
事前にCI端末に証明書を入れておくと更新の度に入れ直さないといけなくなったり、複数台のCI端末を用意する場合は管理が煩雑になるので、テンポラリーキーチェーンを強く推奨します。