| ... | @@ -7,10 +7,11 @@ name: Mobile EAS Production | ... | @@ -7,10 +7,11 @@ name: Mobile EAS Production |
| 7 | # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. | 7 | # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. |
| 8 | # | 8 | # |
| 9 | # Every merge to main that touches the mobile app reconciles, per platform: | 9 | # 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 | 10 | # 1. Store builds: when no production build matches the current native |
| 11 | # app.config.ts, cut a new build and submit it (TestFlight + Play internal | 11 | # fingerprint, or the latest build's version differs from app.config.ts, |
| 12 | # track). Bumping `version` is therefore all it takes to | 12 | # cut a new build and submit it (TestFlight + Play internal track). Native |
| 13 | # start the next release train — the first build of a version enters | 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 |
| 14 | # external-TestFlight beta review immediately, and later builds of the | 15 | # external-TestFlight beta review immediately, and later builds of the |
| 15 | # same version auto-approve until that version is released. After App | 16 | # same version auto-approve until that version is released. After App |
| 16 | # Store approval, Apple closes the release train and `version` must be | 17 | # Store approval, Apple closes the release train and `version` must be |
| ... | @@ -21,10 +22,11 @@ name: Mobile EAS Production | ... | @@ -21,10 +22,11 @@ name: Mobile EAS Production |
| 21 | # fingerprint. Older binaries of the same major version with a matching | 22 | # fingerprint. Older binaries of the same major version with a matching |
| 22 | # fingerprint receive it too; the major version is part of the | 23 | # fingerprint receive it too; the major version is part of the |
| 23 | # fingerprint (apps/mobile/fingerprint.config.js), so a new major never | 24 | # 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 | # reaches the previous major's store binaries over the air. Until a |
| 25 | # drift means no binary could install the update, | 26 | # build for the new fingerprint finishes, the OTA is skipped rather than |
| 26 | # it is skipped and flagged in the job summary instead of published | 27 | # published into the void. |
| 27 | # 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. |
| 28 | # workflow_dispatch remains as a manual override for both modes (e.g. to | 30 | # workflow_dispatch remains as a manual override for both modes (e.g. to |
| 29 | # retry an errored build or force an OTA). | 31 | # retry an errored build or force an OTA). |
| 30 | # Manual v2-preview builds keep the production app identity and disable OTA. | 32 | # Manual v2-preview builds keep the production app identity and disable OTA. |
| ... | @@ -263,61 +265,74 @@ jobs: | ... | @@ -263,61 +265,74 @@ jobs: |
| 263 | --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ | 265 | --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ |
| 264 | --non-interactive | 266 | --non-interactive |
| 265 | | 267 | |
| 266 | # No --status filter on build:list: an in-queue/in-progress build must | 268 | # One pass per platform. The fingerprint decides OTA vs store build: |
| 267 | # count as existing, or every merge during the build window would cut a | 269 | # - a finished build matches → publish the OTA; |
| 268 | # duplicate. After an errored build, retry via workflow_dispatch | 270 | # - a queued/in-progress build matches → wait, the next merge after it |
| 269 | # mode=build — pushes won't re-trigger it until the app version changes. | 271 | # finishes publishes the OTA (no duplicate build per merge); |
| 270 | - id: store_builds | 272 | # - no build matches → native drift, so cut and submit a new build. |
| 271 | name: Ensure store builds exist for the current app version | 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 |
| 272 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' | 279 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' |
| 273 | continue-on-error: true | | |
| 274 | working-directory: apps/mobile | 280 | working-directory: apps/mobile |
| 275 | env: | 281 | env: |
| 276 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | 282 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 277 | run: | | 283 | run: | |
| 278 | failed=0 | 284 | failed=0 |
| 279 | version="$(npx expo config --json --type public | jq -r '.version')" | 285 | 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: | | | |
| 302 | message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" | 286 | message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" |
| 303 | for platform in ios android; do | 287 | for platform in ios android; do |
| 304 | # eas-cli prints an environment-loaded notice to stdout before the | 288 | # eas-cli prints an environment-loaded notice to stdout before the |
| 305 | # JSON even with --json, so discard everything before the document. | 289 | # JSON even with --json, so discard everything before the document. |
| 306 | hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" | 290 | 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')" | 291 | matches="$(eas build:list --platform "$platform" --build-profile production --fingerprint-hash "$hash" --limit 20 --json --non-interactive)" |
| 308 | if [ "$matching" -gt 0 ]; then | 292 | finished="$(jq '[.[] | select(.status == "FINISHED")] | length' <<<"$matches")" |
| 309 | eas update \ | 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 \ |
| 310 | --channel production \ | 299 | --channel production \ |
| 311 | --environment production \ | 300 | --environment production \ |
| 312 | --platform "$platform" \ | 301 | --platform "$platform" \ |
| 313 | --message "$message" \ | 302 | --message "$message" \ |
| 314 | --non-interactive | 303 | --non-interactive; then |
| 315 | echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" | 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" |
| 316 | else | 333 | 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" |
| 318 | fi | 336 | fi |
| 319 | done | 337 | done |
| 320 | | 338 | exit "$failed" |
| 321 | - name: Propagate store build failure | | |
| 322 | if: steps.store_builds.outcome == 'failure' | | |
| 323 | run: exit 1 | | |