Open History to see the last commit
1name: Mobile EAS Production
2
3# Production builds and OTA updates run from CI (Linux) — never from a laptop.
4# Under the fingerprint runtime-version policy the fingerprint must be computed
5# in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different
6# fingerprint (platform-specific deps + pnpm version) and errors. On this Linux
7# runner, with corepack pinning pnpm 10.24 in eas.json, local == build.
8#
9# Every merge reconciles native fingerprints and publishes matching OTAs.
10# Android store builds run here with `eas build --local` on Blacksmith (the
11# android job). iOS builds run on EAS unless the MOBILE_IOS_LOCAL_BUILDS
12# repository variable is `true`, which moves them to the ios job the same way.
13# Local builds never appear in `eas build:list`, so each finished local build
14# pushes a mobile-android-build/<version>/<fingerprint> or
15# mobile-ios-build/<profile>/<version>/<fingerprint> tag that the reconcile reads.
16# iOS prereleases use the testflight profile (3.0.0); App Store binaries use
17# production and the committed app version, built manually. Both receive OTAs
18# for their own major version. Android continues using production automatically.
19# TestFlight beta review may still be required for subsequent builds.
20# Hold native-change PRs (the "📱 Native Change" label) and merge them as a
21# batch to avoid a store build per PR.
22# workflow_dispatch remains as a manual override for both modes (e.g. to
23# retry an errored build or force an OTA).
24on:
25 workflow_dispatch:
26 inputs:
27 mode:
28 description: "build (+ auto-submit to TestFlight / Google Play) or update (OTA)"
29 required: true
30 type: choice
31 default: build
32 options:
33 - build
34 - update
35 profile:
36 description: "production: store release; testflight: iOS 3.0.0 beta"
37 required: true
38 type: choice
39 default: production
40 options:
41 - production
42 - testflight
43 platform:
44 description: "Target platform"
45 required: true
46 type: choice
47 default: all
48 options:
49 - ios
50 - android
51 - all
52 version:
53 description: "Optional build version override (blank uses profile default; store overrides are committed)"
54 required: false
55 type: string
56 message:
57 description: "OTA update message (mode=update only)"
58 required: false
59 type: string
60 push:
61 branches: [main]
62 paths:
63 - apps/mobile/**
64 - packages/client-runtime/**
65 - packages/contracts/**
66 - packages/shared/**
67 - assets/**
68 - scripts/**
69 - patches/**
70 - pnpm-lock.yaml
71 - pnpm-workspace.yaml
72 - .github/workflows/mobile-eas-production.yml
73
74jobs:
75 production:
76 name: EAS ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }}
77 if: github.repository == 'pingdotgg/t3code'
78 runs-on: blacksmith-8vcpu-ubuntu-2404
79 # Serialize runs so OTAs publish in merge order. GitHub keeps at most one
80 # queued job per group, so a burst of merges collapses into one run of the
81 # newest commit — intermediate commits don't need their own OTA.
82 concurrency:
83 group: mobile-eas-production
84 cancel-in-progress: false
85 permissions:
86 contents: read
87 outputs:
88 android_build: ${{ steps.reconcile.outputs.android_build || steps.android_manual.outputs.android_build }}
89 android_fingerprint: ${{ steps.reconcile.outputs.android_fingerprint || steps.android_manual.outputs.android_fingerprint }}
90 android_version: ${{ steps.reconcile.outputs.android_version || steps.android_manual.outputs.android_version }}
91 android_sha: ${{ steps.reconcile.outputs.android_sha || steps.android_manual.outputs.android_sha }}
92 ios_build: ${{ steps.reconcile.outputs.ios_build || steps.ios_manual.outputs.ios_build }}
93 ios_profile: ${{ steps.reconcile.outputs.ios_profile || steps.ios_manual.outputs.ios_profile }}
94 ios_fingerprint: ${{ steps.reconcile.outputs.ios_fingerprint || steps.ios_manual.outputs.ios_fingerprint }}
95 ios_version: ${{ steps.reconcile.outputs.ios_version || steps.ios_manual.outputs.ios_version }}
96 ios_sha: ${{ steps.reconcile.outputs.ios_sha || steps.ios_manual.outputs.ios_sha }}
97 env:
98 APP_VARIANT: production
99 NODE_OPTIONS: --max-old-space-size=8192
100 steps:
101 - name: Validate TestFlight inputs
102 if: github.event_name == 'workflow_dispatch' && inputs.profile == 'testflight'
103 env:
104 PLATFORM: ${{ inputs.platform }}
105 VERSION: ${{ inputs.version }}
106 run: |
107 if [ "$PLATFORM" != "ios" ] || [ -n "$VERSION" ]; then
108 echo "::error::TestFlight uses platform=ios and its fixed version. Use production for a committed store version."
109 exit 1
110 fi
111
112 - id: expo-token
113 name: Check for EXPO_TOKEN
114 env:
115 EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
116 run: |
117 if [ -n "$EXPO_TOKEN" ]; then
118 echo "present=true" >> "$GITHUB_OUTPUT"
119 else
120 echo "present=false" >> "$GITHUB_OUTPUT"
121 echo "EXPO_PRODUCTION_TOKEN is not available; skipping EAS production job."
122 fi
123
124 - id: version_app_token
125 name: Mint release app token for version override
126 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' && inputs.profile != 'testflight'
127 uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
128 with:
129 app-id: ${{ secrets.RELEASE_APP_ID }}
130 private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
131 owner: ${{ github.repository_owner }}
132
133 - name: Checkout
134 if: steps.expo-token.outputs.present == 'true'
135 uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
136 with:
137 fetch-depth: 0
138 token: ${{ steps.version_app_token.outputs.token || github.token }}
139 # No sparse-checkout here: it makes actions/checkout fetch with
140 # --filter=blob:none, and eas-cli archives the project via
141 # `git clone --depth 1 file://<workspace>`, which fails (exit 128)
142 # when the partial clone can't serve the unfetched blobs.
143
144 - name: Setup Vite+
145 if: steps.expo-token.outputs.present == 'true'
146 uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0
147 with:
148 node-version-file: package.json
149 cache: true
150 run-install: |
151 args:
152 - --filter=@t3tools/mobile...
153
154 - name: Expose pnpm
155 if: steps.expo-token.outputs.present == 'true'
156 run: |
157 pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")"
158 vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin"
159 echo "$vp_pnpm_bin" >> "$GITHUB_PATH"
160 "$vp_pnpm_bin/pnpm" --version
161
162 - name: Setup EAS
163 if: steps.expo-token.outputs.present == 'true'
164 uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1
165 with:
166 eas-version: latest
167 token: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
168 # npm, not pnpm: this only installs eas-cli into the action's own
169 # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's
170 # ignored build script (no allowBuilds config outside the repo).
171 packager: npm
172
173 - name: Pull production environment variables
174 if: steps.expo-token.outputs.present == 'true'
175 working-directory: apps/mobile
176 env:
177 EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
178 run: |
179 eas env:pull production --non-interactive
180 # EAS commands skip dotenv, and the Android fingerprint hashes this
181 # file. Without it every merge misses the build's hash and rebuilds.
182 node --input-type=module <<'NODE'
183 import { appendFileSync, readFileSync } from "node:fs";
184 import { parseEnv } from "node:util";
185 const env = parseEnv(readFileSync(".env.local", "utf8"));
186 const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE;
187 if (!googleServicesFile) {
188 throw new Error("T3CODE_ANDROID_GOOGLE_SERVICES_FILE is missing from the production environment");
189 }
190 appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`);
191 NODE
192
193 - name: Apply manual version override
194 if: steps.version_app_token.outcome == 'success'
195 env:
196 GH_TOKEN: ${{ steps.version_app_token.outputs.token }}
197 APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }}
198 RELEASE_VERSION: ${{ inputs.version }}
199 run: |
200 if [ "$GITHUB_REF_TYPE" != "branch" ]; then
201 echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2
202 exit 1
203 fi
204 if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then
205 echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2
206 exit 1
207 fi
208
209 node --input-type=module -e '
210 import fs from "node:fs";
211 const path = "apps/mobile/app.config.ts";
212 const source = fs.readFileSync(path, "utf8");
213 const next = source.replace(
214 /^( version: repoEnv\.T3CODE_MOBILE_VERSION \?\? ")[^"]+(".*)$/m,
215 `$1${process.env.RELEASE_VERSION}$2`,
216 );
217 if (next === source && !source.includes(` version: repoEnv.T3CODE_MOBILE_VERSION ?? "${process.env.RELEASE_VERSION}"`)) {
218 throw new Error("Could not update app version");
219 }
220 fs.writeFileSync(path, next);
221 '
222 vp fmt apps/mobile/app.config.ts
223
224 if git diff --quiet -- apps/mobile/app.config.ts; then
225 echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed."
226 exit 0
227 fi
228
229 user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
230 git config user.name "${APP_SLUG}[bot]"
231 git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com"
232 git add apps/mobile/app.config.ts
233 git commit --no-verify -m "chore(mobile): bump app version to $RELEASE_VERSION"
234 git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}"
235
236 - name: Summarize manual build version
237 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build'
238 working-directory: apps/mobile
239 env:
240 MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
241 run: |
242 if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then
243 T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')"
244 export T3CODE_MOBILE_VERSION
245 fi
246 version="$(npx expo config --json --type public | jq -r '.version')"
247 {
248 echo "## Manual $MOBILE_BUILD_PROFILE build"
249 echo
250 echo "- App version: \`$version\`"
251 echo "- Platform: \`${{ inputs.platform }}\`"
252 echo
253 echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version."
254 } >> "$GITHUB_STEP_SUMMARY"
255
256 - name: Build and submit iOS (manual)
257 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'android' && vars.MOBILE_IOS_LOCAL_BUILDS != 'true'
258 working-directory: apps/mobile
259 env:
260 EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
261 MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
262 run: eas build --platform ios --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait
263
264 - id: ios_manual
265 name: Queue iOS build (manual)
266 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'android' && vars.MOBILE_IOS_LOCAL_BUILDS == 'true'
267 working-directory: apps/mobile
268 env:
269 EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
270 MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
271 run: |
272 if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then
273 T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')"
274 export T3CODE_MOBILE_VERSION
275 fi
276 version="$(npx expo config --json --type public | jq -r '.version')"
277 hash="$(eas fingerprint:generate --platform ios --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')"
278 {
279 echo "ios_build=true"
280 echo "ios_profile=$MOBILE_BUILD_PROFILE"
281 echo "ios_fingerprint=$hash"
282 echo "ios_version=$version"
283 echo "ios_sha=$(git rev-parse HEAD)"
284 } >> "$GITHUB_OUTPUT"
285
286 - id: android_manual
287 name: Queue Android build (manual)
288 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'ios'
289 working-directory: apps/mobile
290 env:
291 EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
292 run: |
293 version="$(npx expo config --json --type public | jq -r '.version')"
294 hash="$(eas fingerprint:generate --platform android --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')"
295 {
296 echo "android_build=true"
297 echo "android_fingerprint=$hash"
298 echo "android_version=$version"
299 echo "android_sha=$(git rev-parse HEAD)"
300 } >> "$GITHUB_OUTPUT"
301
302 # @expo/fingerprint swallows autolinking errors and returns no Expo module
303 # sources, so native changes stop moving the hash and OTAs reach binaries
304 # that lack them. Refuse to publish any OTA from such a fingerprint.
305 - name: Verify fingerprints hash Expo native modules
306 if: steps.expo-token.outputs.present == 'true' && (github.event_name == 'push' || inputs.mode == 'update')
307 working-directory: apps/mobile
308 env:
309 EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
310 PLATFORM: ${{ inputs.platform || 'all' }}
311 run: |
312 if [ "$PLATFORM" = "all" ]; then platforms="ios android"; else platforms="$PLATFORM"; fi
313 failed=0
314 for platform in $platforms; do
315 # eas-cli prints an environment-loaded notice to stdout before the
316 # JSON even with --json, so discard everything before the document.
317 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')"
318 if [ "${expo_modules:-0}" -eq 0 ]; then
319 failed=1
320 echo ":x: $platform: the native fingerprint hashes no Expo native modules — OTAs and builds skipped" >> "$GITHUB_STEP_SUMMARY"
321 fi
322 done
323 exit "$failed"
324
325 - name: Publish OTA update (manual)
326 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update'
327 working-directory: apps/mobile
328 env:
329 EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
330 MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
331 OTA_MESSAGE: ${{ inputs.message || format('Production OTA ({0})', github.sha) }}
332 run: |
333 if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then
334 T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')"
335 export T3CODE_MOBILE_VERSION
336 fi
337 eas update \
338 --channel production \
339 --environment production \
340 --platform ${{ inputs.platform }} \
341 --message "$OTA_MESSAGE" \
342 --emit-metadata \
343 --non-interactive
344
345 # Reconcile each version separately because the major is in the fingerprint.
346 # Production iOS only receives OTAs here; store binaries are manual releases.
347 # Failed prerelease builds are retried manually, not on every merge.
348 - id: reconcile
349 name: Publish OTA or cut store build
350 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
351 working-directory: apps/mobile
352 env:
353 EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
354 IOS_LOCAL_BUILDS: ${{ vars.MOBILE_IOS_LOCAL_BUILDS == 'true' }}
355 run: |
356 failed=0
357 message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))"
358 for target in ios:production ios:testflight android:production; do
359 platform="${target%%:*}"
360 profile="${target#*:}"
361 unset T3CODE_MOBILE_VERSION
362 if [ "$profile" = "testflight" ]; then
363 T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')"
364 export T3CODE_MOBILE_VERSION
365 fi
366 version="$(npx expo config --json --type public | jq -r '.version')"
367 # eas-cli prints an environment-loaded notice to stdout before the
368 # JSON even with --json, so discard everything before the document.
369 hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')"
370 matches="$(eas build:list --platform "$platform" --build-profile "$profile" --fingerprint-hash "$hash" --limit 20 --json --non-interactive)"
371 finished="$(jq '[.[] | select(.status == "FINISHED")] | length' <<<"$matches")"
372 active="$(jq '[.[] | select(.status == "NEW" or .status == "IN_QUEUE" or .status == "IN_PROGRESS")] | length' <<<"$matches")"
373 errored="$(jq '[.[] | select(.status == "ERRORED")] | length' <<<"$matches")"
374 latest_version="$(eas build:list --platform "$platform" --build-profile "$profile" --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')"
375 # Local builds (the android and ios jobs) record each build as a tag;
376 # EAS records still count for builds made before that.
377 tag_prefix=""
378 if [ "$platform" = "android" ]; then
379 tag_prefix="mobile-android-build"
380 elif [ "$IOS_LOCAL_BUILDS" = "true" ]; then
381 tag_prefix="mobile-ios-build/$profile"
382 fi
383 if [ -n "$tag_prefix" ]; then
384 if [ -n "$(git tag -l "$tag_prefix/*/$hash")" ]; then
385 finished=$((finished + 1))
386 fi
387 if [ -n "$(git tag -l "$tag_prefix/$version/*")" ]; then
388 latest_version="$version"
389 fi
390 fi
391
392 if [ "$finished" -gt 0 ]; then
393 if eas update \
394 --channel production \
395 --environment production \
396 --platform "$platform" \
397 --message "$message" \
398 --emit-metadata \
399 --non-interactive; then
400 echo ":white_check_mark: $platform/$profile: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
401 # The next target's export replaces dist; keep this one for the map upload.
402 rm -rf "dist-$platform-$profile" && mv dist "dist-$platform-$profile"
403 else
404 failed=1
405 echo ":x: $platform/$profile: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
406 fi
407 fi
408
409 # Never rebuild the released iOS version automatically.
410 if [ "$target" = "ios:production" ]; then
411 continue
412 fi
413
414 reason=""
415 if [ "$finished" -eq 0 ] && [ "$active" -eq 0 ]; then
416 if [ "$errored" -gt 0 ]; then
417 echo ":x: $platform/$profile: the build for fingerprint \`$hash\` errored — retry with workflow_dispatch mode=build profile=$profile platform=$platform" >> "$GITHUB_STEP_SUMMARY"
418 failed=1
419 continue
420 fi
421 reason="native fingerprint \`$hash\` has no build"
422 elif [ "$latest_version" != "$version" ] && [ "$active" -eq 0 ]; then
423 reason="latest $profile build is $latest_version, expected $version"
424 fi
425
426 if [ -z "$reason" ]; then
427 if [ "$finished" -eq 0 ]; then
428 echo ":hourglass: $platform/$profile: build for fingerprint \`$hash\` is in progress — OTA resumes on the next merge after it finishes" >> "$GITHUB_STEP_SUMMARY"
429 fi
430 continue
431 fi
432
433 echo "$platform: $reason — building"
434 if [ "$platform" = "android" ]; then
435 {
436 echo "android_build=true"
437 echo "android_fingerprint=$hash"
438 echo "android_version=$version"
439 echo "android_sha=$(git rev-parse HEAD)"
440 } >> "$GITHUB_OUTPUT"
441 echo ":building_construction: android: building and submitting $version in the Android store build job ($reason)" >> "$GITHUB_STEP_SUMMARY"
442 continue
443 fi
444 if [ "$platform" = "ios" ] && [ "$IOS_LOCAL_BUILDS" = "true" ]; then
445 {
446 echo "ios_build=true"
447 echo "ios_profile=$profile"
448 echo "ios_fingerprint=$hash"
449 echo "ios_version=$version"
450 echo "ios_sha=$(git rev-parse HEAD)"
451 } >> "$GITHUB_OUTPUT"
452 echo ":building_construction: ios/$profile: building and submitting $version in the iOS store build job ($reason)" >> "$GITHUB_STEP_SUMMARY"
453 continue
454 fi
455 if eas build --platform "$platform" --profile "$profile" --auto-submit --non-interactive --no-wait; then
456 echo ":building_construction: $platform: scheduled production build and submission for $version ($reason)" >> "$GITHUB_STEP_SUMMARY"
457 else
458 failed=1
459 echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY"
460 fi
461 done
462 exit "$failed"
463
464 # Lets the relay symbolicate crash stacks from the updates published
465 # above. A step of its own so the R2 credentials reach only this script,
466 # not `eas update` and the dependencies it runs. Never fails the job.
467 - name: Upload OTA source maps
468 if: ${{ !cancelled() && steps.expo-token.outputs.present == 'true' }}
469 working-directory: apps/mobile
470 env:
471 MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }}
472 run: |
473 for dir in dist dist-*; do
474 if [ -f "$dir/eas-update-metadata.json" ]; then
475 node scripts/upload-sourcemaps.mts ota "$dir"
476 fi
477 done
478
479 # `eas build --local` on Blacksmith, ARM-only, without release lint (see the
480 # production profile in eas.json), with Gradle, ccache, and pnpm state on a
481 # sticky disk. Its own concurrency group keeps later OTAs from waiting on it.
482 android:
483 name: Android store build
484 needs: production
485 # Skipped when the production job failed: no store build after a failed OTA.
486 if: needs.production.outputs.android_build == 'true'
487 runs-on: blacksmith-32vcpu-ubuntu-2404
488 timeout-minutes: 60
489 concurrency:
490 group: mobile-android-store-build
491 cancel-in-progress: false
492 permissions:
493 contents: write # pushes the build record tag
494 env:
495 APP_VARIANT: production
496 NODE_OPTIONS: --max-old-space-size=8192
497 EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
498 BUILD_TAG: mobile-android-build/${{ needs.production.outputs.android_version }}/${{ needs.production.outputs.android_fingerprint }}
499 steps:
500 - name: Checkout
501 uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
502 with:
503 ref: ${{ needs.production.outputs.android_sha }}
504 fetch-depth: 0
505
506 # Dependency scripts and the build run in this job; only Record build gets a token.
507 # persist-credentials: false would clean up via `git submodule foreach`, which fails on .repos gitlinks.
508 - name: Drop checkout credentials
509 run: |
510 git config --local --unset-all http.https://github.com/.extraheader || true
511 { git config --local --name-only --get-regexp '^includeif\.' || true; } | while read -r key; do
512 path=$(git config --local --get "$key")
513 git config --local --unset-all "$key"
514 case "$path" in "$RUNNER_TEMP"/*) rm -f "$path" ;; esac
515 done
516 if git config --local --get-regexp '^(http\..*\.extraheader|includeif\.)'; then
517 echo "::error::Checkout credentials are still configured"
518 exit 1
519 fi
520
521 # A queued run can follow a build of the same fingerprint; manual runs always build.
522 - id: record
523 name: Check build record
524 env:
525 FORCE: ${{ github.event_name == 'workflow_dispatch' }}
526 run: |
527 if [ "$FORCE" != "true" ] && [ -n "$(git tag -l "$BUILD_TAG")" ]; then
528 echo "built=true" >> "$GITHUB_OUTPUT"
529 echo ":white_check_mark: android: \`$BUILD_TAG\` was built by an earlier run" >> "$GITHUB_STEP_SUMMARY"
530 fi
531
532 - name: Prepare build directories
533 if: steps.record.outputs.built != 'true'
534 run: |
535 cache="$HOME/.cache/t3-android-store"
536 {
537 echo "GRADLE_USER_HOME=$cache/gradle"
538 echo "CCACHE_DIR=$cache/ccache"
539 echo "CCACHE_MAXSIZE=5G"
540 # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime.
541 echo "CCACHE_COMPILERCHECK=%compiler% -v"
542 echo "pnpm_config_store_dir=$cache/pnpm-store"
543 # eas build --local copies the project here. A fixed path keeps
544 # ccache keys stable, and the same disk as the store lets pnpm hardlink.
545 echo "EAS_LOCAL_BUILD_WORKINGDIR=$cache/eas-build"
546 echo "CCACHE_BASEDIR=$cache/eas-build"
547 # CMake (3.17+) reads these, and AGP passes Gradle's environment through.
548 echo "CMAKE_C_COMPILER_LAUNCHER=ccache"
549 echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache"
550 } >> "$GITHUB_ENV"
551
552 - name: Mount build cache
553 if: steps.record.outputs.built != 'true'
554 uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
555 with:
556 key: ${{ github.repository }}-android-store-build
557 path: ~/.cache/t3-android-store
558
559 - name: Configure Gradle and ccache
560 if: steps.record.outputs.built != 'true'
561 run: |
562 mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR"
563 rm -rf "$EAS_LOCAL_BUILD_WORKINGDIR"
564 # No daemon: nothing should hold the sticky disk open when it is committed.
565 printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties"
566 sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
567 sudo apt-get update -qq && sudo apt-get install -y -qq ccache
568 ccache --zero-stats
569
570 - name: Setup Node
571 if: steps.record.outputs.built != 'true'
572 uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
573 with:
574 node-version-file: package.json
575
576 - name: Enable corepack
577 if: steps.record.outputs.built != 'true'
578 run: |
579 npm install --global corepack@latest
580 corepack enable
581 pnpm --version
582
583 # EAS commands load app.config.ts, whose plugins must resolve. Keep install
584 # scripts: some download native assets that the build's fingerprint check hashes.
585 - name: Install mobile dependencies
586 if: steps.record.outputs.built != 'true'
587 run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile...
588
589 - name: Setup Java
590 if: steps.record.outputs.built != 'true'
591 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
592 with:
593 distribution: temurin
594 java-version: 17
595
596 - name: Setup EAS
597 if: steps.record.outputs.built != 'true'
598 uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1
599 with:
600 eas-version: latest
601 token: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
602 packager: npm
603
604 - name: Pull production environment variables
605 if: steps.record.outputs.built != 'true'
606 working-directory: apps/mobile
607 run: |
608 eas env:pull production --non-interactive
609 node --input-type=module <<'NODE'
610 import { appendFileSync, readFileSync } from "node:fs";
611 import { parseEnv } from "node:util";
612 const env = parseEnv(readFileSync(".env.local", "utf8"));
613 const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE;
614 if (!googleServicesFile) {
615 throw new Error("T3CODE_ANDROID_GOOGLE_SERVICES_FILE is missing from the production environment");
616 }
617 appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`);
618 NODE
619
620 - name: Build
621 if: steps.record.outputs.built != 'true'
622 working-directory: apps/mobile
623 env:
624 # EAS secret variables don't reach local builds; the eas-build-on-success
625 # hook needs these to upload the bundle's source map.
626 MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }}
627 run: |
628 eas build --local --platform android --profile production --non-interactive --output "$RUNNER_TEMP/t3code.aab"
629 abis="$(unzip -Z1 "$RUNNER_TEMP/t3code.aab" | sed -n 's|^base/lib/\([^/]*\)/.*|\1|p' | sort -u | paste -sd, -)"
630 {
631 echo "### Android $BUILD_TAG"
632 echo "- ABIs: \`$abis\`"
633 echo '```'
634 ccache --show-stats
635 echo '```'
636 } >> "$GITHUB_STEP_SUMMARY"
637 if [ "$abis" != "arm64-v8a,armeabi-v7a" ]; then
638 echo "::error::Expected only ARM native libraries in the AAB, found $abis"
639 exit 1
640 fi
641
642 # A --path submission doesn't keep its archive on EAS; this is the only downloadable copy.
643 - name: Keep the AAB
644 if: steps.record.outputs.built != 'true'
645 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
646 with:
647 name: android-store-aab-${{ github.run_id }}-${{ github.run_attempt }}
648 path: ${{ runner.temp }}/t3code.aab
649 retention-days: 14
650 if-no-files-found: error
651
652 - name: Submit to Google Play
653 if: steps.record.outputs.built != 'true'
654 working-directory: apps/mobile
655 run: eas submit --platform android --profile production --path "$RUNNER_TEMP/t3code.aab" --non-interactive
656
657 # The tag means "delivered", so it follows the submit. If it can't be
658 # pushed, the next merge rebuilds with a new build number; nothing is lost.
659 - name: Record build
660 if: steps.record.outputs.built != 'true'
661 env:
662 GH_TOKEN: ${{ github.token }}
663 run: |
664 sha="$(git rev-parse HEAD)"
665 # GitHub occasionally fails ref writes server-side ("fatal error in commit_refs").
666 for attempt in 1 2 3; do
667 if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$BUILD_TAG" >/dev/null 2>&1; then
668 echo "$BUILD_TAG exists."
669 break
670 fi
671 if gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$BUILD_TAG" -f sha="$sha" >/dev/null; then
672 break
673 fi
674 if [ "$attempt" = 3 ]; then
675 echo "::error::Submitted, but could not create $BUILD_TAG; the next merge will rebuild."
676 exit 1
677 fi
678 sleep $((attempt * 15))
679 done
680 echo ":rocket: android: submitted and recorded \`$BUILD_TAG\`" >> "$GITHUB_STEP_SUMMARY"
681
682 # Off unless the MOBILE_IOS_LOCAL_BUILDS repository variable is `true`; the
683 # production job only sets ios_build then. Store binaries need the final
684 # Xcode 27.1; MOBILE_IOS_ALLOW_XCODE_RC=true accepts the release candidate.
685 # No sticky disk: it mounts ext4 and only works on Linux runners.
686 ios:
687 name: iOS store build
688 needs: production
689 # Skipped when the production job failed: no store build after a failed OTA.
690 if: needs.production.outputs.ios_build == 'true'
691 runs-on: blacksmith-12vcpu-macos-27
692 timeout-minutes: 60
693 concurrency:
694 group: mobile-ios-store-build
695 cancel-in-progress: false
696 permissions:
697 contents: write # pushes the build record tag
698 env:
699 APP_VARIANT: production
700 NODE_OPTIONS: --max-old-space-size=8192
701 EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
702 PROFILE: ${{ needs.production.outputs.ios_profile }}
703 BUILD_TAG: mobile-ios-build/${{ needs.production.outputs.ios_profile }}/${{ needs.production.outputs.ios_version }}/${{ needs.production.outputs.ios_fingerprint }}
704 ALLOW_XCODE_RC: ${{ vars.MOBILE_IOS_ALLOW_XCODE_RC == 'true' }}
705 steps:
706 - name: Checkout
707 uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
708 with:
709 ref: ${{ needs.production.outputs.ios_sha }}
710 fetch-depth: 0
711
712 # Dependency scripts and the build run in this job; only Record build gets a token.
713 # persist-credentials: false would clean up via `git submodule foreach`, which fails on .repos gitlinks.
714 - name: Drop checkout credentials
715 run: |
716 git config --local --unset-all http.https://github.com/.extraheader || true
717 { git config --local --name-only --get-regexp '^includeif\.' || true; } | while read -r key; do
718 path=$(git config --local --get "$key")
719 git config --local --unset-all "$key"
720 case "$path" in "$RUNNER_TEMP"/*) rm -f "$path" ;; esac
721 done
722 if git config --local --get-regexp '^(http\..*\.extraheader|includeif\.)'; then
723 echo "::error::Checkout credentials are still configured"
724 exit 1
725 fi
726
727 # A queued run can follow a build of the same fingerprint; manual runs always build.
728 - id: record
729 name: Check build record
730 env:
731 FORCE: ${{ github.event_name == 'workflow_dispatch' }}
732 run: |
733 if [ "$FORCE" != "true" ] && [ -n "$(git tag -l "$BUILD_TAG")" ]; then
734 echo "built=true" >> "$GITHUB_OUTPUT"
735 echo ":white_check_mark: ios: \`$BUILD_TAG\` was built by an earlier run" >> "$GITHUB_STEP_SUMMARY"
736 fi
737
738 - name: Select Xcode 27.1
739 if: steps.record.outputs.built != 'true'
740 run: |
741 xcode=/Applications/Xcode_27.1.app
742 if [ ! -d "$xcode" ] && [ "$ALLOW_XCODE_RC" = "true" ]; then
743 xcode=/Applications/Xcode_27.1_Release_Candidate.app
744 fi
745 if [ ! -d "$xcode" ]; then
746 ls -d /Applications/Xcode*.app
747 echo "::error::Xcode 27.1 (final) is not on this runner. Set the MOBILE_IOS_ALLOW_XCODE_RC repository variable to true to build store binaries with the release candidate."
748 exit 1
749 fi
750 sudo xcode-select -s "$xcode/Contents/Developer"
751 xcodebuild -version
752 echo "- Xcode: \`$(basename "$xcode")\`" >> "$GITHUB_STEP_SUMMARY"
753
754 # EAS's credentials step fails to import the distribution certificate without it.
755 - name: Install Apple WWDR G3 intermediate
756 if: steps.record.outputs.built != 'true'
757 run: |
758 curl -fsSL -o "$RUNNER_TEMP/AppleWWDRCAG3.cer" https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer
759 sudo security add-certificates -k /Library/Keychains/System.keychain "$RUNNER_TEMP/AppleWWDRCAG3.cer" || true
760 security find-certificate -c "Apple Worldwide Developer Relations" /Library/Keychains/System.keychain | grep -m1 labl
761
762 - name: Setup Node
763 if: steps.record.outputs.built != 'true'
764 uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
765 with:
766 node-version-file: package.json
767
768 - name: Enable corepack
769 if: steps.record.outputs.built != 'true'
770 run: |
771 npm install --global corepack@latest
772 corepack enable
773 pnpm --version
774
775 # EAS commands load app.config.ts, whose plugins must resolve. Keep install
776 # scripts: some download native assets that the build's fingerprint check hashes.
777 - name: Install mobile dependencies
778 if: steps.record.outputs.built != 'true'
779 run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile...
780
781 - name: Setup EAS
782 if: steps.record.outputs.built != 'true'
783 uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1
784 with:
785 eas-version: latest
786 token: ${{ secrets.EXPO_PRODUCTION_TOKEN }}
787 packager: npm
788
789 - name: Build tools
790 if: steps.record.outputs.built != 'true'
791 run: |
792 command -v pod || sudo gem install cocoapods --no-document
793 command -v fastlane || brew install fastlane
794 pod --version
795 fastlane --version | tail -1
796
797 - name: Pull production environment variables
798 if: steps.record.outputs.built != 'true'
799 working-directory: apps/mobile
800 run: eas env:pull production --non-interactive
801
802 - name: Build
803 if: steps.record.outputs.built != 'true'
804 working-directory: apps/mobile
805 env:
806 # EAS secret variables don't reach local builds; the eas-build-on-success
807 # hook needs these to upload the bundle's source map.
808 MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }}
809 run: |
810 # A real path: /var -> /private/var symlinks break the expo-widgets bundle step.
811 export EAS_LOCAL_BUILD_WORKINGDIR="$RUNNER_TEMP/eas-build"
812 eas build --local --platform ios --profile "$PROFILE" --non-interactive --output "$RUNNER_TEMP/t3code.ipa"
813
814 - name: Submit to App Store Connect
815 if: steps.record.outputs.built != 'true'
816 working-directory: apps/mobile
817 run: eas submit --platform ios --profile "$PROFILE" --path "$RUNNER_TEMP/t3code.ipa" --non-interactive
818
819 # The tag means "delivered", so it follows the submit. If it can't be
820 # pushed, the next merge rebuilds with a new build number; nothing is lost.
821 - name: Record build
822 if: steps.record.outputs.built != 'true'
823 env:
824 GH_TOKEN: ${{ github.token }}
825 run: |
826 sha="$(git rev-parse HEAD)"
827 # GitHub occasionally fails ref writes server-side ("fatal error in commit_refs").
828 for attempt in 1 2 3; do
829 if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$BUILD_TAG" >/dev/null 2>&1; then
830 echo "$BUILD_TAG exists."
831 break
832 fi
833 if gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$BUILD_TAG" -f sha="$sha" >/dev/null; then
834 break
835 fi
836 if [ "$attempt" = 3 ]; then
837 echo "::error::Submitted, but could not create $BUILD_TAG; the next merge will rebuild."
838 exit 1
839 fi
840 sleep $((attempt * 15))
841 done
842 echo ":rocket: ios: submitted and recorded \`$BUILD_TAG\`" >> "$GITHUB_STEP_SUMMARY"