diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 2e61de6039e..bc335ef99cc 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -5,6 +5,23 @@ name: Mobile EAS Production # in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. +# +# Every merge to main that touches the mobile app reconciles, per platform: +# 1. Store builds: if the latest production build's version differs from +# app.config.ts, cut a new build with --auto-submit (TestFlight + +# Play internal track). Bumping `version` is therefore all it takes to +# start the next release train — the first build of a version enters +# external-TestFlight beta review immediately, and later builds of the +# same version auto-approve. Releasing to the App Store stays a manual +# App Store Connect step. +# 2. OTA: publish a production-channel update for each platform where at +# least one finished production build matches the current native +# fingerprint. Old-version binaries with a matching fingerprint receive +# it too. When native drift means no binary could install the update, +# it is skipped and flagged in the job summary instead of published +# into the void. +# workflow_dispatch remains as a manual override for both modes (e.g. to +# retry an errored build or force an OTA). on: workflow_dispatch: inputs: @@ -29,10 +46,30 @@ on: description: "OTA update message (mode=update only)" required: false type: string + push: + branches: [main] + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - scripts/** + - patches/** + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-eas-production.yml + +# Serialize runs so OTAs publish in merge order. GitHub keeps at most one +# queued run per group, so a burst of merges collapses into one run of the +# newest commit — intermediate commits don't need their own OTA. +concurrency: + group: mobile-eas-production + cancel-in-progress: false jobs: production: - name: EAS Production ${{ inputs.mode }} + name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }} runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read @@ -98,15 +135,15 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas env:pull production --non-interactive - - name: Build and submit - if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' + - name: Build and submit (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait - - name: Publish OTA update - if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update' + - name: Publish OTA update (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} @@ -117,3 +154,62 @@ jobs: --platform ${{ inputs.platform }} \ --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ --non-interactive + + # No --status filter on build:list: an in-queue/in-progress build must + # count as existing, or every merge during the build window would cut a + # duplicate. Builds started here stay attached to this serialized run so + # the queued run for a later merge cannot overtake them and lose its OTA. + # After an errored build, retry via workflow_dispatch mode=build — pushes + # won't re-trigger it until the app version changes. + - id: store_builds + name: Ensure store builds exist for the current app version + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' + continue-on-error: true + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + failed=0 + version="$(npx expo config --json --type public | jq -r '.version')" + for platform in ios android; do + latest="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" + if [ "$latest" = "$version" ]; then + echo "$platform: production build for $version already exists (or is in progress)" + continue + fi + echo "$platform: latest production build is $latest, app.config.ts says $version — building" + if eas build --platform "$platform" --profile production --auto-submit --non-interactive; then + echo ":building_construction: $platform: cut production build for $version (auto-submitted)" >> "$GITHUB_STEP_SUMMARY" + else + failed=1 + echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" + fi + done + exit "$failed" + + - name: Publish fingerprint-gated OTA + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" + for platform in ios android; do + hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | jq -r '.hash')" + matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')" + if [ "$matching" -gt 0 ]; then + eas update \ + --channel production \ + --environment production \ + --platform "$platform" \ + --message "$message" \ + --non-interactive + echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" + else + 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" + fi + done + + - name: Propagate store build failure + if: steps.store_builds.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml new file mode 100644 index 00000000000..fd98817cd10 --- /dev/null +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -0,0 +1,205 @@ +name: Mobile Fingerprint Check + +# Detects whether a PR changes the native fingerprint — i.e. whether merging +# it would leave main un-OTA-able until a new store build ships. Native-change +# PRs get the "📱 Native Change" label so they can be held and merged as a +# batch right before the next store submission, keeping main OTA-able for +# everything else in between. (Once one native PR merges, every later merge +# inherits the drifted fingerprint and loses OTA reach too — that is why the +# signal has to fire before merge, not after.) +# +# The check is advisory: it always passes, the label is the signal. Both +# fingerprints are computed in this one job (same OS, same corepack-pinned +# pnpm), so the comparison is self-consistent; no EXPO_TOKEN needed. +on: + pull_request: + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - scripts/** + - patches/** + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-fingerprint-check.yml + +concurrency: + group: mobile-fingerprint-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + fingerprint: + name: Native fingerprint diff + runs-on: blacksmith-8vcpu-ubuntu-2404 + permissions: + contents: read + issues: write + pull-requests: write + env: + APP_VARIANT: production + NODE_OPTIONS: --max-old-space-size=8192 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Default pull_request checkout is the merge commit (PR applied on + # top of base), so the "head" fingerprint is the state main would + # actually be in after merging — stale branches compare cleanly. + fetch-depth: 0 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/mobile... + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Fingerprint merge result + working-directory: apps/mobile + run: | + mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base" + for platform in ios android; do + npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/head/$platform.json" + done + + - name: Fingerprint base + run: | + git checkout --quiet "${{ github.event.pull_request.base.sha }}" + # Re-sync node_modules to the base commit's lockfile before + # fingerprinting — a dep-changing PR must not fingerprint the base + # against head's installed packages. + pnpm install --filter=@t3tools/mobile... + cd apps/mobile + for platform in ios android; do + npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/base/$platform.json" + done + + - id: compare + name: Compare fingerprints + run: | + changed="" + { + echo "## Native fingerprint diff" + echo + for platform in ios android; do + head_hash="$(jq -r .hash "$RUNNER_TEMP/fp/head/$platform.json")" + base_hash="$(jq -r .hash "$RUNNER_TEMP/fp/base/$platform.json")" + if [ "$head_hash" = "$base_hash" ]; then + echo "- ✅ **$platform**: unchanged (\`$head_hash\`) — OTA-compatible" + continue + fi + changed="$changed $platform" + echo "- 📱 **$platform**: \`$base_hash\` → \`$head_hash\` — merging requires a new native build before OTAs work again" + jq -r -n \ + --slurpfile h "$RUNNER_TEMP/fp/head/$platform.json" \ + --slurpfile b "$RUNNER_TEMP/fp/base/$platform.json" ' + ($b[0].sources | map({ (.filePath // .id): .hash }) | add // {}) as $bm + | $h[0].sources[] + | select($bm[(.filePath // .id)] != .hash) + | " - \(.type): `\(.filePath // .id)`"' + done + } >> "$GITHUB_STEP_SUMMARY" + echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" + + - name: Sync native change label + # Fork PRs get a read-only token under pull_request; the check stays + # advisory there (summary only). This workflow must not move to + # pull_request_target — it installs and runs PR code. + if: github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v8 + env: + CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }} + with: + script: | + const managedLabel = { + name: "📱 Native Change", + color: "d93f0b", + description: + "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", + }; + const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; + const issueNumber = context.payload.pull_request.number; + + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + }); + + if ( + existing.color !== managedLabel.color || + (existing.description ?? "") !== managedLabel.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); + + if (nativeChanged && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [managedLabel.name], + }); + } else if (!nativeChanged && hasLabel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: managedLabel.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + core.info( + `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, + );