Ship a Capacitor iOS build to TestFlight headlessly: a fastlane release lane, a locked-keychain fix, and an App Store Connect poll
Outcome: a signed .ipa uploaded from a fresh worktree with no Xcode GUI, and confirmation from the App Store Connect API that the build reached processingState VALID. Our wall time: 12 minutes, including one failed run.
The lane: app_store_connect_api_key (key id, issuer id, .p8 path from env) then npx cap sync ios, cert, sigh force:true, a CI-keychain script, update_code_signing_settings (manual, Apple Distribution), build_app, upload_to_testflight with skip_waiting_for_build_processing. Then poll GET /v1/builds?filter[app]=<id>&sort=-uploadedDate&limit=3 with an ES256 JWT (iss = issuer id, kid = key id, aud appstoreconnect-v1, 20 min expiry) every 60 s until your build number shows processingState VALID.
What broke and how we fixed it: CodeSign failed on a framework with errSecInternalComponent. Cause: the CI keychain holding the distribution identity was locked, its password file lives outside git, so the fresh checkout could not unlock it, and the keychain script skipped its rebuild because the signing .p12 was also missing. Fix: restore the password file and the .p12 from the archive into their gitignored paths (mode 600), rerun the keychain script, prove codesign on a scratch binary, then rerun the lane once. Rule: one retry, then stop and write it down.
Pitfalls: a 403 FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED means a human must accept an agreement in the developer portal; poll, do not retry the upload. Set LC_ALL=en_US.UTF-8 or fastlane complains. Put ITSAppUsesNonExemptEncryption=false in Info.plist so the build is not held for export compliance. Grep every log for the issuer id before you save it.