authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 21:35:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 21:35:43-07:00
log19bef8c1d5724953f8866b19e9e8c382bd99315f
treef6872b8e17dbaa12b141f224b28c5c2736ba99cb
parente688034b5f5505f92fcf1d78ba853c9c0107f9f1
signature Signed by PGP key B5690EEEBB952194

ci(mobile): cut a store build when the native fingerprint has no build (#18496)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

1 files changed, 62 insertions(+), 47 deletions(-)

.github/workflows/mobile-eas-production.yml+62-47
......@@ -7,10 +7,11 @@ name: Mobile EAS Production
77# runner, with corepack pinning pnpm 10.24 in eas.json, local == build.
88#
99# Every merge to main that touches the mobile app reconciles, per platform:
10# 1. Store builds: if the latest production build's version differs from
11# app.config.ts, cut a new build and submit it (TestFlight + Play internal
12# track). Bumping `version` is therefore all it takes to
13# start the next release train — the first build of a version enters
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
1415# external-TestFlight beta review immediately, and later builds of the
1516# same version auto-approve until that version is released. After App
1617# Store approval, Apple closes the release train and `version` must be
......@@ -21,10 +22,11 @@ name: Mobile EAS Production
2122# fingerprint. Older binaries of the same major version with a matching
2223# fingerprint receive it too; the major version is part of the
2324# fingerprint (apps/mobile/fingerprint.config.js), so a new major never
24# reaches the previous major's store binaries over the air. When native
25# drift means no binary could install the update,
26# it is skipped and flagged in the job summary instead of published
27# into the void.
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.
28# Hold native-change PRs (the "📱 Native Change" label) and merge them as a
29# batch to avoid a store build per PR.
2830# workflow_dispatch remains as a manual override for both modes (e.g. to
2931# retry an errored build or force an OTA).
3032# Manual v2-preview builds keep the production app identity and disable OTA.
......@@ -263,61 +265,74 @@ jobs:
263265 --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \
264266 --non-interactive
265267
266 # No --status filter on build:list: an in-queue/in-progress build must
267 # count as existing, or every merge during the build window would cut a
268 # duplicate. After an errored build, retry via workflow_dispatch
269 # mode=build — pushes won't re-trigger it until the app version changes.
270 - id: store_builds
271 name: Ensure store builds exist for the current app version
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.
277 - id: reconcile
278 name: Publish OTA or cut store build
272279 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
273 continue-on-error: true
274280 working-directory: apps/mobile
275281 env:
276282 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
277283 run: |
278284 failed=0
279285 version="$(npx expo config --json --type public | jq -r '.version')"
280 for platform in ios android; do
281 latest="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')"
282 if [ "$latest" = "$version" ]; then
283 echo "$platform: production build for $version already exists (or is in progress)"
284 continue
285 fi
286 echo "$platform: latest production build is $latest, app.config.ts says $version — building"
287 if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then
288 echo ":building_construction: $platform: scheduled production build and submission for $version" >> "$GITHUB_STEP_SUMMARY"
289 else
290 failed=1
291 echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY"
292 fi
293 done
294 exit "$failed"
295
296 - name: Publish fingerprint-gated OTA
297 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
298 working-directory: apps/mobile
299 env:
300 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
301 run: |
302286 message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))"
303287 for platform in ios android; do
304288 # eas-cli prints an environment-loaded notice to stdout before the
305289 # JSON even with --json, so discard everything before the document.
306290 hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')"
307 matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')"
308 if [ "$matching" -gt 0 ]; then
309 eas update \
291 matches="$(eas build:list --platform "$platform" --build-profile production --fingerprint-hash "$hash" --limit 20 --json --non-interactive)"
292 finished="$(jq '[.[] | select(.status == "FINISHED")] | length' <<<"$matches")"
293 active="$(jq '[.[] | select(.status == "NEW" or .status == "IN_QUEUE" or .status == "IN_PROGRESS")] | length' <<<"$matches")"
294 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"')"
296
297 if [ "$finished" -gt 0 ]; then
298 if eas update \
310299 --channel production \
311300 --environment production \
312301 --platform "$platform" \
313302 --message "$message" \
314 --non-interactive
315 echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
303 --non-interactive; then
304 echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
305 else
306 failed=1
307 echo ":x: $platform: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
308 fi
309 fi
310
311 reason=""
312 if [ "$finished" -eq 0 ] && [ "$active" -eq 0 ]; then
313 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"
315 failed=1
316 continue
317 fi
318 reason="native fingerprint \`$hash\` has no build"
319 elif [ "$latest_version" != "$version" ] && [ "$active" -eq 0 ]; then
320 reason="latest production build is $latest_version, app.config.ts says $version"
321 fi
322
323 if [ -z "$reason" ]; then
324 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"
326 fi
327 continue
328 fi
329
330 echo "$platform: $reason — building"
331 if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then
332 echo ":building_construction: $platform: scheduled production build and submission for $version ($reason)" >> "$GITHUB_STEP_SUMMARY"
316333 else
317 echo ":warning: $platform: no finished production build matches fingerprint \`$hash\` — OTA skipped; JS changes reach $platform only once a matching build ships" >> "$GITHUB_STEP_SUMMARY"
334 failed=1
335 echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY"
318336 fi
319337 done
320
321 - name: Propagate store build failure
322 if: steps.store_builds.outcome == 'failure'
323 run: exit 1
338 exit "$failed"