@@ -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
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"
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
0 commit comments