CI のログを眺めていて、いちばん混乱したのがこの状態でした。build_app は最後まで通り、.ipa も生成されている。それなのに直後の upload_to_testflight だけが署名関連のエラーで落ちる。ローカルの Mac で同じコミットをビルドすると何事もなく TestFlight まで届く。
手元で再現しないバグほど厄介なものはありません。個人開発でアプリのリリースを自分一人で回していると、署名の更新を後回しにしがちで、そのツケが CI 側にだけ出ることがあります。私はこのとき、Antigravity に CI のログと Fastfile、Matchfile を渡して「ビルドは成功して .ipa があるのにアップロードだけ署名で落ちる。考えられる原因を、ビルド時の署名とアップロード時の検証の差から順に挙げてほしい」と尋ねるところから始めました。手を動かす前に、症状を「ビルド署名」と「配信側の検証」という二つの段に切り分けてくれたことで、闇雲にやり直す時間をかなり節約できました。
この記事は、その切り分けの結果として残った運用メモです。fastlane match を使っていて、ローカルと CI で署名の挙動が食い違う方を想定しています。
症状の正体:ビルドと配信は別々の証明書を見ている
まず押さえておきたいのは、iOS の .ipa 生成と App Store Connect への配信が、別々のタイミングで別々のものを検証しているという点です。
# 実際にバイナリを署名している証明書の Common Name と有効期限codesign -dvvv "/tmp/ipa_inspect/Payload/MyApp.app" 2>&1 | grep -E "Authority|Signed Time"
ここで出てくる証明書の Common Name(例:Apple Distribution: Your Company (XXXXXXXXXX))と、App Store Connect 上で「有効」になっている証明書を突き合わせます。CI で生成した .ipa の証明書だけが、Apple 側で失効扱いになっていれば、それが答えです。
# fastlane/Fastfileplatform :ios do desc "証明書とプロファイルを同期(CIは読み取り専用)" lane :sync_signing do match( type: "appstore", app_identifier: [ "com.yourcompany.yourapp", "com.yourcompany.yourapp.widget" ], readonly: is_ci, # CI では絶対に新規発行させない keychain_name: ENV["MATCH_KEYCHAIN_NAME"], keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"] ) endend
readonly: is_ci がこの記事でいちばん重要な一行です。is_ci は fastlane が CI 環境を自動判定するヘルパーで、ローカルでは false、CI では true になります。CI で証明書が見つからなければ、新規発行ではなく素直に失敗してほしい——その失敗こそが「ローカルで match を更新し直せ」という正しいシグナルだからです。
証明書を更新するときは、ローカルで明示的に実行します。
# ローカルでのみ:新しい証明書/プロファイルを発行して match リポジトリに保存bundle exec fastlane match appstore# Extension も忘れずに同期(Bundle ID をすべて列挙)
Step 3:認証は API キーに寄せ、Apple ID パスワードを CI から外す
もう一つのドリフト源が、CI 上の Apple アカウント認証です。Apple ID とパスワード(および 2FA)で認証する方式は、セッションが切れるたびに挙動が変わり、再現性を損ないます。CI では App Store Connect API キーに統一することを推奨します。署名まわりの不確定要素がはっきり減ります。
# fastlane/Fastfileplatform :ios do before_all do # API キーを一度だけロードして以降の全 lane で共有 app_store_connect_api_key( key_id: ENV["ASC_KEY_ID"], issuer_id: ENV["ASC_ISSUER_ID"], key_content: ENV["ASC_KEY_CONTENT"], # .p8 の中身を base64 ではなく素のまま環境変数へ is_key_content_base64: false, in_house: false ) endend
API キーを before_all で読み込んでおくと、match・upload_to_testflight・latest_testflight_build_number などが同じ認証情報を再利用します。アップロード段だけ別認証になって落ちる、という事故も避けられます。
match の Git リポジトリ自体へのアクセスも、CI ではデプロイキーか短命トークンで read-only に絞ります。書き込み権限を CI に渡さないことが、そのまま「CI が証明書を書き換えられない」保証になります。
# fastlane/actions/verify_signing_action.rbmodule Fastlane module Actions class VerifySigningAction < Action def self.run(params) ipa = params[:ipa_path] expected_ids = params[:app_identifiers] Dir.mktmpdir do |dir| sh("unzip -o #{ipa.shellescape} -d #{dir} >/dev/null") app = Dir.glob("#{dir}/Payload/*.app").first UI.user_error!("アプリ本体が見つかりません") unless app profile = "#{app}/embedded.mobileprovision" plist = sh("security cms -D -i #{profile.shellescape}") # 有効期限のチェック require "time" exp = plist[/<key>ExpirationDate<\/key>\s*<date>(.*?)<\/date>/m, 1] if exp && Time.parse(exp) < Time.now + 7 * 24 * 3600 UI.user_error!("プロファイルの期限が7日以内に切れます: #{exp}。ローカルで match を更新してください") end # Bundle ID のチェック(application-identifier から team prefix を除去して比較) app_id = plist[/<key>application-identifier<\/key>\s*<string>(.*?)<\/string>/m, 1] bundle = app_id.to_s.sub(/^[A-Z0-9]+\./, "") unless expected_ids.include?(bundle) UI.user_error!("署名された Bundle ID (#{bundle}) が期待値 #{expected_ids} と一致しません") end UI.success("署名検証 OK: #{bundle} / 期限 #{exp}") end end def self.available_options [ FastlaneCore::ConfigItem.new(key: :ipa_path, type: String), FastlaneCore::ConfigItem.new(key: :app_identifiers, type: Array) ] end def self.is_supported?(platform) platform == :ios end end endend
これを release レーンに差し込みます。
lane :release do sync_signing build_app(scheme: "MyApp", export_method: "app-store") # ★ アップロード前の関所:ここで止まれば既存ビルドを巻き込まずに済む verify_signing( ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH], app_identifiers: ["com.yourcompany.yourapp", "com.yourcompany.yourapp.widget"] ) upload_to_testflight(skip_waiting_for_build_processing: true)end
この関所の効用は、失敗をアップロードの手前に前倒しすることにあります。App Store Connect に弾かれてから原因を探るのと、ローカルの検証ステップで「期限が近い」と明示されるのとでは、対応のしやすさがまるで違います。私はこの一段を入れてから、「なぜか今日だけアップロードできない」という当日の慌ただしさがなくなりました。
Step 5:GitHub Actions 側でキーチェーンを毎回作り直す
CI ランナーが使い回しのマシンだと、前回ジョブの証明書がキーチェーンに残り、それが古い証明書としてビルドに使われることがあります。ランナーがクリーンでも、match が import 先に既定キーチェーンを使うと、ジョブ間で状態が漏れます。対策は、ジョブごとに専用の一時キーチェーンを作り、終了時に必ず破棄することです。
CI を readonly に固定し、認証を API キーに寄せ、アップロード前に署名を検査する。この三つを入れておけば、失敗は「リリース当日の事故」ではなく「ローカルで match を更新するだけの定例作業」に格下げできます。リリース作業そのものより、その作業が再現可能であることのほうが、長く運用するうえでは効いてきます。