From ac28501ba0253a4a38b5221f6e255cf5cc183e5e Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:15:18 +0200 Subject: [PATCH] fix(mobile): ship EAS OTA when Free plan build quota is exhausted Native `eas build` was failing silently after the Expo Free plan monthly iOS quota ran out, so fingerprint-continuous-deploy never published OTAs to the installed TestFlight binary. Replacing the silent action with a logged deploy script that falls back to the latest finished binary runtime, plus a runtime-version override for pure-JS emergency ships. --- .easignore | 56 +++++ .github/workflows/mobile-eas-development.yml | 46 +++- .github/workflows/mobile-eas-production.yml | 104 ++++++-- apps/mobile/app.config.ts | 20 +- apps/mobile/scripts/eas-continuous-deploy.sh | 236 +++++++++++++++++++ 5 files changed, 425 insertions(+), 37 deletions(-) create mode 100644 .easignore create mode 100755 apps/mobile/scripts/eas-continuous-deploy.sh diff --git a/.easignore b/.easignore new file mode 100644 index 00000000000..075b9d74737 --- /dev/null +++ b/.easignore @@ -0,0 +1,56 @@ +# Keep EAS project archives small. Mobile only needs the monorepo packages it +# resolves through pnpm — not vendored agent reference trees or desktop shells. + +# Vendored reference repos (agents only; never imported by app code) +.repos/ + +# Desktop / electron / native host tooling +apps/desktop/ +apps/vscode/ +apps/discord-bot/ +native/ +release/ +release-mock/ +dist-electron/ +.electron-runtime/ + +# Infra, docs, agent metadata +docs/ +.agents/ +.grok/ +.github/ +.claude/ +.cursor/ +.vscode/ +infra/ +artifacts/ +.showcase/ +apps/mobile/.showcase/ + +# Tests and snapshots (not needed for EAS bundle) +**/__screenshots__/ +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx +**/test-results/ +**/playwright-report/ +**/.vitest-*/ + +# Local / generated +node_modules/ +.t3/ +.turbo/ +.alchemy/ +.dir-deploy-n +.logs/ +*.log +.DS_Store +.env +.env.* +!.env.example + +# Git / IDE noise +.git/ +.idea/ +*.tsbuildinfo diff --git a/.github/workflows/mobile-eas-development.yml b/.github/workflows/mobile-eas-development.yml index cc5e9c4a4ad..80d786babce 100644 --- a/.github/workflows/mobile-eas-development.yml +++ b/.github/workflows/mobile-eas-development.yml @@ -3,6 +3,7 @@ name: Mobile EAS Development # Keep the installable development client current without rebuilding it for # JavaScript-only changes. Expo Fingerprint reuses a compatible native build # and publishes an OTA update; native changes produce a new internal build. +# Free-plan build failures fall back to OTA against the latest finished binary. on: workflow_dispatch: inputs: @@ -15,6 +16,10 @@ on: - ios - android - all + runtime_version: + description: "Force OTA runtimeVersion (blank = fingerprint / fallback)" + required: false + type: string sha: description: "Exact fork/integration SHA (blank uses its current tip)" required: false @@ -73,6 +78,24 @@ jobs: git merge-base --is-ancestor "$target_sha" "$integration_sha" git checkout --detach "$target_sha" test "$(git rev-parse HEAD)" = "$target_sha" + echo "sha=$target_sha" >> "$GITHUB_OUTPUT" + + - name: Overlay deploy tooling from workflow ref + if: steps.expo-token.outputs.present == 'true' && github.ref_name != 'fork/integration' + env: + WORKFLOW_SHA: ${{ github.sha }} + run: | + git fetch origin "$WORKFLOW_SHA" + for path in \ + apps/mobile/scripts/eas-continuous-deploy.sh \ + apps/mobile/app.config.ts \ + .easignore; do + if git cat-file -e "${WORKFLOW_SHA}:${path}" 2>/dev/null; then + git checkout "$WORKFLOW_SHA" -- "$path" + echo "overlaid $path from $WORKFLOW_SHA" + fi + done + chmod +x apps/mobile/scripts/eas-continuous-deploy.sh - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' @@ -107,13 +130,20 @@ jobs: - name: Publish compatible update or development build if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action/continuous-deploy-fingerprint@main + working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - with: - profile: development - branch: development - platform: ${{ inputs.platform }} - environment: development - working-directory: apps/mobile - github-token: ${{ secrets.GITHUB_TOKEN }} + GITHUB_SHA: ${{ github.sha }} + run: | + chmod +x scripts/eas-continuous-deploy.sh + args=( + --profile development + --channel development + --environment development + --platform "${{ inputs.platform }}" + --message "Development auto (${{ github.sha }})" + ) + if [ -n "${{ inputs.runtime_version }}" ]; then + args+=(--runtime-version "${{ inputs.runtime_version }}") + fi + ./scripts/eas-continuous-deploy.sh "${args[@]}" diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 560c5b62e1c..0325981d4da 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -5,6 +5,11 @@ name: Mobile EAS Production # in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. +# +# mode=auto uses apps/mobile/scripts/eas-continuous-deploy.sh (full logs). When a +# native rebuild is needed but blocked (e.g. Expo Free plan monthly iOS quota), +# it falls back to OTA against the latest finished production binary runtime so +# pure-JS fixes still reach installed TestFlight clients. on: workflow_dispatch: inputs: @@ -27,7 +32,11 @@ on: - android - all message: - description: "OTA update message (mode=update only)" + description: "OTA update message (mode=update / auto)" + required: false + type: string + runtime_version: + description: "Force OTA runtimeVersion (mode=update or auto). Blank = fingerprint / fallback" required: false type: string sha: @@ -94,6 +103,27 @@ jobs: test "$(git rev-parse HEAD)" = "$target_sha" echo "sha=$target_sha" >> "$GITHUB_OUTPUT" + # Workflow file may come from a feature branch (workflow_dispatch --ref) + # while product code is always the approved integration SHA above. Overlay + # the deploy script + app.config runtime override from the workflow ref so + # we can ship fixes without waiting for a full compose. + - name: Overlay deploy tooling from workflow ref + if: steps.expo-token.outputs.present == 'true' && github.ref_name != 'fork/integration' + env: + WORKFLOW_SHA: ${{ github.sha }} + run: | + git fetch origin "$WORKFLOW_SHA" + for path in \ + apps/mobile/scripts/eas-continuous-deploy.sh \ + apps/mobile/app.config.ts \ + .easignore; do + if git cat-file -e "${WORKFLOW_SHA}:${path}" 2>/dev/null; then + git checkout "$WORKFLOW_SHA" -- "$path" + echo "overlaid $path from $WORKFLOW_SHA" + fi + done + chmod +x apps/mobile/scripts/eas-continuous-deploy.sh + - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' uses: voidzero-dev/setup-vp@v1 @@ -130,40 +160,70 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas env:pull production --non-interactive - # Fingerprint decides: a JavaScript-only integration publishes an OTA - # update to the production channel, which the installed TestFlight build - # picks up on next launch. A change to native runtime inputs starts a - # production build and submits it to TestFlight instead. - - name: Deploy with fingerprint check + - name: Deploy with fingerprint check (auto) if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'auto' - uses: expo/expo-github-action/continuous-deploy-fingerprint@main + working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - with: - profile: production - branch: production - platform: ${{ inputs.platform }} - environment: production - auto-submit-builds: true - working-directory: apps/mobile - github-token: ${{ secrets.GITHUB_TOKEN }} + GITHUB_SHA: ${{ steps.source.outputs.sha }} + run: | + chmod +x scripts/eas-continuous-deploy.sh + args=( + --profile production + --channel production + --environment production + --platform "${{ inputs.platform }}" + --auto-submit + --message "${{ inputs.message || format('Production auto ({0})', steps.source.outputs.sha) }}" + ) + if [ -n "${{ inputs.runtime_version }}" ]; then + args+=(--runtime-version "${{ inputs.runtime_version }}") + fi + ./scripts/eas-continuous-deploy.sh "${args[@]}" - name: Build and submit if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait + run: | + set -o pipefail + eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait 2>&1 | tee /tmp/eas-build.log + if grep -q 'used its iOS builds from the Free plan' /tmp/eas-build.log; then + echo "::error::Expo Free plan iOS build quota exhausted. Upgrade at https://expo.dev/accounts/patroza/settings/billing or wait for the monthly reset, then re-run mode=build. Pure-JS fixes can still ship via mode=auto (fallback OTA) or mode=update with runtime_version." + fi - name: Publish OTA update if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + GITHUB_SHA: ${{ steps.source.outputs.sha }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} run: | - eas update \ - --channel production \ - --environment production \ - --platform ${{ inputs.platform }} \ - --message "${{ inputs.message || format('Production OTA ({0})', steps.source.outputs.sha) }}" \ - --non-interactive + chmod +x scripts/eas-continuous-deploy.sh + args=( + --profile production + --channel production + --environment production + --platform "${{ inputs.platform }}" + --message "${{ inputs.message || format('Production OTA ({0})', steps.source.outputs.sha) }}" + ) + if [ -n "$RUNTIME_VERSION" ]; then + args+=(--runtime-version "$RUNTIME_VERSION") + else + # mode=update without runtime: publish under fingerprint policy only + # (no native rebuild attempt). Use --runtime-version to target an + # installed binary when fingerprints drifted. + : + fi + if [ -n "$RUNTIME_VERSION" ]; then + ./scripts/eas-continuous-deploy.sh "${args[@]}" + else + eas update \ + --channel production \ + --environment production \ + --platform ${{ inputs.platform }} \ + --message "${{ inputs.message || format('Production OTA ({0})', steps.source.outputs.sha) }}" \ + --non-interactive + fi diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index fbce5e05418..c7308e65563 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -176,13 +176,19 @@ const config: ExpoConfig = { platforms: ["ios", "android"], scheme: variant.scheme, version: "1.0.1", - runtimeVersion: { - // Fingerprint (not appVersion) so an OTA only reaches binaries whose native - // project — native deps, config plugins, AND patches/ — matches the update. - // With appVersion, every 0.1.0 build shares a runtime version, so a JS update - // could land on a binary missing the native changes it needs and crash. - policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint", - }, + // Default: fingerprint policy so OTAs only reach binaries with matching native + // inputs (deps, config plugins, patches). Override with MOBILE_RUNTIME_VERSION_OVERRIDE + // when CI must ship pure-JS fixes to an already-installed binary (e.g. Free-plan + // build quota exhausted, or a fingerprint drift without a finished native build). + runtimeVersion: (() => { + const override = process.env.MOBILE_RUNTIME_VERSION_OVERRIDE?.trim(); + if (override) { + return override; + } + return { + policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint", + }; + })(), orientation: "portrait", icon: variant.assets.appIcon, userInterfaceStyle: "automatic", diff --git a/apps/mobile/scripts/eas-continuous-deploy.sh b/apps/mobile/scripts/eas-continuous-deploy.sh new file mode 100755 index 00000000000..bbb1367377c --- /dev/null +++ b/apps/mobile/scripts/eas-continuous-deploy.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# Continuous deploy for fingerprint runtime policy with full logging. +# +# For each platform: +# 1. Compute fingerprint +# 2. If a finished/in-progress build exists for that runtime → OTA only +# 3. Else try `eas build --no-wait` (+ optional --auto-submit) +# 4. Always publish an OTA for the current fingerprint after a successful +# build queue so the binary picks it up when it finishes +# 5. If build fails (e.g. Expo Free plan monthly iOS quota), fall back to OTA +# against the latest *finished* production binary's runtime so pure-JS +# fixes still reach installed TestFlight clients +# +# Usage (from apps/mobile): +# ./scripts/eas-continuous-deploy.sh \ +# --profile production \ +# --channel production \ +# --environment production \ +# --platform ios \ +# [--auto-submit] \ +# [--message "..."] \ +# [--runtime-version HASH] # force OTA-only to this runtime (skip build) + +set -euo pipefail + +PROFILE="" +CHANNEL="" +ENVIRONMENT="" +PLATFORM="ios" +AUTO_SUBMIT=0 +MESSAGE="" +FORCE_RUNTIME="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) PROFILE="${2:-}"; shift 2 ;; + --channel) CHANNEL="${2:-}"; shift 2 ;; + --environment) ENVIRONMENT="${2:-}"; shift 2 ;; + --platform) PLATFORM="${2:-}"; shift 2 ;; + --auto-submit) AUTO_SUBMIT=1; shift ;; + --message) MESSAGE="${2:-}"; shift 2 ;; + --runtime-version) FORCE_RUNTIME="${2:-}"; shift 2 ;; + -h | --help) + sed -n '2,25p' "$0" + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$PROFILE" || -z "$CHANNEL" ]]; then + echo "--profile and --channel are required" >&2 + exit 1 +fi + +if [[ ! "$PLATFORM" =~ ^(ios|android|all)$ ]]; then + echo "--platform must be ios, android, or all" >&2 + exit 1 +fi + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +if [[ -z "$MESSAGE" ]]; then + MESSAGE="EAS continuous deploy $(date -u +%Y-%m-%dT%H:%MZ) ${GITHUB_SHA:-local}" +fi + +platforms=() +if [[ "$PLATFORM" == "all" ]]; then + platforms=(ios android) +else + platforms=("$PLATFORM") +fi + +generate_fingerprint() { + local platform="$1" + local args=(fingerprint:generate --platform "$platform" --json --non-interactive) + if [[ -n "$ENVIRONMENT" ]]; then + args+=(--environment "$ENVIRONMENT") + else + args+=(--build-profile "$PROFILE") + fi + # JSON on stdout; progress on stderr + eas "${args[@]}" | node -e ' + let s = ""; + process.stdin.on("data", (c) => (s += c)); + process.stdin.on("end", () => { + const j = JSON.parse(s); + const hash = j.hash || j.fingerprintHash; + if (!hash) { + console.error("fingerprint:generate returned no hash:", s.slice(0, 500)); + process.exit(1); + } + process.stdout.write(String(hash)); + }); + ' +} + +find_matching_build() { + local platform="$1" + local runtime="$2" + eas build:list \ + --platform "$platform" \ + --build-profile "$PROFILE" \ + --runtime-version "$runtime" \ + --limit 5 \ + --json \ + --non-interactive \ + | node -e ' + let s = ""; + process.stdin.on("data", (c) => (s += c)); + process.stdin.on("end", () => { + const builds = JSON.parse(s || "[]"); + const ok = new Set(["NEW", "IN_QUEUE", "IN_PROGRESS", "FINISHED"]); + const b = builds.find((x) => ok.has(String(x.status || "").toUpperCase().replace(/-/g, "_"))); + if (b && b.id) process.stdout.write(String(b.id)); + }); + ' +} + +latest_finished_runtime() { + local platform="$1" + eas build:list \ + --platform "$platform" \ + --build-profile "$PROFILE" \ + --status finished \ + --limit 1 \ + --json \ + --non-interactive \ + | node -e ' + let s = ""; + process.stdin.on("data", (c) => (s += c)); + process.stdin.on("end", () => { + const builds = JSON.parse(s || "[]"); + const b = builds[0]; + if (b && b.runtimeVersion) process.stdout.write(String(b.runtimeVersion)); + }); + ' +} + +publish_update() { + local platform="$1" + local runtime_override="${2:-}" + local msg="$3" + local env_args=() + if [[ -n "$ENVIRONMENT" ]]; then + env_args+=(--environment "$ENVIRONMENT") + fi + if [[ -n "$runtime_override" ]]; then + echo "Publishing OTA for platform=$platform runtimeVersion=$runtime_override (override)" + MOBILE_RUNTIME_VERSION_OVERRIDE="$runtime_override" eas update \ + --channel "$CHANNEL" \ + --platform "$platform" \ + --message "$msg" \ + --non-interactive \ + "${env_args[@]}" + else + echo "Publishing OTA for platform=$platform (fingerprint policy)" + unset MOBILE_RUNTIME_VERSION_OVERRIDE || true + eas update \ + --channel "$CHANNEL" \ + --platform "$platform" \ + --message "$msg" \ + --non-interactive \ + "${env_args[@]}" + fi +} + +try_build() { + local platform="$1" + local args=( + build + --platform "$platform" + --profile "$PROFILE" + --non-interactive + --no-wait + ) + if [[ "$AUTO_SUBMIT" -eq 1 ]]; then + args+=(--auto-submit) + fi + echo "Starting eas build: ${args[*]}" + # Capture output so Free-plan / credential errors are visible in CI logs + if eas "${args[@]}"; then + return 0 + fi + return 1 +} + +for platform in "${platforms[@]}"; do + echo "==== platform: $platform ====" + + if [[ -n "$FORCE_RUNTIME" ]]; then + publish_update "$platform" "$FORCE_RUNTIME" "$MESSAGE (forced runtime $FORCE_RUNTIME)" + continue + fi + + echo "Generating fingerprint..." + fingerprint="$(generate_fingerprint "$platform")" + echo "$platform fingerprint: $fingerprint" + + echo "Looking for builds with runtimeVersion=$fingerprint ..." + build_id="$(find_matching_build "$platform" "$fingerprint" || true)" + if [[ -n "$build_id" ]]; then + echo "Existing build found: $build_id — publishing OTA only" + publish_update "$platform" "" "$MESSAGE" + continue + fi + + echo "No matching build for fingerprint $fingerprint — attempting native build" + if try_build "$platform"; then + echo "Native build queued; publishing OTA for current fingerprint so it is ready when the binary finishes" + publish_update "$platform" "" "$MESSAGE" + continue + fi + + echo "Native build failed (often Expo Free plan monthly iOS quota or credentials)." + echo "Falling back to OTA against the latest finished $PROFILE $platform binary runtime." + fallback="$(latest_finished_runtime "$platform" || true)" + if [[ -z "$fallback" ]]; then + echo "ERROR: no finished $PROFILE $platform builds found to target for fallback OTA" >&2 + exit 1 + fi + if [[ "$fallback" == "$fingerprint" ]]; then + echo "Latest finished runtime equals current fingerprint but no matching build list hit; publishing with fingerprint policy" + publish_update "$platform" "" "$MESSAGE (fallback)" + else + echo "Fallback runtimeVersion: $fallback (installed clients will receive pure-JS fixes)" + echo "NOTE: native-input changes in this tip will not land until a new binary is built for $fingerprint" + publish_update "$platform" "$fallback" "$MESSAGE (fallback runtime $fallback; tip fingerprint $fingerprint)" + fi +done + +echo "eas-continuous-deploy finished successfully"