Jenkins から TestFlight へ ― Mac 1 台で iOS リリースを半自動化した話
個人開発の iOS アプリを TestFlight に上げるたびに、xcodebuild archive → ipa 化 → validate → upload を手元で叩くのに疲れてきますよね。しかもリリースのたびに「Firebase の本番 plist 置いたっけ」「ビルド番号上げたっけ」と同じ指差し確認を繰り返す。この記事では、開発機を兼ねた Mac 1 台を Jenkins エージェントにして、ブランチ名とビルド番号を入れるだけで TestFlight まで届く手動実行パイプラインを組んだ話をまとめます。
fastlane は使いません。xcodebuild と Makefile と Jenkins の Credentials だけで組んでいます。
TL;DR
- Archive・署名・アップロードのロジックは Makefile(
make testflight)に寄せ、Jenkins は「材料の配置」と「ビルド番号の指定」だけをやる薄いラッパにしました。 - 署名まわり(Distribution 証明書・プロビジョニングプロファイル・App Store Connect API の p8 鍵)は、手動リリースと同じユーザーの Mac 環境をそのまま共用します。CI 用に署名環境を二重管理しない、が方針です。
- ワークスペースは毎回 fresh clone なので、git 管理外の素材 2 つだけを Jenkins Credentials から注入します(Firebase 本番 plist = Secret file / ASC API キー ID・Issuer ID = Secret text)。
- 一番怖い事故は「プレースホルダ plist のままビルドが成功して、ログインが壊れた ipa が TestFlight に上がる」こと。
plutilで実物検査してビルドを止めるようにしました。 - Credentials は UI 登録ではなく JCasC(Vault → k8s Secret → JCasC)で宣言的に登録。UI 登録だと再起動で消えた前科があるためです。
何を自動化して、何を自動化しないか
対象のアプリは iOS(UIKit)+ FastAPI 構成で、コンテナ側(API・Web)のビルドは既に Kaniko の Jenkins パイプラインで回っています。残っていた手作業が iOS のリリースでした。
手動リリースの手順はこうです。
- リリースブランチを checkout
- Firebase の本番用
GoogleService-Info.plistを配置(git 管理外) - ビルド番号(
CFBundleVersion)を上げる -
xcodebuild archive→ ipa 化 →altool系で validate → upload
このうち 1〜3 が「毎回同じなのに忘れると事故る」部分で、4 は Makefile に固めてありました。なので Jenkins ジョブの責務は 1〜3 の再現だけに絞り、4 は make testflight を呼ぶだけにします。パイプラインにビルドロジックを書き始めると Jenkins でしか再現できないリリースになるので、「手元でも make testflight 一発で同じことができる」状態は崩さないようにしました。
署名環境は「人間と共用」する
CI で iOS の署名をやる方法は大きく 2 つあります。
| 方式 | 概要 | 採用 |
|---|---|---|
| CI 専用の署名環境を作る | 証明書と鍵を export して CI 側のキーチェーンに import する | 見送り |
| 人間のリリース環境を共用する | エージェントを手動リリースと同じユーザーで動かす | 採用 |
チーム開発なら前者が筋ですが、個人開発 + エージェントが開発機そのもの、という条件なら後者が圧倒的に楽です。Jenkins エージェントを普段リリースしている macOS ユーザーで動かせば、
- Apple Distribution 証明書(ログインキーチェーン)
- App Store 用プロビジョニングプロファイル
~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8
が全部そのまま見えます。「CI 用に証明書を export して期限管理を二重化する」作業が丸ごと消えます。
その代わりキーチェーンのロックという地雷を踏むので、後述します。
git 管理外の素材 2 つを Credentials から注入する
ワークスペースは毎回まっさらなので、リポジトリに無いものは注入するしかありません。必要だったのは 2 つだけでした。
| 素材 | Credential 種別 | 用途 |
|---|---|---|
本番 Firebase の GoogleService-Info.plist
|
Secret file | Firebase ログイン設定(Release 構成用) |
| ASC API の キー ID / Issuer ID | Secret text ×2 | validate / upload の認証(p8 鍵本体は Mac 側に既にある) |
パイプラインの該当部分はこれだけです。
stage('素材配置') {
steps {
// Firebase 本番設定。make swift-init(archive が依存で呼ぶ)より前に
// 置くこと。先に置けば swift-init はプレースホルダで上書きしない。
withCredentials([file(credentialsId: 'app-gsi-release', variable: 'GSI_RELEASE')]) {
sh '''
set -euo pipefail
mkdir -p ios/App/Firebase/Release
cp "$GSI_RELEASE" ios/App/Firebase/Release/GoogleService-Info.plist
# 中身がプレースホルダでないことを軽く検査(REVERSED_CLIENT_ID を持つ実物か)
plutil -extract REVERSED_CLIENT_ID raw ios/App/Firebase/Release/GoogleService-Info.plist > /dev/null
'''
}
// ビルド番号はワークスペース内の settings.env だけを書き換える
sh 'make set-build B=${BUILD_NO}'
}
}
stage('Archive → TestFlight') {
steps {
withCredentials([
string(credentialsId: 'app-asc-key-id', variable: 'ASC_KEY_ID'),
string(credentialsId: 'app-asc-issuer-id', variable: 'ASC_ISSUER_ID'),
]) {
sh 'make testflight'
}
}
}
plutil の 1 行が一番大事
このパイプラインで一番価値がある行は、実は plutil -extract REVERSED_CLIENT_ID raw ... の検査だと思っています。
このプロジェクトでは、plist が無い環境でもビルドが通るように「プレースホルダ plist を自動生成する」仕組みを入れていました。開発には便利なのですが、リリースでこれを踏むと Firebase ログインが壊れた ipa が、ビルドもアップロードも成功した顔で TestFlight に並びます。壊れていることに気づくのはインストールしてログインを試した瞬間です。
だから「plist の配置漏れ・プレースホルダ混入」はビルド成功ではなくビルド失敗に倒します。実物の plist にしか無い REVERSED_CLIENT_ID キーを plutil で引けるか、という 1 行で十分でした。
ビルド番号は「ワークスペース内だけ」書き換える
App Store Connect は同じ (バージョン, ビルド番号) の組を再受付しないので、ビルド番号は毎回上げる必要があります。ここで git 上の設定ファイルを Jenkins が書き換えてコミットし始めると、リリースのたびにボットコミットが増えて履歴が汚れます。
このジョブでは make set-build でワークスペース内のファイルだけを書き換え、git には触りません。「提出が確定したビルド番号を人間がコミットして確定させる」という運用にして、真実の値は人間側に残しています。
誤爆防止の小技
手動実行ジョブとはいえ、「とりあえずビルドボタンを押したら main が TestFlight に上がった」は避けたい。パラメータの既定値を工夫しました。
string(
name: 'BRANCH',
defaultValue: '', // ← あえて空。空なら検証ステージで即失敗
description: 'アップロードするブランチ。通常 release/x.y.z(誤爆防止のため既定値なし)。'
)
-
BRANCHの既定値はあえて空。空のまま実行するとパラメータ検証で即失敗します。TestFlight に上がるのはリリース資材(release/x.y.z)だけ、を既定値レベルで強制します。 -
BUILD_NOも同様に空既定 + 正の整数チェック。 - validate → upload の順は Makefile 側で固定。ルール違反(アイコン欠落など)は upload の 5〜10 分待ちの前に数十秒で分かります。
Credentials は JCasC で宣言的に登録する
Secret file を含む 3 つの credential は、Jenkins の UI からは登録しませんでした。この Jenkins は JCasC(Configuration as Code)が credential の正本で、UI から足したものは再起動のたびに消えるからです(実際に一度、通知用 Webhook の credential が消えて通知が止まった前科があります)。
経路は他の credential と同じで Vault → k8s Secret → JCasC です。Secret file は JCasC だとこう書けます。
- file:
scope: GLOBAL
id: "app-gsi-release"
fileName: "GoogleService-Info.plist"
secretBytes: "${app-gsi-release}" # k8s Secret のキー(base64)を解決
secretBytes に base64 を入れておけば、パイプライン側からは UI で登録した Secret file と完全に同じ見え方(withCredentials の file(...))になります。plist は 1.5KB 程度なので Vault の KV に base64 で入れて問題ありませんでした。
踏んだ地雷集
errSecInternalComponent ― キーチェーンのロック
署名環境を人間と共用する方式の宿命です。GUI からログアウトした直後などにジョブを回すと、コード署名が errSecInternalComponent で落ちます。ログインキーチェーンがロックされているのが原因なので、
- Mac に GUI ログインし直す、または
-
security unlock-keychainを実行する
で復旧します。パイプラインの失敗ハンドラのコメントにこの対処を書いておくと、数ヶ月後の自分が救われます。
プラグイン検証ダイアログは CLI に出ない
Swift Package のビルドツールプラグイン(swift-openapi-generator など)を使っていると、CLI の xcodebuild は「未検証のプラグイン」で必ず止まります。GUI と違って承認ダイアログが出ないためです。-skipPackagePluginValidation を付けて回避しました。
Archive は時間がかかる前提でタイムアウトを組む
Archive + upload で 20〜30 分かかることがあります。Jenkins のジョブタイムアウトを短くしすぎると、アップロード中に切られて「ASC 側には上がったのにジョブは失敗」という一番嫌な状態になります。余裕を持って 60 分にしました。
まとめ
- ビルドロジックは Makefile に寄せ、Jenkins は「材料の配置」だけの薄いラッパにする。手元でも CI でも同じコマンドで再現できる状態を守る。
- 個人開発なら署名環境は人間と共用が楽。CI 専用の証明書管理を作らない代わりに、キーチェーンのロックだけ覚悟する。
- 「静かに壊れる」系の事故(プレースホルダ plist)は、成功ではなく失敗に倒す検査を 1 行入れる。
- Credentials が JCasC 管理の Jenkins では、UI 登録は再起動で消える。Secret file も
secretBytesで宣言的に登録する。
ブランチ名とビルド番号を入れてボタンを押すだけ、になってからは「リリースするか迷って先延ばし」が減りました。次はビルド番号の自動採番(ASC の最新ビルド番号 +1)まで自動化したいところです。
未経験から学べます!一緒に挑戦していきましょう![]()
noteもやってます↓