Skip to content

Commit 2f0a4eb

Browse files
fix(mobile): separate TestFlight and store release versions (#20060)
1 parent e733a40 commit 2f0a4eb

5 files changed

Lines changed: 94 additions & 46 deletions

File tree

‎.github/workflows/mobile-eas-production.yml‎

Lines changed: 58 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,11 @@ name: Mobile EAS Production
66
# fingerprint (platform-specific deps + pnpm version) and errors. On this Linux
77
# runner, with corepack pinning pnpm 10.24 in eas.json, local == build.
88
#
9-
# Every merge to main that touches the mobile app reconciles, per platform:
10-
# 1. Store builds: when no production build matches the current native
11-
# fingerprint, or the latest build's version differs from app.config.ts,
12-
# cut a new build and submit it (TestFlight + Play internal track). Native
13-
# changes therefore ship a binary on merge, and bumping `version` is all
14-
# it takes to start the next release train — the first build of a version enters
15-
# external-TestFlight beta review immediately, and later builds of the
16-
# same version auto-approve until that version is released. After App
17-
# Store approval, Apple closes the release train and `version` must be
18-
# bumped before another iOS build can be submitted. Releasing to the App
19-
# Store stays a manual App Store Connect step.
20-
# 2. OTA: publish a production-channel update for each platform where at
21-
# least one finished production build matches the current native
22-
# fingerprint. Older binaries of the same major version with a matching
23-
# fingerprint receive it too; the major version is part of the
24-
# fingerprint (apps/mobile/fingerprint.config.js), so a new major never
25-
# reaches the previous major's store binaries over the air. Until a
26-
# build for the new fingerprint finishes, the OTA is skipped rather than
27-
# published into the void.
9+
# Every merge reconciles native fingerprints and publishes matching OTAs.
10+
# iOS prereleases use the testflight profile (3.0.0); App Store binaries use
11+
# production and the committed app version, built manually. Both receive OTAs
12+
# for their own major version. Android continues using production automatically.
13+
# TestFlight beta review may still be required for subsequent builds.
2814
# Hold native-change PRs (the "📱 Native Change" label) and merge them as a
2915
# batch to avoid a store build per PR.
3016
# workflow_dispatch remains as a manual override for both modes (e.g. to
@@ -44,12 +30,13 @@ on:
4430
- build
4531
- update
4632
profile:
47-
description: "Store build profile (v2-preview: TestFlight + Play closed track v2; build only)"
33+
description: "production: store release; testflight: iOS 3.0.0 beta; v2-preview: legacy closed testing"
4834
required: true
4935
type: choice
5036
default: production
5137
options:
5238
- production
39+
- testflight
5340
- v2-preview
5441
platform:
5542
description: "Target platform"
@@ -61,7 +48,7 @@ on:
6148
- android
6249
- all
6350
version:
64-
description: "Optional build version override (blank uses app.config.ts; an override is committed before building)"
51+
description: "Optional build version override (blank uses profile default; store overrides are committed)"
6552
required: false
6653
type: string
6754
message:
@@ -105,6 +92,17 @@ jobs:
10592
echo "::error::V2 previews use store builds only. Select mode=build."
10693
exit 1
10794
95+
- name: Validate TestFlight inputs
96+
if: github.event_name == 'workflow_dispatch' && inputs.profile == 'testflight'
97+
env:
98+
PLATFORM: ${{ inputs.platform }}
99+
VERSION: ${{ inputs.version }}
100+
run: |
101+
if [ "$PLATFORM" != "ios" ] || [ -n "$VERSION" ]; then
102+
echo "::error::TestFlight uses platform=ios and its fixed version. Use production for a committed store version."
103+
exit 1
104+
fi
105+
108106
- id: expo-token
109107
name: Check for EXPO_TOKEN
110108
env:
@@ -119,7 +117,7 @@ jobs:
119117
120118
- id: version_app_token
121119
name: Mint release app token for version override
122-
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != ''
120+
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' && inputs.profile != 'testflight'
123121
uses: actions/create-github-app-token@v2
124122
with:
125123
app-id: ${{ secrets.RELEASE_APP_ID }}
@@ -204,10 +202,10 @@ jobs:
204202
const path = "apps/mobile/app.config.ts";
205203
const source = fs.readFileSync(path, "utf8");
206204
const next = source.replace(
207-
/^( version: ")[^"]+(".*)$/m,
205+
/^( version: repoEnv\.T3CODE_MOBILE_VERSION \?\? ")[^"]+(".*)$/m,
208206
`$1${process.env.RELEASE_VERSION}$2`,
209207
);
210-
if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) {
208+
if (next === source && !source.includes(` version: repoEnv.T3CODE_MOBILE_VERSION ?? "${process.env.RELEASE_VERSION}"`)) {
211209
throw new Error("Could not update app version");
212210
}
213211
fs.writeFileSync(path, next);
@@ -234,6 +232,10 @@ jobs:
234232
env:
235233
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
236234
run: |
235+
if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then
236+
T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')"
237+
export T3CODE_MOBILE_VERSION
238+
fi
237239
version="$(npx expo config --json --type public | jq -r '.version')"
238240
{
239241
echo "## Manual $MOBILE_BUILD_PROFILE build"
@@ -257,23 +259,23 @@ jobs:
257259
working-directory: apps/mobile
258260
env:
259261
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
262+
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
263+
OTA_MESSAGE: ${{ inputs.message || format('Production OTA ({0})', github.sha) }}
260264
run: |
265+
if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then
266+
T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')"
267+
export T3CODE_MOBILE_VERSION
268+
fi
261269
eas update \
262270
--channel production \
263271
--environment production \
264272
--platform ${{ inputs.platform }} \
265-
--message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \
273+
--message "$OTA_MESSAGE" \
266274
--non-interactive
267275
268-
# One pass per platform. The fingerprint decides OTA vs store build:
269-
# - a finished build matches → publish the OTA;
270-
# - a queued/in-progress build matches → wait, the next merge after it
271-
# finishes publishes the OTA (no duplicate build per merge);
272-
# - no build matches → native drift, so cut and submit a new build.
273-
# A version bump also cuts a build even when native code is unchanged,
274-
# because only the major version is part of the fingerprint.
275-
# An errored build for this fingerprint is not retried on push (it would
276-
# rebuild on every merge); retry via workflow_dispatch mode=build.
276+
# Reconcile each version separately because the major is in the fingerprint.
277+
# Production iOS only receives OTAs here; store binaries are manual releases.
278+
# Failed prerelease builds are retried manually, not on every merge.
277279
- id: reconcile
278280
name: Publish OTA or cut store build
279281
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
@@ -282,17 +284,24 @@ jobs:
282284
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
283285
run: |
284286
failed=0
285-
version="$(npx expo config --json --type public | jq -r '.version')"
286287
message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))"
287-
for platform in ios android; do
288+
for target in ios:production ios:testflight android:production; do
289+
platform="${target%%:*}"
290+
profile="${target#*:}"
291+
unset T3CODE_MOBILE_VERSION
292+
if [ "$profile" = "testflight" ]; then
293+
T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')"
294+
export T3CODE_MOBILE_VERSION
295+
fi
296+
version="$(npx expo config --json --type public | jq -r '.version')"
288297
# eas-cli prints an environment-loaded notice to stdout before the
289298
# JSON even with --json, so discard everything before the document.
290299
hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')"
291-
matches="$(eas build:list --platform "$platform" --build-profile production --fingerprint-hash "$hash" --limit 20 --json --non-interactive)"
300+
matches="$(eas build:list --platform "$platform" --build-profile "$profile" --fingerprint-hash "$hash" --limit 20 --json --non-interactive)"
292301
finished="$(jq '[.[] | select(.status == "FINISHED")] | length' <<<"$matches")"
293302
active="$(jq '[.[] | select(.status == "NEW" or .status == "IN_QUEUE" or .status == "IN_PROGRESS")] | length' <<<"$matches")"
294303
errored="$(jq '[.[] | select(.status == "ERRORED")] | length' <<<"$matches")"
295-
latest_version="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')"
304+
latest_version="$(eas build:list --platform "$platform" --build-profile "$profile" --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')"
296305
297306
if [ "$finished" -gt 0 ]; then
298307
if eas update \
@@ -301,34 +310,39 @@ jobs:
301310
--platform "$platform" \
302311
--message "$message" \
303312
--non-interactive; then
304-
echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
313+
echo ":white_check_mark: $platform/$profile: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
305314
else
306315
failed=1
307-
echo ":x: $platform: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
316+
echo ":x: $platform/$profile: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
308317
fi
309318
fi
310319
320+
# Never rebuild the released iOS version automatically.
321+
if [ "$target" = "ios:production" ]; then
322+
continue
323+
fi
324+
311325
reason=""
312326
if [ "$finished" -eq 0 ] && [ "$active" -eq 0 ]; then
313327
if [ "$errored" -gt 0 ]; then
314-
echo ":x: $platform: the production build for fingerprint \`$hash\` errored — retry with workflow_dispatch mode=build" >> "$GITHUB_STEP_SUMMARY"
328+
echo ":x: $platform/$profile: the build for fingerprint \`$hash\` errored — retry with workflow_dispatch mode=build profile=$profile platform=$platform" >> "$GITHUB_STEP_SUMMARY"
315329
failed=1
316330
continue
317331
fi
318332
reason="native fingerprint \`$hash\` has no build"
319333
elif [ "$latest_version" != "$version" ] && [ "$active" -eq 0 ]; then
320-
reason="latest production build is $latest_version, app.config.ts says $version"
334+
reason="latest $profile build is $latest_version, expected $version"
321335
fi
322336
323337
if [ -z "$reason" ]; then
324338
if [ "$finished" -eq 0 ]; then
325-
echo ":hourglass: $platform: production build for fingerprint \`$hash\` is in progress — OTA resumes on the next merge after it finishes" >> "$GITHUB_STEP_SUMMARY"
339+
echo ":hourglass: $platform/$profile: build for fingerprint \`$hash\` is in progress — OTA resumes on the next merge after it finishes" >> "$GITHUB_STEP_SUMMARY"
326340
fi
327341
continue
328342
fi
329343
330344
echo "$platform: $reason — building"
331-
if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then
345+
if eas build --platform "$platform" --profile "$profile" --auto-submit --non-interactive --no-wait; then
332346
echo ":building_construction: $platform: scheduled production build and submission for $version ($reason)" >> "$GITHUB_STEP_SUMMARY"
333347
else
334348
failed=1

‎apps/mobile/app.config.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ const config: ExpoConfig = {
229229
slug: "t3-code",
230230
platforms: ["ios", "android"],
231231
scheme: variant.scheme,
232-
version: "2.0.0",
232+
version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.0.0",
233233
runtimeVersion: {
234234
// Development manifests resolve on every launch, so avoid fingerprint's
235235
// expensive native-project calculation there. Preview and production stay

‎apps/mobile/eas.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@
4949
"image": "macos-tahoe-26.6-xcode-27.1"
5050
}
5151
},
52+
"testflight": {
53+
"extends": "production",
54+
"env": {
55+
"T3CODE_MOBILE_VERSION": "3.0.0"
56+
}
57+
},
5258
"v2-preview": {
5359
"extends": "production",
5460
"distribution": "store",
@@ -72,6 +78,9 @@
7278
}
7379
},
7480
"submit": {
81+
"testflight": {
82+
"extends": "production"
83+
},
7584
"v2-preview": {
7685
"extends": "production",
7786
"android": {

‎apps/mobile/fingerprint.config.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ const path = require("node:path");
88
// version keeps each major's OTAs on its own binaries: a new major reaches
99
// users only once its store build is promoted.
1010
const appConfig = fs.readFileSync(path.join(__dirname, "app.config.ts"), "utf8");
11-
const majorVersion = appConfig.match(/^ {2}version: "(\d+)\./m)?.[1];
11+
const version =
12+
process.env.T3CODE_MOBILE_VERSION ??
13+
appConfig.match(/^ {2}version: repoEnv\.T3CODE_MOBILE_VERSION \?\? "([^"]+)"/m)?.[1];
14+
const majorVersion = version?.match(/^(\d+)\./)?.[1];
1215
if (!majorVersion) {
1316
throw new Error("fingerprint.config.js could not read the app version from app.config.ts");
1417
}

‎docs/operations/release.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,28 @@ and `VERCEL_ORG_ID` secrets. It also respects the optional `VERCEL_TEAM_SLUG`
296296
variable. The Vercel project's root directory must be `apps/marketing`.
297297
Git deployments remain disabled in `apps/marketing/vercel.ts`.
298298

299+
## Mobile TestFlight and store releases
300+
301+
Merges to main publish compatible OTAs and build iOS prereleases with the `testflight`
302+
profile at version `3.0.0`. Build numbers increment automatically. The committed app
303+
version remains the App Store release version; Android keeps using that version.
304+
305+
To release iOS, dispatch **Mobile EAS Production** with `mode=build`,
306+
`profile=production`, `platform=ios`, and the intended version, such as `2.0.1`.
307+
The workflow commits and pushes the version before building and uploading it.
308+
Select that build in App Store Connect for App Store review.
309+
310+
To force a TestFlight rebuild, select `profile=testflight`, `platform=ios`, and
311+
leave the version blank. Submit the first `3.0.0` build for external beta review
312+
in App Store Connect. Later builds may still require review; uploads alone do
313+
not guarantee external approval. TestFlight builds expire after 90 days.
314+
315+
Both release and TestFlight binaries receive compatible production-channel OTAs,
316+
separated by their major-version fingerprints. Manual `mode=update` uses the
317+
selected profile's version. Before releasing `3.0.0` to the App Store, advance
318+
`build.testflight.env.T3CODE_MOBILE_VERSION` in `apps/mobile/eas.json` to the next
319+
unreleased major and get its first beta build approved.
320+
299321
## Android APK mirror
300322

301323
`.github/workflows/mobile-android-apk.yml` checks Google Play hourly and publishes the newest

0 commit comments

Comments
 (0)