When a Local iOS Build Passes but CI's TestFlight Upload Rejects the Signature — Field Notes on match Code-Signing Drift
Running fastlane match on CI can produce builds that succeed while only the TestFlight upload fails on signing. These field notes show how to diagnose the certificate/profile drift reproducibly, lock CI to readonly, and add a pre-upload gate that stops bad signatures before they reach Apple.
What confused me most while staring at the CI log was this exact state: build_app ran to completion, the .ipa existed — and yet the very next step, upload_to_testflight, failed on a signing error. Building the same commit locally on my Mac sailed straight through to TestFlight.
Few bugs are more frustrating than the ones that won't reproduce on your own machine. I started by handing Antigravity the CI log, the Fastfile, and the Matchfile, asking it to "list the likely causes in order, separating what's verified at build-signing time from what's verified at distribution time, given that the build succeeds and the .ipa exists but only the upload fails on signing." Splitting the symptom into two stages — build signing vs. distribution-side validation — before touching anything saved me a lot of blind retries.
These notes are what survived that triage. They're for anyone using fastlane match whose signing behaves differently locally than it does on CI.
What's really happening: build and distribution inspect different certificates
The key thing to internalize is that producing an iOS .ipa and uploading it to App Store Connect validate different things at different times.
At build time, Xcode needs the specified provisioning profile to exist and the matching signing certificate (with its private key) to be present in the keychain. Satisfy that, and the .ipa gets built.
At upload time, App Store Connect re-validates that the profile and signature embedded in the .ipa don't conflict with the currently valid certificates, registered devices, and Bundle ID entitlements. A build that succeeds can absolutely be rejected at this second check.
Bring match into it and you add a third source of truth: the certificates stored in a Git repository. Your local keychain, the CI runner's temporary keychain, and the match Git repo — when all three point at the same certificate, you're fine. The moment any one of them drifts, you get the asymmetry where the build succeeds with an old certificate and the upload is rejected against newer entitlements. That asymmetry is the whole of "signing drift."
Here are the common ways it drifts:
Source of drift
Typical trigger
Build
Upload
match certificate regenerated
Someone ran match nuke / match --force locally
Succeeds on the old cert
Rejected as revoked
Certificate expired
Apple Distribution cert passed its one-year mark
Can succeed from cache
Rejected
Profile/certificate mismatch
Added an Extension but didn't re-sync match
Main target succeeds
Rejected for missing Extension signature
CI keychain missing the cert
Fetched with readonly: true but failed to import into the keychain
Fails earlier
Never reached
The first two rows are the interesting ones. The build succeeds but the upload fails precisely because an old or expired certificate lingering in the local or CI keychain is enough to satisfy the build. It isn't that the failure won't reproduce — it's that only the reproducing environment is carrying the stale certificate.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why a build can succeed while the upload fails, and how to identify exactly which certificate and profile were embedded in the .ipa
✦Concrete Fastfile / GitHub Actions settings to run match readonly on CI so certificate regeneration can't invalidate existing builds
✦A pre-upload action that inspects the embedded provisioning profile and certificate expiry, failing fast on any mismatch
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Before arguing about causes, confirm — as a matter of fact, read from the file — which certificate and profile the generated .ipa was signed with. Don't guess; read it.
# Unpack the .ipa and pull out the embedded profileunzip -o MyApp.ipa -d /tmp/ipa_inspect >/dev/nullPROFILE="/tmp/ipa_inspect/Payload/MyApp.app/embedded.mobileprovision"# Decode the profile (a plist)security cms -D -i "$PROFILE" > /tmp/profile.plist# Profile name, UUID, expiry/usr/libexec/PlistBuddy -c "Print :Name" /tmp/profile.plist/usr/libexec/PlistBuddy -c "Print :UUID" /tmp/profile.plist/usr/libexec/PlistBuddy -c "Print :ExpirationDate" /tmp/profile.plist
The signing certificate itself is visible via codesign:
# The certificate actually signing the binary: Common Name and signing timecodesign -dvvv "/tmp/ipa_inspect/Payload/MyApp.app" 2>&1 | grep -E "Authority|Signed Time"
Cross-reference the Common Name you get here (e.g., Apple Distribution: Your Company (XXXXXXXXXX)) against the certificate marked "valid" in App Store Connect. If the certificate in the CI-built .ipa is the one Apple now considers revoked, that's your answer.
I did this by eye at first, but unpacking the profile every time gets old, so I folded it into the pre-upload gate below.
Step 2: Always run match readonly on CI
The single biggest source of signing drift is CI silently regenerating certificates. When match can't find the target certificate, its default is to issue a new one. A CI-issued certificate eats into Apple's certificate quota (two Distribution certs max) and can even revoke the old one — which instantly invalidates every existing build signed with it.
The principle that prevents this is simple: issue and renew certificates locally only; CI is strictly read-only.
# fastlane/Fastfileplatform :ios do desc "Sync certs and profiles (read-only on CI)" lane :sync_signing do match( type: "appstore", app_identifier: [ "com.yourcompany.yourapp", "com.yourcompany.yourapp.widget" ], readonly: is_ci, # never let CI issue anything new keychain_name: ENV["MATCH_KEYCHAIN_NAME"], keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"] ) endend
readonly: is_ci is the most important line in this article. is_ci is fastlane's helper that auto-detects the CI environment — false locally, true on CI. If CI can't find a certificate, you want it to fail honestly rather than mint a new one, because that failure is the correct signal that says "go renew match locally."
When you do renew, run it explicitly on your machine:
# Local only: issue new certs/profiles and store them in the match repobundle exec fastlane match appstore# Don't forget the Extensions — list every Bundle ID
Step 3: Authenticate with an API key and keep Apple ID passwords off CI
Another drift source is how CI authenticates with your Apple account. Apple ID + password (plus 2FA) breaks reproducibility because the session expires and behavior changes. On CI, standardize on an App Store Connect API key and a clear chunk of signing uncertainty disappears.
# fastlane/Fastfileplatform :ios do before_all do # Load the key once and share it across every lane app_store_connect_api_key( key_id: ENV["ASC_KEY_ID"], issuer_id: ENV["ASC_ISSUER_ID"], key_content: ENV["ASC_KEY_CONTENT"], # the raw .p8 contents in an env var is_key_content_base64: false, in_house: false ) endend
Loading the key in before_all means match, upload_to_testflight, and latest_testflight_build_number all reuse the same credentials. That avoids the failure mode where only the upload step authenticates differently and gets rejected.
Lock down access to the match Git repo too — on CI, use a deploy key or a short-lived read-only token. Not giving CI write access is the same as guaranteeing CI can't rewrite your certificates.
Step 4: A pre-upload gate that inspects the signature
Everything above is prevention, but drift still happens. As a last line of defense, insert a check between build and upload that refuses to proceed if the signature doesn't meet requirements. I asked Antigravity to "write a script that reads the .ipa's embedded profile and signing certificate expiry, and exits 1 on an expired profile or a Bundle ID mismatch," then dropped the result into a fastlane custom action.
# 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!("App bundle not found") unless app profile = "#{app}/embedded.mobileprovision" plist = sh("security cms -D -i #{profile.shellescape}") # Expiry check 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!("Profile expires within 7 days: #{exp}. Renew match locally.") end # Bundle ID check (strip the team prefix from application-identifier) 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!("Signed Bundle ID (#{bundle}) does not match expected #{expected_ids}") end UI.success("Signing verified: #{bundle} / expires #{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
Wire it into the release lane:
lane :release do sync_signing build_app(scheme: "MyApp", export_method: "app-store") # The checkpoint: stopping here avoids dragging existing builds down with it 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
The value of this checkpoint is moving the failure ahead of the upload. Hunting for a cause after App Store Connect rejects you is a very different experience from a local step telling you plainly that "the profile is about to expire." Since adding it, I no longer get blindsided by the "for some reason today's upload won't go through" scramble.
Step 5: Recreate the keychain on every GitHub Actions run
If your CI runner is a reused machine, the previous job's certificate can linger in the keychain and get picked up as the stale cert for the build. Even on a clean runner, letting match import into the default keychain leaks state between jobs. The fix is to create a dedicated temporary keychain per job and destroy it on exit.
The points that matter: set-keychain-settings -lut 21600 extends the lock timeout, and the final if: always()deletes the keychain even when the job fails. Just not carrying certificate residue into the next job clears up most of the nondeterministic "passed last time, fails on signing this time" failures.
The fastest order to check when you suspect drift
Here's the order I walk every time I suspect drift. Working top to bottom usually lands me on the cause within minutes.
#
Check
Command / where to look
1
Embedded certificate expiry
codesign -dvvv App.app — Authority and signing time
2
Is the cert valid on Apple's side
App Store Connect → Certificates list
3
Generation of the cert in the match repo
Last commit timestamp in the cert repository
4
Is CI readonly
readonly: is_ci in the Fastfile
5
Missing Extension profile
Are all Bundle IDs present in the embedded profile
In my experience, most causes land on the combination of 1 and 3 — "someone regenerated the cert locally, but CI's cache is still old."
What this design changes
Try to eradicate signing drift and you'll sink into a swamp. As long as humans renew certificates, drift will occasionally happen. So the goal of these notes isn't to drive it to zero — it's to make sure that when it does drift, it stops quietly before the upload, without taking existing builds down with it.
Pin CI to readonly, move authentication to an API key, and verify the signature before upload. With those three in place, a failure stops being a "release-day incident" and becomes "routine work: just renew match locally." Over a long-lived project, the reproducibility of release work ends up mattering more than the release work itself.
If you try one thing, start with the verification action from Step 4. A single checkpoint in front of the upload takes a surprising amount of the anxiety out of signing.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.