docs/plans/2026-07-14-ios-testflight-master-builds.md
masterStatus: Plan only · Date: 2026-07-14 Difficulty: Moderate. Allow about one engineering day split across two small changes, plus Apple processing and tester verification time.
Use the existing iOS signing/archive workflow to send the newest eligible master
state to an internal TestFlight group. Rapid pushes are coalesced: one beta may run
and only the newest superseding beta waits. This intentionally does not produce one
TestFlight build for every intermediate SHA.
Production behavior stays event-based: final v* tag pushes submit the existing iOS
release for review, prerelease tag pushes remain upload-only, and manual dispatch can
never submit to production. Master builds are marked TestFlight Internal Only, so
Apple cannot offer them to external testers or customers.
Out of scope: external TestFlight, app changes, new dependencies, and the existing iOS/macOS App Store review-submission race. There is no new app-wide upload queue; the internal-only beta path neither edits App Store metadata nor opens a review.
| Event | Export | Fastlane lane | Production submit |
|---|---|---|---|
Push to master | TestFlight Internal Only | ios beta | Never |
Push of final v* tag | App Store Connect | ios release | Yes |
Push of prerelease v* tag | App Store Connect | ios release | No |
Manual dispatch, default beta mode | TestFlight Internal Only | ios beta | Never |
Manual dispatch, release-upload-only | App Store Connect | ios release | Never |
The production guard must include the event type, not only the ref:
SUBMIT_FOR_REVIEW: >-
${{ github.event_name == 'push'
&& startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref, '-') }}
Manual beta dispatch is allowed only from master. The explicit
release-upload-only mode preserves the workflow's current manual upload capability
without inheriting its final-tag submission footgun.
ASC_* secrets in an internal-testflight GitHub
environment restricted to master, with no reviewer gate. A team key cannot be
app-scoped; the separate lower-role key reduces privilege and credential reuse but
still has team-wide upload access.xcodebuild -help supports the
testFlightInternalTestingOnly export option and that the current provisioning
profile can export that distribution type.agvtool, the archived IPA, and
Apple before enabling unattended builds.Do not add the master push trigger yet.
Add ios beta to fastlane/Fastfile. It requires IPA_PATH and the existing
ASC_* contract, calls upload_to_testflight, and sets these options explicitly:
skip_submission: true
skip_waiting_for_build_processing: false
distribute_external: false
submit_beta_review: false
wait_processing_timeout_duration: 1800
Do not pass tester groups, changelog, or notification options. App Store Connect's automatic group owns internal distribution. A processing timeout means “upload succeeded, processing unknown”; inspect App Store Connect before retrying. Never re-upload the same IPA blindly.
Refactor .github/workflows/build-ios.yml into one build job and conditional beta
and release upload jobs:
workflow_dispatch.mode with beta as the safe default and
release-upload-only as the other choice.permissions: contents: read.testFlightInternalTestingOnly: true; tag and manual release
uploads keep the current App Store Connect export.package.json marketing version on release routes.CFBundleShortVersionString to
incrementPatch(max(stripPrerelease(package.version), highest stable vX.Y.Z tag)).
Fetch tags and implement the strict three-integer comparison without a dependency.
This future train must be greater than the latest approved iOS version, including
immediately after a release.CFBundleVersion exactly to
$(date -u +%Y%m%d%H%M).${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT} on every route.
This remains above the old timestamp values and separates same-minute runs and
reruns.VERSIONING_SYSTEM = apple-generic must be added for reliable agvtool
behavior.if-no-files-found: error, a clean download directory, and one-day
retention for beta artifacts. The upload jobs must fail unless exactly one regular
.ipa exists.internal-testflight; keep release credentials on
the existing release path. Never enable verbose fastlane output.Add workflow-level concurrency for the complete beta build/upload lifecycle: beta
runs share a fixed group with cancel-in-progress: false and queue: single, keeping
the running run and only the newest pending run. Tag and manual release runs use a
run/attempt-specific group, so a master push cannot coalesce them.
ruby -c fastlane/Fastfilebundle exec fastlane lanes on the macOS runnernpx prettier --check .github/workflows/build-ios.ymlgit diff --checkmaster push trigger is absent.Merge Increment A, then manually dispatch beta from master once. Do not proceed
until all of the following are true:
Record only the Actions URL, non-sensitive version/build pair, duration, and result.
master and document operationsAdd the branch trigger while retaining the existing tag trigger:
push:
branches: [master]
tags: ['v*']
Update docs/build-and-publish-notes.md, docs/apple-release-automation.md, and
.github/SECURITY-SETUP.md with the routing table, internal-only boundary,
credentials, version/build formulas, coalescing, timeout recovery, and rollback.
After merge, confirm one normal master push uploads successfully. Start three beta
dispatches close together and verify the active run completes, the middle pending
run is superseded, and the newest pending run proceeds. A failed Apple
upload/processing result must leave Actions red.
The next tagged release is a follow-up observation, not an activation blocker:
Each beta that survives coalescing consumes roughly 10–15 macOS runner minutes plus
one signed IPA upload. Track accepted beta runs × duration and artifact storage for
the first week. Keep one-day beta artifact retention; consider a proven-safe
docs-only path filter later only if cost/noise is material.
Rollback is to remove the master branch trigger while retaining manual beta mode.
If a distributed build must stop, use App Store Connect Expire Build; disabling
automatic group distribution affects future assignment but does not retract an
installed build. Keep the new version/build scheme after Apple has accepted it.
| Risk | Mitigation |
|---|---|
| Beta reaches external testing or production | Internal-only export plus fail-closed beta lane |
| Post-release betas are rejected as an old version | Future patch train based on package and stable tags; mandatory post-release check |
| Rapid pushes create cost and TestFlight noise | Workflow-wide beta coalescing and one-day artifacts |
| Manual/tag routing submits the wrong build | Explicit modes, event-type production guard, routing-table review |
| Production credentials gain exposure | Dedicated Developer beta key; release key stays on release job; protected master/CODEOWNERS |
| Signing identity gains exposure on every master build | Install only at export, restrict workflow changes, accept and document certificate reuse |
| Apple processing times out after upload | Inspect App Store Connect first; rebuild with a new number only when retry is required |