Repository navigation
fix(usage): count shared Windows and WSL transcripts once (#24319) #3065
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. | |
| # Android store builds run here with `eas build --local` on Blacksmith (the | |
| # android job). iOS builds run on EAS unless the MOBILE_IOS_LOCAL_BUILDS | |
| # repository variable is `true`, which moves them to the ios job the same way. | |
| # Local builds never appear in `eas build:list`, so each finished local build | |
| # pushes a mobile-android-build/<version>/<fingerprint> or | |
| # mobile-ios-build/<profile>/<version>/<fingerprint> tag that the reconcile reads. | |
| # 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 | |
| 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 | |
| # Serialize runs so OTAs publish in merge order. GitHub keeps at most one | |
| # queued job 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 | |
| permissions: | |
| contents: read | |
| outputs: | |
| android_build: ${{ steps.reconcile.outputs.android_build || steps.android_manual.outputs.android_build }} | |
| android_fingerprint: ${{ steps.reconcile.outputs.android_fingerprint || steps.android_manual.outputs.android_fingerprint }} | |
| android_version: ${{ steps.reconcile.outputs.android_version || steps.android_manual.outputs.android_version }} | |
| android_sha: ${{ steps.reconcile.outputs.android_sha || steps.android_manual.outputs.android_sha }} | |
| ios_build: ${{ steps.reconcile.outputs.ios_build || steps.ios_manual.outputs.ios_build }} | |
| ios_profile: ${{ steps.reconcile.outputs.ios_profile || steps.ios_manual.outputs.ios_profile }} | |
| ios_fingerprint: ${{ steps.reconcile.outputs.ios_fingerprint || steps.ios_manual.outputs.ios_fingerprint }} | |
| ios_version: ${{ steps.reconcile.outputs.ios_version || steps.ios_manual.outputs.ios_version }} | |
| ios_sha: ${{ steps.reconcile.outputs.ios_sha || steps.ios_manual.outputs.ios_sha }} | |
| 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@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 | |
| 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@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 | |
| 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@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 | |
| 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@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 | |
| 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" | |
| 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 iOS (manual) | |
| if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'android' && vars.MOBILE_IOS_LOCAL_BUILDS != 'true' | |
| working-directory: apps/mobile | |
| env: | |
| EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} | |
| MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} | |
| run: eas build --platform ios --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait | |
| - id: ios_manual | |
| name: Queue iOS build (manual) | |
| if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'android' && vars.MOBILE_IOS_LOCAL_BUILDS == 'true' | |
| working-directory: apps/mobile | |
| env: | |
| EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} | |
| 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')" | |
| hash="$(eas fingerprint:generate --platform ios --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" | |
| { | |
| echo "ios_build=true" | |
| echo "ios_profile=$MOBILE_BUILD_PROFILE" | |
| echo "ios_fingerprint=$hash" | |
| echo "ios_version=$version" | |
| echo "ios_sha=$(git rev-parse HEAD)" | |
| } >> "$GITHUB_OUTPUT" | |
| - id: android_manual | |
| name: Queue Android build (manual) | |
| if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'ios' | |
| working-directory: apps/mobile | |
| env: | |
| EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} | |
| run: | | |
| version="$(npx expo config --json --type public | jq -r '.version')" | |
| hash="$(eas fingerprint:generate --platform android --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" | |
| { | |
| echo "android_build=true" | |
| echo "android_fingerprint=$hash" | |
| echo "android_version=$version" | |
| echo "android_sha=$(git rev-parse HEAD)" | |
| } >> "$GITHUB_OUTPUT" | |
| # @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 }} | |
| IOS_LOCAL_BUILDS: ${{ vars.MOBILE_IOS_LOCAL_BUILDS == 'true' }} | |
| 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"')" | |
| # Local builds (the android and ios jobs) record each build as a tag; | |
| # EAS records still count for builds made before that. | |
| tag_prefix="" | |
| if [ "$platform" = "android" ]; then | |
| tag_prefix="mobile-android-build" | |
| elif [ "$IOS_LOCAL_BUILDS" = "true" ]; then | |
| tag_prefix="mobile-ios-build/$profile" | |
| fi | |
| if [ -n "$tag_prefix" ]; then | |
| if [ -n "$(git tag -l "$tag_prefix/*/$hash")" ]; then | |
| finished=$((finished + 1)) | |
| fi | |
| if [ -n "$(git tag -l "$tag_prefix/$version/*")" ]; then | |
| latest_version="$version" | |
| fi | |
| fi | |
| 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 [ "$platform" = "android" ]; then | |
| { | |
| echo "android_build=true" | |
| echo "android_fingerprint=$hash" | |
| echo "android_version=$version" | |
| echo "android_sha=$(git rev-parse HEAD)" | |
| } >> "$GITHUB_OUTPUT" | |
| echo ":building_construction: android: building and submitting $version in the Android store build job ($reason)" >> "$GITHUB_STEP_SUMMARY" | |
| continue | |
| fi | |
| if [ "$platform" = "ios" ] && [ "$IOS_LOCAL_BUILDS" = "true" ]; then | |
| { | |
| echo "ios_build=true" | |
| echo "ios_profile=$profile" | |
| echo "ios_fingerprint=$hash" | |
| echo "ios_version=$version" | |
| echo "ios_sha=$(git rev-parse HEAD)" | |
| } >> "$GITHUB_OUTPUT" | |
| echo ":building_construction: ios/$profile: building and submitting $version in the iOS store build job ($reason)" >> "$GITHUB_STEP_SUMMARY" | |
| continue | |
| fi | |
| 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 | |
| # `eas build --local` on Blacksmith, ARM-only, without release lint (see the | |
| # production profile in eas.json), with Gradle, ccache, and pnpm state on a | |
| # sticky disk. Its own concurrency group keeps later OTAs from waiting on it. | |
| android: | |
| name: Android store build | |
| needs: production | |
| # Skipped when the production job failed: no store build after a failed OTA. | |
| if: needs.production.outputs.android_build == 'true' | |
| runs-on: blacksmith-32vcpu-ubuntu-2404 | |
| timeout-minutes: 60 | |
| concurrency: | |
| group: mobile-android-store-build | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write # pushes the build record tag | |
| env: | |
| APP_VARIANT: production | |
| NODE_OPTIONS: --max-old-space-size=8192 | |
| EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} | |
| BUILD_TAG: mobile-android-build/${{ needs.production.outputs.android_version }}/${{ needs.production.outputs.android_fingerprint }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 | |
| with: | |
| ref: ${{ needs.production.outputs.android_sha }} | |
| fetch-depth: 0 | |
| # Dependency scripts and the build run in this job; only Record build gets a token. | |
| # persist-credentials: false would clean up via `git submodule foreach`, which fails on .repos gitlinks. | |
| - name: Drop checkout credentials | |
| run: | | |
| git config --local --unset-all http.https://github.com/.extraheader || true | |
| { git config --local --name-only --get-regexp '^includeif\.' || true; } | while read -r key; do | |
| path=$(git config --local --get "$key") | |
| git config --local --unset-all "$key" | |
| case "$path" in "$RUNNER_TEMP"/*) rm -f "$path" ;; esac | |
| done | |
| if git config --local --get-regexp '^(http\..*\.extraheader|includeif\.)'; then | |
| echo "::error::Checkout credentials are still configured" | |
| exit 1 | |
| fi | |
| # A queued run can follow a build of the same fingerprint; manual runs always build. | |
| - id: record | |
| name: Check build record | |
| env: | |
| FORCE: ${{ github.event_name == 'workflow_dispatch' }} | |
| run: | | |
| if [ "$FORCE" != "true" ] && [ -n "$(git tag -l "$BUILD_TAG")" ]; then | |
| echo "built=true" >> "$GITHUB_OUTPUT" | |
| echo ":white_check_mark: android: \`$BUILD_TAG\` was built by an earlier run" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Prepare build directories | |
| if: steps.record.outputs.built != 'true' | |
| run: | | |
| cache="$HOME/.cache/t3-android-store" | |
| { | |
| echo "GRADLE_USER_HOME=$cache/gradle" | |
| echo "CCACHE_DIR=$cache/ccache" | |
| echo "CCACHE_MAXSIZE=5G" | |
| # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime. | |
| echo "CCACHE_COMPILERCHECK=%compiler% -v" | |
| echo "pnpm_config_store_dir=$cache/pnpm-store" | |
| # eas build --local copies the project here. A fixed path keeps | |
| # ccache keys stable, and the same disk as the store lets pnpm hardlink. | |
| echo "EAS_LOCAL_BUILD_WORKINGDIR=$cache/eas-build" | |
| echo "CCACHE_BASEDIR=$cache/eas-build" | |
| # CMake (3.17+) reads these, and AGP passes Gradle's environment through. | |
| echo "CMAKE_C_COMPILER_LAUNCHER=ccache" | |
| echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" | |
| } >> "$GITHUB_ENV" | |
| - name: Mount build cache | |
| if: steps.record.outputs.built != 'true' | |
| uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 | |
| with: | |
| key: ${{ github.repository }}-android-store-build | |
| path: ~/.cache/t3-android-store | |
| - name: Configure Gradle and ccache | |
| if: steps.record.outputs.built != 'true' | |
| run: | | |
| mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR" | |
| rm -rf "$EAS_LOCAL_BUILD_WORKINGDIR" | |
| # No daemon: nothing should hold the sticky disk open when it is committed. | |
| printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties" | |
| sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources | |
| sudo apt-get update -qq && sudo apt-get install -y -qq ccache | |
| ccache --zero-stats | |
| - name: Setup Node | |
| if: steps.record.outputs.built != 'true' | |
| uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 | |
| with: | |
| node-version-file: package.json | |
| - name: Enable corepack | |
| if: steps.record.outputs.built != 'true' | |
| run: | | |
| npm install --global corepack@latest | |
| corepack enable | |
| pnpm --version | |
| # EAS commands load app.config.ts, whose plugins must resolve. Keep install | |
| # scripts: some download native assets that the build's fingerprint check hashes. | |
| - name: Install mobile dependencies | |
| if: steps.record.outputs.built != 'true' | |
| run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile... | |
| - name: Setup Java | |
| if: steps.record.outputs.built != 'true' | |
| uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 | |
| with: | |
| distribution: temurin | |
| java-version: 17 | |
| - name: Setup EAS | |
| if: steps.record.outputs.built != 'true' | |
| uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 | |
| with: | |
| eas-version: latest | |
| token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} | |
| packager: npm | |
| - name: Pull production environment variables | |
| if: steps.record.outputs.built != 'true' | |
| working-directory: apps/mobile | |
| run: | | |
| eas env:pull production --non-interactive | |
| 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: Build | |
| if: steps.record.outputs.built != 'true' | |
| working-directory: apps/mobile | |
| env: | |
| # EAS secret variables don't reach local builds; the eas-build-on-success | |
| # hook needs these to upload the bundle's source map. | |
| MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }} | |
| run: | | |
| eas build --local --platform android --profile production --non-interactive --output "$RUNNER_TEMP/t3code.aab" | |
| abis="$(unzip -Z1 "$RUNNER_TEMP/t3code.aab" | sed -n 's|^base/lib/\([^/]*\)/.*|\1|p' | sort -u | paste -sd, -)" | |
| { | |
| echo "### Android $BUILD_TAG" | |
| echo "- ABIs: \`$abis\`" | |
| echo '```' | |
| ccache --show-stats | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$abis" != "arm64-v8a,armeabi-v7a" ]; then | |
| echo "::error::Expected only ARM native libraries in the AAB, found $abis" | |
| exit 1 | |
| fi | |
| # A --path submission doesn't keep its archive on EAS; this is the only downloadable copy. | |
| - name: Keep the AAB | |
| if: steps.record.outputs.built != 'true' | |
| uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 | |
| with: | |
| name: android-store-aab-${{ github.run_id }}-${{ github.run_attempt }} | |
| path: ${{ runner.temp }}/t3code.aab | |
| retention-days: 14 | |
| if-no-files-found: error | |
| - name: Submit to Google Play | |
| if: steps.record.outputs.built != 'true' | |
| working-directory: apps/mobile | |
| run: eas submit --platform android --profile production --path "$RUNNER_TEMP/t3code.aab" --non-interactive | |
| # The tag means "delivered", so it follows the submit. If it can't be | |
| # pushed, the next merge rebuilds with a new build number; nothing is lost. | |
| - name: Record build | |
| if: steps.record.outputs.built != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| sha="$(git rev-parse HEAD)" | |
| # GitHub occasionally fails ref writes server-side ("fatal error in commit_refs"). | |
| for attempt in 1 2 3; do | |
| if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$BUILD_TAG" >/dev/null 2>&1; then | |
| echo "$BUILD_TAG exists." | |
| break | |
| fi | |
| if gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$BUILD_TAG" -f sha="$sha" >/dev/null; then | |
| break | |
| fi | |
| if [ "$attempt" = 3 ]; then | |
| echo "::error::Submitted, but could not create $BUILD_TAG; the next merge will rebuild." | |
| exit 1 | |
| fi | |
| sleep $((attempt * 15)) | |
| done | |
| echo ":rocket: android: submitted and recorded \`$BUILD_TAG\`" >> "$GITHUB_STEP_SUMMARY" | |
| # Off unless the MOBILE_IOS_LOCAL_BUILDS repository variable is `true`; the | |
| # production job only sets ios_build then. Xcode 27.1 matches the EAS build | |
| # image in eas.json. | |
| # No sticky disk: it mounts ext4 and only works on Linux runners. | |
| ios: | |
| name: iOS store build | |
| needs: production | |
| # Skipped when the production job failed: no store build after a failed OTA. | |
| if: needs.production.outputs.ios_build == 'true' | |
| runs-on: blacksmith-12vcpu-macos-27 | |
| timeout-minutes: 60 | |
| concurrency: | |
| group: mobile-ios-store-build | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write # pushes the build record tag | |
| env: | |
| APP_VARIANT: production | |
| NODE_OPTIONS: --max-old-space-size=8192 | |
| EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} | |
| PROFILE: ${{ needs.production.outputs.ios_profile }} | |
| BUILD_TAG: mobile-ios-build/${{ needs.production.outputs.ios_profile }}/${{ needs.production.outputs.ios_version }}/${{ needs.production.outputs.ios_fingerprint }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 | |
| with: | |
| ref: ${{ needs.production.outputs.ios_sha }} | |
| fetch-depth: 0 | |
| # Dependency scripts and the build run in this job; only Record build gets a token. | |
| # persist-credentials: false would clean up via `git submodule foreach`, which fails on .repos gitlinks. | |
| - name: Drop checkout credentials | |
| run: | | |
| git config --local --unset-all http.https://github.com/.extraheader || true | |
| { git config --local --name-only --get-regexp '^includeif\.' || true; } | while read -r key; do | |
| path=$(git config --local --get "$key") | |
| git config --local --unset-all "$key" | |
| case "$path" in "$RUNNER_TEMP"/*) rm -f "$path" ;; esac | |
| done | |
| if git config --local --get-regexp '^(http\..*\.extraheader|includeif\.)'; then | |
| echo "::error::Checkout credentials are still configured" | |
| exit 1 | |
| fi | |
| # A queued run can follow a build of the same fingerprint; manual runs always build. | |
| - id: record | |
| name: Check build record | |
| env: | |
| FORCE: ${{ github.event_name == 'workflow_dispatch' }} | |
| run: | | |
| if [ "$FORCE" != "true" ] && [ -n "$(git tag -l "$BUILD_TAG")" ]; then | |
| echo "built=true" >> "$GITHUB_OUTPUT" | |
| echo ":white_check_mark: ios: \`$BUILD_TAG\` was built by an earlier run" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Select Xcode 27.1 | |
| if: steps.record.outputs.built != 'true' | |
| run: | | |
| xcode=/Applications/Xcode_27.1.app | |
| if [ ! -d "$xcode" ]; then | |
| ls -d /Applications/Xcode*.app | |
| echo "::error::Xcode 27.1 is not on this runner." | |
| exit 1 | |
| fi | |
| sudo xcode-select -s "$xcode/Contents/Developer" | |
| xcodebuild -version | |
| echo "- Xcode: \`$(basename "$xcode")\`" >> "$GITHUB_STEP_SUMMARY" | |
| # EAS's credentials step fails to import the distribution certificate without it. | |
| - name: Install Apple WWDR G3 intermediate | |
| if: steps.record.outputs.built != 'true' | |
| run: | | |
| curl -fsSL -o "$RUNNER_TEMP/AppleWWDRCAG3.cer" https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer | |
| sudo security add-certificates -k /Library/Keychains/System.keychain "$RUNNER_TEMP/AppleWWDRCAG3.cer" || true | |
| security find-certificate -c "Apple Worldwide Developer Relations" /Library/Keychains/System.keychain | grep -m1 labl | |
| - name: Setup Node | |
| if: steps.record.outputs.built != 'true' | |
| uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 | |
| with: | |
| node-version-file: package.json | |
| - name: Enable corepack | |
| if: steps.record.outputs.built != 'true' | |
| run: | | |
| npm install --global corepack@latest | |
| corepack enable | |
| pnpm --version | |
| # EAS commands load app.config.ts, whose plugins must resolve. Keep install | |
| # scripts: some download native assets that the build's fingerprint check hashes. | |
| - name: Install mobile dependencies | |
| if: steps.record.outputs.built != 'true' | |
| run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile... | |
| - name: Setup EAS | |
| if: steps.record.outputs.built != 'true' | |
| uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 | |
| with: | |
| eas-version: latest | |
| token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} | |
| packager: npm | |
| - name: Build tools | |
| if: steps.record.outputs.built != 'true' | |
| run: | | |
| command -v pod || sudo gem install cocoapods --no-document | |
| command -v fastlane || brew install fastlane | |
| pod --version | |
| fastlane --version | tail -1 | |
| - name: Pull production environment variables | |
| if: steps.record.outputs.built != 'true' | |
| working-directory: apps/mobile | |
| run: eas env:pull production --non-interactive | |
| - name: Build | |
| if: steps.record.outputs.built != 'true' | |
| working-directory: apps/mobile | |
| env: | |
| # EAS secret variables don't reach local builds; the eas-build-on-success | |
| # hook needs these to upload the bundle's source map. | |
| MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }} | |
| run: | | |
| # A real path: /var -> /private/var symlinks break the expo-widgets bundle step. | |
| export EAS_LOCAL_BUILD_WORKINGDIR="$RUNNER_TEMP/eas-build" | |
| eas build --local --platform ios --profile "$PROFILE" --non-interactive --output "$RUNNER_TEMP/t3code.ipa" | |
| - name: Submit to App Store Connect | |
| if: steps.record.outputs.built != 'true' | |
| working-directory: apps/mobile | |
| run: eas submit --platform ios --profile "$PROFILE" --path "$RUNNER_TEMP/t3code.ipa" --non-interactive | |
| # The tag means "delivered", so it follows the submit. If it can't be | |
| # pushed, the next merge rebuilds with a new build number; nothing is lost. | |
| - name: Record build | |
| if: steps.record.outputs.built != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| sha="$(git rev-parse HEAD)" | |
| # GitHub occasionally fails ref writes server-side ("fatal error in commit_refs"). | |
| for attempt in 1 2 3; do | |
| if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$BUILD_TAG" >/dev/null 2>&1; then | |
| echo "$BUILD_TAG exists." | |
| break | |
| fi | |
| if gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$BUILD_TAG" -f sha="$sha" >/dev/null; then | |
| break | |
| fi | |
| if [ "$attempt" = 3 ]; then | |
| echo "::error::Submitted, but could not create $BUILD_TAG; the next merge will rebuild." | |
| exit 1 | |
| fi | |
| sleep $((attempt * 15)) | |
| done | |
| echo ":rocket: ios: submitted and recorded \`$BUILD_TAG\`" >> "$GITHUB_STEP_SUMMARY" |