Skip to content

fix(web): load selected diff metadata before file patches (#30567) #2498

fix(web): load selected diff metadata before file patches (#30567)

fix(web): load selected diff metadata before file patches (#30567) #2498

name: Mobile EAS Production
# Production builds and OTA updates run from CI (Linux) — never from a laptop.
# Under the fingerprint runtime-version policy the fingerprint must be computed
# 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 reconciles native fingerprints and publishes matching OTAs.
# iOS prereleases use the testflight profile (3.0.0); App Store binaries use
# production and the committed app version, built manually. Both receive OTAs
# for their own major version. Android continues using production automatically.
# TestFlight beta review may still be required for subsequent builds.
# Hold native-change PRs (the "📱 Native Change" label) and merge them as a
# batch to avoid a store build per PR.
# 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:
mode:
description: "build (+ auto-submit to TestFlight / Google Play) or update (OTA)"
required: true
type: choice
default: build
options:
- build
- update
profile:
description: "production: store release; testflight: iOS 3.0.0 beta"
required: true
type: choice
default: production
options:
- production
- testflight
platform:
description: "Target platform"
required: true
type: choice
default: all
options:
- ios
- android
- all
version:
description: "Optional build version override (blank uses profile default; store overrides are committed)"
required: false
type: string
message:
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 ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }}
if: github.repository == 'pingdotgg/t3code'
runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
contents: read
env:
APP_VARIANT: production
NODE_OPTIONS: --max-old-space-size=8192
steps:
- name: Validate TestFlight inputs
if: github.event_name == 'workflow_dispatch' && inputs.profile == 'testflight'
env:
PLATFORM: ${{ inputs.platform }}
VERSION: ${{ inputs.version }}
run: |
if [ "$PLATFORM" != "ios" ] || [ -n "$VERSION" ]; then
echo "::error::TestFlight uses platform=ios and its fixed version. Use production for a committed store version."
exit 1
fi
- id: expo-token
name: Check for EXPO_TOKEN
env:
EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
run: |
if [ -n "$EXPO_TOKEN" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "EXPO_PRODUCTION_TOKEN is not available; skipping EAS production job."
fi
- id: version_app_token
name: Mint release app token for version override
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' && inputs.profile != 'testflight'
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Checkout
if: steps.expo-token.outputs.present == 'true'
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ steps.version_app_token.outputs.token || github.token }}
# No sparse-checkout here: it makes actions/checkout fetch with
# --filter=blob:none, and eas-cli archives the project via
# `git clone --depth 1 file://<workspace>`, which fails (exit 128)
# when the partial clone can't serve the unfetched blobs.
- name: Setup Vite+
if: steps.expo-token.outputs.present == 'true'
uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
cache: true
run-install: |
args:
- --filter=@t3tools/mobile...
- name: Expose pnpm
if: steps.expo-token.outputs.present == 'true'
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: Setup EAS
if: steps.expo-token.outputs.present == 'true'
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
# npm, not pnpm: this only installs eas-cli into the action's own
# tool dir, and pnpm 11 hard-fails that install on dtrace-provider's
# ignored build script (no allowBuilds config outside the repo).
packager: npm
- name: Pull production environment variables
if: steps.expo-token.outputs.present == 'true'
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
run: |
eas env:pull production --non-interactive
# EAS commands skip dotenv, and the Android fingerprint hashes this
# file. Without it every merge misses the build's hash and rebuilds.
node --input-type=module <<'NODE'
import { appendFileSync, readFileSync } from "node:fs";
import { parseEnv } from "node:util";
const env = parseEnv(readFileSync(".env.local", "utf8"));
const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE;
if (!googleServicesFile) {
throw new Error("T3CODE_ANDROID_GOOGLE_SERVICES_FILE is missing from the production environment");
}
appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`);
NODE
- name: Apply manual version override
if: steps.version_app_token.outcome == 'success'
env:
GH_TOKEN: ${{ steps.version_app_token.outputs.token }}
APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }}
RELEASE_VERSION: ${{ inputs.version }}
run: |
if [ "$GITHUB_REF_TYPE" != "branch" ]; then
echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2
exit 1
fi
if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then
echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2
exit 1
fi
node --input-type=module -e '
import fs from "node:fs";
const path = "apps/mobile/app.config.ts";
const source = fs.readFileSync(path, "utf8");
const next = source.replace(
/^( version: repoEnv\.T3CODE_MOBILE_VERSION \?\? ")[^"]+(".*)$/m,
`$1${process.env.RELEASE_VERSION}$2`,
);
if (next === source && !source.includes(` version: repoEnv.T3CODE_MOBILE_VERSION ?? "${process.env.RELEASE_VERSION}"`)) {
throw new Error("Could not update app version");
}
fs.writeFileSync(path, next);
'
vp fmt apps/mobile/app.config.ts
if git diff --quiet -- apps/mobile/app.config.ts; then
echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed."
exit 0
fi
user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
git config user.name "${APP_SLUG}[bot]"
git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com"
git add apps/mobile/app.config.ts
git commit --no-verify \
-m "chore(mobile): bump app version to $RELEASE_VERSION" \
-m "Co-authored-by: codex <codex@users.noreply.github.com>"
git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}"
- name: Summarize manual build version
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build'
working-directory: apps/mobile
env:
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
run: |
if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then
T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')"
export T3CODE_MOBILE_VERSION
fi
version="$(npx expo config --json --type public | jq -r '.version')"
{
echo "## Manual $MOBILE_BUILD_PROFILE build"
echo
echo "- App version: \`$version\`"
echo "- Platform: \`${{ inputs.platform }}\`"
echo
echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version."
} >> "$GITHUB_STEP_SUMMARY"
- 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_PRODUCTION_TOKEN }}
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait
# @expo/fingerprint swallows autolinking errors and returns no Expo module
# sources, so native changes stop moving the hash and OTAs reach binaries
# that lack them. Refuse to publish any OTA from such a fingerprint.
- name: Verify fingerprints hash Expo native modules
if: steps.expo-token.outputs.present == 'true' && (github.event_name == 'push' || inputs.mode == 'update')
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
PLATFORM: ${{ inputs.platform || 'all' }}
run: |
if [ "$PLATFORM" = "all" ]; then platforms="ios android"; else platforms="$PLATFORM"; fi
failed=0
for platform in $platforms; do
# eas-cli prints an environment-loaded notice to stdout before the
# JSON even with --json, so discard everything before the document.
expo_modules="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq --arg reason "expoAutolinking${platform^}" '[.sources[] | select((.reasons // []) | index($reason))] | length')"
if [ "${expo_modules:-0}" -eq 0 ]; then
failed=1
echo ":x: $platform: the native fingerprint hashes no Expo native modules — OTAs and builds skipped" >> "$GITHUB_STEP_SUMMARY"
fi
done
exit "$failed"
- 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_PRODUCTION_TOKEN }}
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
OTA_MESSAGE: ${{ inputs.message || format('Production OTA ({0})', github.sha) }}
run: |
if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then
T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')"
export T3CODE_MOBILE_VERSION
fi
eas update \
--channel production \
--environment production \
--platform ${{ inputs.platform }} \
--message "$OTA_MESSAGE" \
--emit-metadata \
--non-interactive
# Reconcile each version separately because the major is in the fingerprint.
# Production iOS only receives OTAs here; store binaries are manual releases.
# Failed prerelease builds are retried manually, not on every merge.
- id: reconcile
name: Publish OTA or cut store build
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
run: |
failed=0
message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))"
for target in ios:production ios:testflight android:production; do
platform="${target%%:*}"
profile="${target#*:}"
unset T3CODE_MOBILE_VERSION
if [ "$profile" = "testflight" ]; then
T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')"
export T3CODE_MOBILE_VERSION
fi
version="$(npx expo config --json --type public | jq -r '.version')"
# eas-cli prints an environment-loaded notice to stdout before the
# JSON even with --json, so discard everything before the document.
hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')"
matches="$(eas build:list --platform "$platform" --build-profile "$profile" --fingerprint-hash "$hash" --limit 20 --json --non-interactive)"
finished="$(jq '[.[] | select(.status == "FINISHED")] | length' <<<"$matches")"
active="$(jq '[.[] | select(.status == "NEW" or .status == "IN_QUEUE" or .status == "IN_PROGRESS")] | length' <<<"$matches")"
errored="$(jq '[.[] | select(.status == "ERRORED")] | length' <<<"$matches")"
latest_version="$(eas build:list --platform "$platform" --build-profile "$profile" --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')"
if [ "$finished" -gt 0 ]; then
if eas update \
--channel production \
--environment production \
--platform "$platform" \
--message "$message" \
--emit-metadata \
--non-interactive; then
echo ":white_check_mark: $platform/$profile: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
# The next target's export replaces dist; keep this one for the map upload.
rm -rf "dist-$platform-$profile" && mv dist "dist-$platform-$profile"
else
failed=1
echo ":x: $platform/$profile: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
fi
fi
# Never rebuild the released iOS version automatically.
if [ "$target" = "ios:production" ]; then
continue
fi
reason=""
if [ "$finished" -eq 0 ] && [ "$active" -eq 0 ]; then
if [ "$errored" -gt 0 ]; then
echo ":x: $platform/$profile: the build for fingerprint \`$hash\` errored — retry with workflow_dispatch mode=build profile=$profile platform=$platform" >> "$GITHUB_STEP_SUMMARY"
failed=1
continue
fi
reason="native fingerprint \`$hash\` has no build"
elif [ "$latest_version" != "$version" ] && [ "$active" -eq 0 ]; then
reason="latest $profile build is $latest_version, expected $version"
fi
if [ -z "$reason" ]; then
if [ "$finished" -eq 0 ]; then
echo ":hourglass: $platform/$profile: build for fingerprint \`$hash\` is in progress — OTA resumes on the next merge after it finishes" >> "$GITHUB_STEP_SUMMARY"
fi
continue
fi
echo "$platform: $reason — building"
if eas build --platform "$platform" --profile "$profile" --auto-submit --non-interactive --no-wait; then
echo ":building_construction: $platform: scheduled production build and submission for $version ($reason)" >> "$GITHUB_STEP_SUMMARY"
else
failed=1
echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY"
fi
done
exit "$failed"
# Lets the relay symbolicate crash stacks from the updates published
# above. A step of its own so the R2 credentials reach only this script,
# not `eas update` and the dependencies it runs. Never fails the job.
- name: Upload OTA source maps
if: ${{ !cancelled() && steps.expo-token.outputs.present == 'true' }}
working-directory: apps/mobile
env:
MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }}
run: |
for dir in dist dist-*; do
if [ -f "$dir/eas-update-metadata.json" ]; then
node scripts/upload-sourcemaps.mts ota "$dir"
fi
done