今回はかなり技術寄りです: 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.rb
  • signing_config.rb
  • version_info.rb
  • preflight_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 では、

  1. IOS_BUILD_NUMBER があればそれを最優先
  2. なければ pubspec の build number と GITHUB_RUN_NUMBER の大きいほうを候補にする
  3. さらに 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 でも、これで「作る」だけじゃなく「届ける」までをだいぶ安定して回せるようになってきた気がしている。