iOSアプリのCI/CD環境をパパっと作る

こんにちは、あらさんです。ポケモンSV最高です。
今回は面倒なXcodeの配布のための証明書をXcode13から提供されているCloud-managed certificatesを利用して自分たちの管理から脱却する内容です。

:::note
この記事は既に開発中のアプリを対象に書いています。新規でiOSアプリを作る場合はXcode Cloudを使うと一番はやいです。特に悩む部分もなくXcodeをポチポチするだけでTestflightへのデプロイまで終わります。Flutterを利用している場合は公式リファレンスを読んでCodemagic等でAndroid/iOS両方をセットアップすることが一番はやいと思います。
:::

## 現在の痛み

色々な証明書のFastlane Matchを利用したGithubでのホスティングによる手動管理、プロジェクトにあふれるsigningStyleのManualをなんとかしてAutomaticに書き換えたい。

Fastlane Matchを利用することでCIでの証明書管理は飛躍的に快適になった、しかし管理するコストは変わらず更新作業などは発生する。Bitkeyでは様々なプロダクトを扱うためできる限り管理作業を自動化することにメリットがある。この現状を打破したい、というモチベーションが原点となる。

また、特定のCI/CD製品(弊チームではBitrise)に依存していることから抜本的脱却をして、どこで構築しても同じコードが使えることを目指す。

## Fastlane Matchを利用して管理している現状

現状はgitにcertificate周りの使うものをまとめてFastlane matchを利用して管理している。
これについての詳しい内容はこちら。

https://help.apple.com/xcode/mac/current/#/devcac6ab5b3

https://help.apple.com/xcode/mac/current/#/dev154b28f09

:::note
使っていない人に向けてFastlane Matchをざっくり解説

FastlaneはGoogleが開発している開発の自動化をしてくれるスゴいツールである。例えば一般的に継続的デリバリーの仕組みを整える場合にはビルドバージョンを更新して、ビルドして、アップロードするプロセスがある。これをfastlaneの設定ファイルを記述して実行するだけで全自動化してくれるもの。

ただし上記で書いているものはXcodeのコマンドラインツールでも実現できる、必ず採用しなければいけないものではない。ビルドバージョンの更新ではagvtoolがあり、ビルドはxcodebuildがあり、アップロードにはaltoolがある。これは素直に利用できる。

しかしFastlaneにはXcode12まで採用する圧倒的な理由があった、それがFastlane Matchの存在である。Fastlaneの便利な仕組みにCode Signingを楽にしてくれるツールがついているため大変助かった。Xcode 13からは配布証明書を今回の話の中心であるCloud-managed certificatesにより管理できることからxcodebuildコマンドのみで良くなったため、移行することとした。
:::

## Xcode13からのCloud-managed certificatesで管理する未来

App store connect内部で証明書を管理する新しい仕組み、メリットは上記の期限切れが実質なくなること。管理が楽になる。

今までと違う部分に、ローカルで管理する必要があった証明書をApple側が持ってくれて、アーカイブ時に署名するタイミングで差し込んでくれるようになる。

Fastlane matchとの対比

|  | Fastlane match | Xcode cloud-managed |
| --- | --- | --- |
| 証明書の運用 | 配布証明書の作成が必要、更新期限が来たら更新作業が必要 | Appleが配布証明書を管理する、更新期限が来たら勝手に更新する。 |
| 証明書の管理 | 今までの開発環境では必ずローカルに証明書を持つ必要があったため、中央集権で管理できるような仕組みをmatchが整えた | Apple側が証明書を保持して署名までできる仕組みを整えたため開発者側で見る必要がなくなった |

ざっくりこんな感じに変わる

![signing.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133335/4c66ad59-cc6a-f547-d341-1221c5b9f0b3.png)

`allowProvisioningUpdates` をxcodebuildのコマンドに適用するとApp store connectとの通信をするようになる。

> **証明書の管理とローテーション**
> 
> 
> 新しい署名要求が受信されると、有効期限が切れる 90 日前に新しいクラウドマネージド証明書が自動的に作成されます。ソフトウェア署名要求を受信すると、最新の証明書が使用されます。アカウント所有者と管理者は、署名されたソフトウェアをデバイスで 90 日以上実行する必要がある場合、証明書、識別子、およびプロファイルで証明書のローテーションを開始することもできます。証明書の残りの有効期間 (多くの場合 180 日) が半分未満になると、手動で証明書をローテーションできます。
> 

利用するためには、それぞれの開発者のアカウントに対してCloud-managed certificatesの利用の許可をする必要がある、これはAccount Holder, AdminがWeb画面で操作する。

また開発者のアカウントでCloud-managedを有効にすることの他に、App store connect APIを利用してコマンドラインでの利用もできる。ただしApp store connect APIのキーのロールはAdminが必要（細かな権限設定がないのでこれはセンシティブな扱いになりそう？）。

- 開発者個人のローカル端末でビルドしてデプロイする場合には、その開発者のアカウントにCloud-managed certificatesの利用の許可が必要。
- BitriseなどのCIでCloud-managed certificatesを使う場合には、App store connect APIを利用する方が楽なためApp store connect API Key(.p8)のAdmin Roleを発行して利用する

https://developer.apple.com/help/account/create-certificates/cloud-managed-certificates/

https://developer.apple.com/videos/play/wwdc2021/10204/

## AppStoreConnect上で操作する

- AdminRoleのアカウントで他開発者の詳細画面を開くと「クラウド管理配布証明書へのアクセス」にチェックマークをつけて保存する。

![%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88_2022-12-28_15.47.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133335/246ec87f-1e16-012b-016b-8fc1baae5443.png)


- **これを実行したとしてもXCode上での変化は見受けられないので注意**
- **DeveloperRoleの場合は下記の手順が必要になるので要注意**

```swift
From a Admin Role perspective, this is incomprehensible and leads to guesswork for some. But the following led to the goal:

Developer Role: close Xcode
Admin Role: change the role of the user from "developer" to "admin" temporarily
Developer Role: restart Xcode and start the validation/distribution process again. it should run successfully
Developer Role: close Xcode
Admin Role: set the user's role back to "developer".
Developer Role: restart Xcode and start the validation/distribution process again. it should run successfully
```

あらさんのXcodeでarchiveしてOrganizerからuploadをした結果得られた出力を貼り付ける。

```swift
SUMMARY
Team: [REDACTED]
Certificate: Cloud Managed Apple Distribution (Expires [REDACTED])
Profile: iOS Team Store Provisioning Profile: [REDACTED] (Expires [REDACTED]) 
Symbols: Included
Architectures: arm64
Version: [REDACTED] ([REDACTED])
￼
ENTITLEMENTS
[REDACTED]
get-task-allow
false
com.apple.developer.associated-domains
applinks:[REDACTED]
com.apple.developer.team-identifier
[REDACTED]
application-identifier
[REDACTED]
```

Organizerが内部的に利用したexportOptions.plistはこれ、記載されるkeyの内容は `xcodebuild -help` に記載されている。

```xml
<?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>destination</key>
	<string>upload</string>
	<key>manageAppVersionAndBuildNumber</key>
	<true/>
	<key>method</key>
	<string>app-store</string>
	<key>signingStyle</key>
	<string>automatic</string>
	<key>stripSwiftSymbols</key>
	<true/>
	<key>teamID</key>
	<string>[REDACTED]</string>
	<key>uploadSymbols</key>
	<true/>
</dict>
</plist>
```

## 実践・CD構築

**この内容ではGithub ActionsやBitrise, CircleCIなど特定の機能を利用しない。** 
今回はBitriseを利用した方法で実現するが以下の流れの1,2をそれぞれのCIで動くように変更するとどの環境でも動くはず、またローカルでも同じ方法で動く。実際にCLIで純粋に構築する場合には考える内容が多いため適宜注釈を付けつつ流れを抑える。

全体の流れは以下の通り。

1. BitriseにAPIKey(.p8)、Key ID, Issuer IDを登録する
2. Bitriseのワークフローで登録したKey ID, Issuer ID, PrivateKeyを取得する
3. ビルド番号を一番大きいものに変更する
4. **署名なしでアーカイブ（.xcarchiveを生成）する**
5. APIキーを利用してipaを生成する
6. altoolを用いてAPIキーでAppStoreConnectに送信する

### 1. BitriseにAPIKey(.p8)、Key ID, Issuer IDを登録する

**AppStoreConnectでキーを生成する。**

:::note
💡 Cloud-managed Signingする場合には **APIキーはAdminとして作る必要がある** ことに注意。
:::

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

**APIキーを管理するアカウントにAPIキー情報を格納する。**

格納場所はProfile Settings → Apple Service Connection → API Keys → Add API Keysで開ける。

https://app.bitrise.io/me/profile#/apple_developer_account

- この画面で登録できた(2023/01/11時点)
    
    ![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133335/47f27161-eff2-ee72-93b7-d8a851860363.png)

**APIキーをAppのIntegrationで紐付ける。**

- アプリの設定からIntegrationでキーを紐付ける。

![Untitled 1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133335/1107f084-f008-2be8-bf34-6bf2a08d4ea6.png) 

![Untitled 2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133335/2201f7d9-dc81-a1da-57c4-b276111919f8.png)   

### 2. Bitriseのワークフローで登録したKey ID, Issuer ID, PrivateKeyを取得する

BitriseのワークフローでProfile Settingsに登録したキー情報を直接取得するスクリプトを記述する。

Apple Service Connectionで登録した情報は `$BITRISE_BUILD_URL/apple_developer_portal_data.json` にjsonとして配置されている、そのためcurlでそのまま取得して良い。

:::note
💡 `$BITRISE_BUILD_API_TOKEN` はワークフローの実行時に環境変数として渡されるもの、headerに `BUILD_API_TOKEN` をキーとして渡すことを要求している。
:::

一通り生成ができたら後続のscriptで環境変数として扱うためにbitriseが提供している `envman` を使って登録する。

```bash
# Bitrise.yml 
# workflowなどの構造は省略
steps:
  - script:
      title: Fetch Apple Connection Credentials
      inputs:
      - content: |
          #!/bin/bash
          curl -H "BUILD_API_TOKEN: $BITRISE_BUILD_API_TOKEN" -o apple_credentials.json $BITRISE_BUILD_URL/apple_developer_portal_data.json

					# altoolを利用する場合、private_keys/AuthKey_${API_KEY_ID}.p8に
					# キーがあることを要求するためディレクトリを作成
		  mkdir private_keys
          API_KEY_ID=$(cat apple_credentials.json | jq -r '.key_id')
          API_KEY_ISSUER_ID=$(cat apple_credentials.json | jq -r '.issuer_id')
          API_KEY_PATH="private_keys/AuthKey_${API_KEY_ID}.p8"
          cat apple_credentials.json | jq -r '.private_key' > $API_KEY_PATH

          echo -n "$API_KEY_PATH" | envman add --key API_KEY_PATH
          echo -n "$API_KEY_ID" | envman add --key API_KEY_ID
          echo -n "$API_KEY_ISSUER_ID" | envman add --key API_KEY_ISSUER_ID
```

### 3. ビルド番号を一番大きいものに変更する

APIキーを利用してAppStoreConnectに送信するためにはaltoolを経由する必要がある。そしてaltoolを利用する場合には `manageAppVersionAndBuildNumber` が利用できない。

:::note
💡 これを利用する場合には直接exportArchiveによりuploadをするオプションも存在するがAppleIDを要求するためAPIKeyだけで完結させる場合には難しい。
:::

今回はagvtoolを利用してビルド番号をBitriseのワークフロー番号に書き換えている。

```bash
- script:
  title: Update build number
  inputs:
  - content: |
      #!/bin/bash
      xcrun agvtool new-version -all $BITRISE_BUILD_NUMBER
```

### 4. **署名なしでアーカイブ（.xcarchiveを生成）する**

今回の肝はここ、archiveする段階で何もせずにxcodebuildコマンドに `allowProvisioningUpdates` を渡すと開発証明書を要求する。Cloud-managed certificatesでは配布証明書の自動管理にフォーカスしているためそのままarchiveすることは証明書がないことから署名できないため失敗してしまう。開発証明書を自前で管理してもよいが、ここで署名のタイミングについて考える。署名はipaを作成する段階で配布証明書によりされる、そのため開発証明書で署名することはこの時点で必要性がない。そこでオプションとして `CODE_SIGNING_REQUIRED, CODE_SIGNING_ALLOWED` を用いて開発証明書による署名ステップを飛ばすことにする。そうすると署名スキップされるため開発証明書は管理しなくて良い。

:::note
💡 署名は必要ではないし許可もしないとするとスキップされるっぽい（要検証）。
:::

```bash
xcodebuild \
    archive \
    CODE_SIGNING_REQUIRED=NO \
    CODE_SIGNING_ALLOWED=NO \
    -workspace Root.xcworkspace \
    -scheme "$DEPLOY_TARGET" \
    -configuration Release \
    -archivePath "$XCARCHIVE_PATH"
```

### 5. APIキーを利用してipaを生成する

xcodebuildコマンドの `exportArchive` オプションを利用するとOrganizerの処理をCLIで実行できる。

- `archivePath` は入力されるxcarchiveを指定する。
- `exportPath` はipaの出力先のディレクトリを指定する。
- `exportOptionsPlist` はどのように処理するのかの設定を.plistファイルとして入力する。

```bash
xcodebuild \
    -exportArchive \
    -archivePath "$XCARCHIVE_PATH" \
    -exportPath "$EXPORT_PATH" \
    -authenticationKeyPath "$API_KEY_PATH" \
    -authenticationKeyID "$API_KEY_ID" \
    -authenticationKeyIssuerID "$API_KEY_ISSUER_ID" \
    -allowProvisioningUpdates \
    -exportOptionsPlist "$API_EXPORT_PLIST_PATH"
```

```xml
<?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>destination</key>
	<string>export</string>
	<key>method</key>
	<string>app-store</string>
	<key>signingStyle</key>
	<string>automatic</string>
	<key>stripSwiftSymbols</key>
	<true/>
	<key>teamID</key>
	<string>[REDACTED]</string>
	<key>uploadSymbols</key>
	<true/>
</dict>
</plist>
```

ipaを作成した段階でどのように作られたかを表す`DistributionSummary.plist`が同じディレクトリに生成される。これを見るとどの証明書を利用したのか分かる。ここで`Cloud managed　Apple Distribution`が記載されていると証明書管理から無事開放されたことになる。

```xml
<?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>[REDACTED]</key>
	<array>
		<dict>
			<key>architectures</key>
			<array>
				<string>arm64</string>
			</array>
			<key>bitcode</key>
			<false/>
			<key>buildNumber</key>
			<string>[REDACTED]</string>
			<key>certificate</key>
			<dict>
				<key>SHA1</key>
				<string>[REDACTED]</string>
				<key>dateExpires</key>
				<string>[REDACTED]</string>
				<key>type</key>
				<string>Cloud Managed Apple Distribution</string>
			</dict>
			<key>entitlements</key>
			<dict>
				<key>application-identifier</key>
				<string>[REDACTED]</string>
				<key>beta-reports-active</key>
				<true/>
				<key>com.apple.developer.team-identifier</key>
				<string>[REDACTED]</string>
				<key>get-task-allow</key>
				<false/>
			</dict>
			<key>name</key>
			<string>[REDACTED]</string>
			<key>profile</key>
			<dict>
				<key>UUID</key>
				<string>[REDACTED]</string>
				<key>dateExpires</key>
				<string>[REDACTED]</string>
				<key>name</key>
				<string>iOS Team Store Provisioning Profile: [REDACTED]</string>
			</dict>
			<key>symbols</key>
			<true/>
			<key>team</key>
			<dict>
				<key>id</key>
				<string>[REDACTED]</string>
				<key>name</key>
				<string></string>
			</dict>
			<key>versionNumber</key>
			<string>[REDACTED]</string>
		</dict>
	</array>
</dict>
</plist>
```

### 6. altoolを用いてAPIキーでAppStoreConnectに送信する

ipaファイルを指定してaltoolに渡す。

:::note
💡 p8キーの存在はコマンドに現れないが暗黙的にコマンドを実行しているディレクトリで `private_keys/AuthKey_<keyid>.p8` の形式で配置されていることを要求している。
:::

```bash
# Required private_keys/AuthKey_<keyid>.p8
xcrun altool \
  --upload-app \
  -f "$EXPORT_PATH/`ls $EXPORT_PATH | grep .ipa`" \
  -t ios \
  --apiKey "$API_KEY_ID" \
  --apiIssuer "$API_KEY_ISSUER_ID"
```

## ローカル環境で実行する場合のTips

ローカル環境でXcodeの設定が済んでいる場合にはAPIは必要なく、以下のコマンドと送信する情報を表すexportOptions.plistで提出までできる。

:::note
💡 前提として利用するAppleIDが**クラウド管理配布証明書へのアクセス**の権限を付与されていることに注意。
:::

```bash
xcodebuild \
    archive \
    -workspace Root.xcworkspace \
    -scheme "$DEPLOY_TARGET" \
    -configuration Release \
    -archivePath "$XCARCHIVE_PATH" \
    -allowProvisioningUpdates

  xcodebuild \
    -exportArchive \
    -archivePath "$XCARCHIVE_PATH" \
    -allowProvisioningUpdates \
    -exportOptionsPlist "$ID_EXPORT_PLIST_PATH"
```

```xml
<?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>destination</key>
	<string>upload</string>
	<key>manageAppVersionAndBuildNumber</key>
	<true/>
	<key>method</key>
	<string>app-store</string>
	<key>signingStyle</key>
	<string>automatic</string>
	<key>stripSwiftSymbols</key>
	<true/>
	<key>teamID</key>
	<string>[TEAM ID HERE]</string>
	<key>uploadSymbols</key>
	<true/>
</dict>
</plist>
```