← Blog一覧へ

iOSアプリのTestFlight配信をGitHub Actionsに載せた

#tech#iOS#GitHub Actions#Flutter

Androidのビルドと配信はタグをpushすれば終わるのに、iOSだけは手元のXcodeからArchiveしてアップロードしていました。今回そこをGitHub Actionsに移したのですが、作業時間の大半はビルドではなく署名に費やしていました。

前提として、対象はFlutterで書かれた1つのアプリです。Androidは既にCIから配信していて、iOSは同じリポジトリのコードを人間がXcodeでArchiveしてから出している状態でした。手順そのものはFlutter公式のBuild and release an iOS appに一通り書かれているので、以下はそこに書かれていない、詰まった部分の記録です。

flutter run で実機テストはできていたので、署名まわりは片付いているものだと思っていました。ここが最初の勘違いです。実機で動いているのに配信はできない。この理屈が飲み込めるまで、だいぶ回り道をしました。

署名の登場人物は3つある

iOSは原則として、Appleが認めたアプリしか実行しません。これを担保しているのがコード署名で、Androidの「提供元不明のアプリを許可」に相当する逃げ道がありません。そして署名を成立させるために、3つのものが要ります。

何者か 単位
証明書(Certificate) 開発者の身分証明 配信用はチームで1つ
App ID アプリの識別子 アプリごと
Provisioning Profile この証明書でこのアプリを署名してよいという許可証 アプリごと

自分は「1アプリにつき1証明書」だと思い込んでいました。実際は、配信に使う証明書はチームに1枚あればよく、アプリが増えたときに増やすのはProvisioning Profileのほうです。開発用の証明書だけは開発者ごとに持ちます。

署名しているのは証明書ではなく秘密鍵

証明書を作るとき、実はもうひとつ重要なものが生成されます。

証明書が発行されるまでの4ステップ。①手元のMacが鍵ペアを作り、秘密鍵はMacのキーチェーンの中だけに残る。②公開鍵を含む申請書(CSR)をAppleに送る。③この公開鍵の持ち主は自社だとAppleが署名した証明書が返ってくる。④証明書をキーチェーンに入れると、①の秘密鍵と結合して署名に使えるようになる。

署名に使うのは秘密鍵です。証明書だけを持っていても署名はできません。ここが分かると、Appleの証明書まわりの制約がまとめて腑に落ちます。証明書はCSRを作ったMacでしか使えず、他のMacやCIサーバーに持っていくには証明書と秘密鍵をセットで書き出す必要がある。他人が作った証明書は、その人から書き出してもらわない限り使えない。

拡張子が多くて混乱するので、整理するとこうなります。

拡張子 中身 用途
.certSigningRequest 申請書(公開鍵入り) Appleに証明書を申請する
.cer 証明書(公開鍵 + Appleの署名) Appleから受け取る
.p12 証明書 + 秘密鍵 他の環境へ持ち運ぶ。パスワード必須
.mobileprovision Provisioning Profile 証明書とアプリの紐付け
.p8 App Store Connect APIの鍵 アップロードの自動化

このうち .p12.p8 は、それ単体で自社名義のアプリを配信できてしまう資格情報です。CIに渡す必要があるのもこの2つなので、置き場所はGitHub Secretsだけに限定しました。

Distribution Managed は手元では使えない

Apple Developerのサイトを見ると、Distribution証明書はあるように見えていました。

Example Inc.    Distribution    Managed

ところが手元で確認すると、Distributionの署名IDが存在しません。

$ security find-identity -v -p codesigning
  1) A1B2C3D4... "Apple Development: Example Dev (XXXXXXXXXX)"
     1 valid identities found

Managed はXcodeが自動で発行して自動で管理する証明書で、秘密鍵が手元のキーチェーンに来ません。Xcode経由でしか使えないので .p12 に書き出せず、CIには持ち込めない。結局、通常のDistribution証明書を新しく発行し直すことになりました。

そもそも証明書には用途の違う2種類があります。

Apple Development Apple Distribution
用途 手元の実機で動かす TestFlight / App Storeに配信
対象端末 登録した端末のみ すべての端末
いつ使うか flutter run でのデバッグ リリース時

flutter run が通っていたのはDevelopment証明書の話でした。配信にはDistribution証明書が別途要る。冒頭の勘違いの正体はこれです。この2種類の違いはApple Developerの証明書の概要にまとまっていて、Developmentは開発者個人に、Distributionはチームに紐づくものとして整理されています。証明書と秘密鍵の関係をもう一段深く知りたい場合は、テクニカルノートのTN3161: Inside Code Signing: Certificatesが詳しいです。

CIではManual Signingしか選べない

Xcodeの署名方式にはAutomaticとManualがあります。Automaticは、ログイン済みのApple IDを使ってXcodeが必要な証明書とプロファイルを勝手に取得し、勝手に更新してくれる仕組みです。これまで手作業でTestFlightに上げられていたのは、Xcodeが裏で全部やっていたからでした。

CIではこれが使えません。理由は単純で、GitHubのランナーにはApple IDがログインされていないからです。つまり「この証明書」「このプロファイル」を明示的に指定するManual Signingに切り替える必要があります。

ただし全部をManualにすると開発時の体験が落ちるので、project.pbxproj ではビルド構成ごとに分けました。

構成 いつ使うか 署名方式
Debug flutter run での開発 Automatic
Profile パフォーマンス計測 Automatic
Release リリースビルド・CI Manual

Manualになるのはリリースビルドだけなので、日々の開発は今までどおりです。

使い捨てのキーチェーンを作って捨てる

CIのランナーは実行ごとに新しいマシンが割り当てられるため、証明書は毎回インポートすることになります。このとき既定の login.keychain に入れることはできません。unlockにログインパスワードが要るのに、CIには入力する相手がいないからです。そこでジョブ専用のキーチェーンを作って使い、最後に削除する形にしました。

この手順は、GitHub Docsとfastlaneの setup_ci が参考になります。

security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -lut 21600 build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" build.keychain
security list-keychains -d user -s build.keychain

set-key-partition-list を忘れると、codesign が秘密鍵を触るたびにGUIのパスワード確認が出ようとして、CIでは応答がないまま止まります。Provisioning Profileのほうはインポートではなく ~/Library/MobileDevice/Provisioning Profiles/ に置くだけです。

ジョブの最後にはキーチェーンごと削除しています。ただしこの後片付けが実際に効くのはself-hostedランナーのときだけです。

GitHub-hosted runners are isolated virtual machines that are automatically destroyed at the end of the job execution…On self-hosted runners, the $RUNNER_TEMP directory is cleaned up at the end of the job execution, but the keychain and provisioning profile might still exist on the runner. (GitHubホストのランナーは隔離された仮想マシンで、ジョブ終了時に自動的に破棄される。self-hostedランナーでは $RUNNER_TEMP はジョブ終了時に片付けられるが、キーチェーンとProvisioning Profileはランナー上に残る可能性がある) — GitHub Docs「Sign Xcode applications — Required: Clean up keychain and provisioning profile

今回はGitHubホストのランナーなのでVMごと消えるのですが、self-hostedに移す日が来たときに削除漏れで資格情報が残るほうが怖いので、最初から入れています。

バージョン番号でアップロードが弾かれる

iOSにはバージョン番号が2種類あります。

意味 利用者に見えるか
CFBundleShortVersionString 3.5.1 アプリのバージョン App Storeに表示される
CFBundleVersion 2 同じバージョン内の連番 内部管理用

pubspec.yamlversion: "3.5.1+2" は、+ の前がバージョン、後ろがビルド番号です。ここにAppleのルールがかかっていて、破るとアップロードの時点で拒否されます。ルールは2つあり、同じバージョン内でビルド番号は重複できないこと、そして一度承認されたバージョンは再利用できないことです。

実際に出たのは後者でした。

Invalid Pre-Release Train. The train version '3.5.0' is closed for new build submissions
CFBundleShortVersionString [3.5.0] must contain a higher version than
that of the previously approved version [3.5.0]

3.5.0 が既にApp Storeで承認済みだったので、3.5.1 に上げて解決しています。

手で採番していると必ずどちらかを踏むので、CIではタグからバージョンを、ワークフローの実行回数からビルド番号を決めるようにしました。ios-v3.5.2 というタグをpushするとバージョンが 3.5.2 になり、ビルド番号は run_number に1を足した値になります。同じバージョンで何度ビルドし直してもビルド番号が単調増加するので、1つ目のルールには当たりません。

なお、TestFlightのビルドは90日で配信が止まります。

You can test a build for up to 90 days. (ビルドをテストできるのは最長90日間) — App Store Connect Help「TestFlight overview

期限切れになってもビルド番号は再利用できないので、この点でも自動採番と相性がよいです。

途中で踏んだもの

1. 証明書が期限切れしていた

No signing certificate "iOS Development" found:
No "iOS Development" signing certificate matching team ID "XXXXXXXXXX" with a private key was found.

Apple Developerの証明書は1年で切れます。切れると flutter run すら通らなくなるので気付きやすいのですが、CI化のさなかに出ると署名設定を疑ってしまい遠回りしました。まず security find-identity -v -p codesigning を叩いて、そもそも有効な証明書が手元にあるかを見るのが早いです。

2. 署名されていないフレームワークが混ざった

Failed to verify code signature of .../flutter_secure_storage.framework
0xe800801c (No code signature found.)

アプリ本体ではなくプラグインのフレームワークに署名が付いていない、というエラーです。原因は、試行錯誤の途中で --no-codesign を付けてビルドした成果物が残っていて、差分ビルドでそのまま再利用されたことでした。

flutter clean
rm -rf ios/Pods ios/.symlinks
flutter pub get && cd ios && pod install

これで消えます。CIは毎回まっさらな環境なので、この種の問題はそもそも起きません。逆に言うと、手元でだけ再現するビルド失敗を追いかけているときは、まず環境の残骸を疑ったほうが良いです。

IPAを検証してからアップロードする

最後に1ステップだけ足しました。出来上がった .ipa を展開して、ビルドに使われたSDKのバージョン、アプリのバージョン、Bundle IDを確認してからアップロードします。SDKを見ているのは、Appleがアップロード時の最低SDKバージョンを毎年引き上げるためです。ランナーのXcodeが入れ替わって古いSDKでビルドされていたら、アップロードして初めて弾かれることになります。

unzip -q app.ipa -d /tmp/check
/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" \
  /tmp/check/Payload/Runner.app/Info.plist

署名まわりの設定は、間違っていても途中では止まらずに最後まで通ってしまうことがあります。ビルドが成功したことと、期待した成果物ができていることは別です。だから配信の直前に、成果物そのものを確認する手順を挟みました。

まとめ

Xcodeが隠してくれていたものを一度全部並べてみると、iOSの署名は複雑というより単に登場人物が多いだけでした。これでAndroidと同じく、タグをpushすればTestFlightまで届きます。