Repository navigation
fix(web): load selected diff metadata before file patches (#30567) #2498
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |