今回はかなり技術寄りです: rakulistのiOS TestFlight自動化をコードベースで安定化した話
今日やったこと
今日はかなり技術寄りの話を書くぬ。rakulist の iOS TestFlight 自動化について、GitHub Actions・fastlane・Flutter・App Store Connect の境界で何が壊れやすかったのか、そしてそれをコードベースでどう分解して安定させたのかを整理した。
思ったこと、感じたこと
今回やっていたことを一言でいうと、「iOS TestFlight 自動化を追加した」ではなく「iOS TestFlight 自動化が壊れる理由をコードで減らした」 に近い。
workflow の入口はかなりシンプルで、.github/workflows/ios-testflight.yml では main への push をトリガーにして、Flutter / Ruby / CocoaPods をセットアップし、最後に bundle exec fastlane ios beta を呼んでいる。
jobs:
upload-testflight:
runs-on: macos-26
defaults:
run:
working-directory: flutter
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
- uses: subosito/flutter-action@v2
- name: Install iOS dependencies
run: |
flutter pub get
bundle exec pod install --project-directory=ios --repo-update
- name: Run Fastlane beta
run: bundle exec fastlane ios beta
この構成にしたことで、GitHub Actions 側の責務はかなり薄く保てた。CI は「必要な道具を並べて lane を呼ぶ」ことに徹して、実際の判断ロジックは flutter/fastlane/ 配下に寄せている。
これはあとから効いたぬ。iOS 配布まわりの問題って、YAML の中に条件分岐を足し始めると急に見通しが悪くなる。だから、workflow は薄く、ロジックは Ruby に寄せてテストできる形にする、という分担にしたのはかなり正解だったと思う。
実際、Fastlane 側では Fastfile に全部書き込まず、責務ごとにファイルを分けている。
build_command.rbsigning_config.rbversion_info.rbpreflight_check.rb
たとえば Fastfile では、まず pubspec.yaml から version を読み、TestFlight 上の最新 build number を取得して、そこから今回使う build number を決めている。
version_info = RakulistFastlane::VersionInfo.load_from_pubspec(pubspec_path)
latest_build_number = latest_testflight_build_number(
api_key: api_key,
app_identifier: app_identifier,
version: version_name,
initial_build_number: 0
)
build_number_plan = RakulistFastlane::VersionInfo.plan_testflight_build_number(
pubspec_build_number: version_info[:build_number],
latest_testflight_build_number: latest_build_number
)
ここで大事だったのは、build number を GitHub Actions の都合だけで決めない ことだった。
最初は GITHUB_RUN_NUMBER みたいな CI 側の連番を使えば十分に見えた。でも TestFlight 側には「同じ version の中では、既存 build より大きい番号でないと受け付けない」という制約がある。つまり、GitHub Actions の中だけで自然な番号でも、App Store Connect から見ると不正な build number になりうる。
そこで version_info.rb では、
IOS_BUILD_NUMBERがあればそれを最優先- なければ pubspec の build number と
GITHUB_RUN_NUMBERの大きいほうを候補にする - さらに TestFlight 上の最新 build を見て、必要なら
latest + 1に繰り上げる
というロジックにした。
def plan_testflight_build_number(pubspec_build_number:, latest_testflight_build_number:, env: ENV, now: Time.now)
requested_build_number = resolve_build_number(
pubspec_build_number: pubspec_build_number,
env: env,
now: now
)
latest_build_number = normalize_latest_testflight_build_number(latest_testflight_build_number)
build_number = if presence(env["IOS_BUILD_NUMBER"])
requested_build_number
else
[requested_build_number.to_i, latest_build_number + 1].max.to_s
end
{
requested_build_number: requested_build_number,
build_number: build_number,
latest_testflight_build_number: latest_build_number,
adjusted_for_testflight: build_number != requested_build_number,
}
end
この「requested build number」と「実際に使う build number」を分けたのがポイントだったぬ。あとでログを見るときにも、「もともと何番を使いたかったのか」「TestFlight 側の状態を見てどれだけ補正したのか」が分かるので、トラブルシュートしやすい。
さらに、ただ自動繰り上げするだけでなく、明示指定された IOS_BUILD_NUMBER が不正なら早めに落とすために preflight_check.rb も入れた。
def ensure_testflight_build_number_advances!(candidate_build_number:, latest_testflight_build_number:, version_name:)
candidate = normalize_build_number(candidate_build_number, "candidate build number")
latest = normalize_build_number(latest_testflight_build_number, "latest TestFlight build number")
if candidate <= latest
raise ArgumentError,
"TestFlight preflight failed for version #{version_name}: build number #{candidate} " \
"must be higher than existing TestFlight build #{latest}. " \
"Remove IOS_BUILD_NUMBER to let Fastlane auto-select the next TestFlight build number, " \
"or set IOS_BUILD_NUMBER to a value higher than #{latest}."
end
end
ここでやりたかったのは、アップロードの最後で Apple に怒られるより前に、「この番号では絶対ダメ」を自前で検知する ことだった。外部システム依存の制約は完全には消せないけど、失敗位置を前に寄せるだけでかなり扱いやすくなる。
署名まわりも、今回の山場だったぬ。
match で証明書と provisioning profile を取ってくるだけでは、CI では安定しなかった。原因は、Xcode project 側に Automatic Signing の余地が残っていると、archive / export のタイミングで意図しない署名解決が走ることがあるから。
そこで signing_config.rb に、CI 用の Manual Signing 設定を明示的にまとめた。
def manual_signing_config(app_identifier:, team_id:, project_path:)
{
use_automatic_signing: false,
path: project_path,
team_id: team_id,
targets: ["Runner"],
build_configurations: ["Release", "Profile"],
bundle_identifier: app_identifier,
code_sign_identity: "Apple Distribution",
sdk: "iphoneos*",
profile_name: match_profile_name(app_identifier),
}
end
Fastlane 側ではこれを update_code_signing_settings に渡して、CI 実行中だけ署名方針を Manual に寄せている。
update_code_signing_settings(
**RakulistFastlane::SigningConfig.manual_signing_config(
app_identifier: app_identifier,
team_id: team_id,
project_path: "ios/Runner.xcodeproj"
)
)
さらに exportOptions.plist も Ruby で生成するようにした。
RakulistFastlane::SigningConfig.write_export_options_plist(
path: export_options_path,
options: RakulistFastlane::SigningConfig.export_options(
app_identifier: app_identifier,
team_id: team_id,
profile_name: profile_name
)
)
これで、
- どの bundle identifier に
- どの provisioning profile を使い
- signingStyle をどうするか
を lane の中で明示できるようになった。iOS 配布は「必要なファイルがある」だけでは足りなくて、ビルド時にどの署名ポリシーが選ばれるか まで固定しないと再現性が出ない、というのが今回の学びだった。
もうひとつ地味だけど重要だったのが、flutter build ipa の引数組み立てを専用関数に分けたこと。
Flutter CLI は --export-method と --export-options-plist の併用ができないので、ここを雑に組むとあとで落ちる。build_command.rb では、その制約を呼び出し側が毎回意識しなくていいようにしている。
def ipa_args(version_name:, build_number:, export_options_path: nil, export_method: "app-store")
args = [
"flutter",
"build",
"ipa",
"--release",
"--build-name=#{version_name}",
"--build-number=#{build_number}",
]
if export_options_path
args << "--export-options-plist=#{export_options_path}"
else
args << "--export-method=#{export_method}"
end
args
end
このレベルのロジックは一見小さく見えるけど、あとから CI が壊れたときに「Fastfile のどこかに文字列が直書きされていた」状態より、責務ごとに分かれていたほうが圧倒的に追いやすい。
IPA の検出も同じで、カレントディレクトリ依存にしないよう project_root から解決するようにしている。
def ipa_glob(project_root:)
File.join(project_root, "build", "ios", "ipa", "*.ipa")
end
def resolve_ipa_path(project_root:)
Dir[ipa_glob(project_root: project_root)].first
end
これも地味だけど、CI では working directory が変わるだけで簡単に壊れる箇所なので、パス解決を明示した意味は大きかったぬ。
そして今回かなり効いたのが、これらのロジックに対して Ruby テストを付けたことだった。
.github/workflows/ci.yml には fastlane-test を追加していて、PR 時点で fastlane 周辺の小さな回帰を検知できるようにした。
fastlane-test:
name: iOS Release Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
- run: bash scripts/run_fastlane_checks.sh
たとえば build_command_test.rb では、--export-options-plist を使うときに --export-method が混ざらないことをテストしているし、version_info_test.rb では build number の補正ロジックを固定している。
assert_includes args, "--export-options-plist=/tmp/ExportOptions.plist"
refute args.any? { |arg| arg.start_with?("--export-method=") }
assert_equal "10", plan[:requested_build_number]
assert_equal "12", plan[:build_number]
assert_equal true, plan[:adjusted_for_testflight]
iOS の本番 upload は secrets や Apple 側の状態が絡むので、PR のたびに完全再現するのは難しい。だからこそ、本番相当の経路でしか分からないところ と 事前にローカル / CI で固定できるところ を分けるのが大事だった。今回の Ruby テスト追加は、その境界を少し前に寄せる作業だったと思う。
自由コメント
今回はかなり技術寄りの話に寄せてみたぬ。
普段の devlog だと「何を考えたか」「どこにハマったか」を中心に書くことが多いけど、iOS TestFlight 自動化まわりは、コード構成まで踏み込んだほうがたぶん伝わる内容だった。特に、
- workflow は薄く保つ
- lane の中の判断を小さい責務に分ける
- App Store Connect の状態を見て build number を決める
- Manual Signing を明示して CI の再現性を上げる
- 本番 upload まで行かなくても分かる部分は Ruby テストで固定する
あたりは、同じように iOS 配布自動化でハマる人にもそのまま効く考え方だと思ってる。
iOS 配布って、成功しているときは1本の workflow に見えるけど、実際には Apple / Flutter / fastlane / CI の境界条件をたくさんまたいでいる。そのぶん、暗黙挙動に寄せるほど壊れやすい。今回の整理でやれたのは、その暗黙を少しずつコード化して、失敗理由を見える場所に引っ張り出すことだったぬ。
こういう基盤整備は新機能みたいな派手さはないけど、継続してリリースできるようになると、あとから効き方が大きい。rakulist でも、これで「作る」だけじゃなく「届ける」までをだいぶ安定して回せるようになってきた気がしている。