diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml new file mode 100644 index 00000000000..77d3bff06e5 --- /dev/null +++ b/.github/workflows/mobile-eas-preview.yml @@ -0,0 +1,78 @@ +name: Mobile EAS Preview + +on: + pull_request: + +jobs: + preview: + name: EAS Preview + runs-on: blacksmith-8vcpu-ubuntu-2404 + permissions: + contents: read + pull-requests: write + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + steps: + - id: expo-token + name: Check for EXPO_TOKEN + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + if [ -n "$EXPO_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "EXPO_TOKEN is not available; skipping EAS preview." + fi + + - name: Checkout + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Vite+ + if: steps.expo-token.outputs.present == 'true' + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + if: steps.expo-token.outputs.present == 'true' + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup EAS + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + packager: pnpm + + - name: Pull preview environment variables + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: eas env:pull preview --non-interactive + + - name: Deploy with fingerprint check + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action/continuous-deploy-fingerprint@main + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + with: + profile: preview:dev + branch: pr-${{ github.event.pull_request.number }} + platform: all + environment: preview + working-directory: apps/mobile + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/mobile/.fingerprintignore b/apps/mobile/.fingerprintignore new file mode 100644 index 00000000000..7f04b98cabe --- /dev/null +++ b/apps/mobile/.fingerprintignore @@ -0,0 +1,24 @@ +# Expo artifacts +.expo/ +.eas/ + +# Build outputs +android/ +ios/ +dist/ +web-build/ + +# Test files +**/*.test.ts +**/*.test.tsx + +# Docs +*.md + +# Local env +.env +.env.local +.env*.local + +# TypeScript +*.tsbuildinfo diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 44639e74ccf..87e144f1735 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -61,6 +61,14 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. ## EAS Builds +CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy. + +Create a PR preview dev-client build manually: + +```bash +vp run eas:ios:preview:dev +``` + Create a cloud dev-client build: ```bash @@ -77,5 +85,6 @@ Android equivalents: ```bash vp run eas:android:dev +vp run eas:android:preview:dev vp run eas:android:preview ``` diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 5c46d01bfd8..378ca1964a7 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -53,10 +53,11 @@ const variant = VARIANT_CONFIG[APP_VARIANT]; const config: ExpoConfig = { name: variant.appName, slug: "t3-code", + platforms: ["ios", "android"], scheme: variant.scheme, version: "0.1.0", runtimeVersion: { - policy: "appVersion", + policy: process.env.MOBILE_VERSION_POLICY ?? "appVersion", }, orientation: "portrait", icon: "./assets/icon.png", diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index cb0075613b8..4e6b55a4223 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -17,13 +17,28 @@ "APP_VARIANT": "preview" }, "channel": "preview", + "environment": "preview", "distribution": "internal" }, + "preview:dev": { + "env": { + "APP_VARIANT": "preview", + "MOBILE_VERSION_POLICY": "fingerprint" + }, + "channel": "preview", + "environment": "preview", + "developmentClient": true, + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, "production": { "env": { "APP_VARIANT": "production" }, "channel": "production", + "environment": "production", "autoIncrement": true } }, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d2dec20e521..6f48f529c87 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -16,6 +16,7 @@ "android:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "eas:android:dev": "eas build --profile development -p android", "eas:android:preview": "eas build --profile preview -p android", + "eas:android:preview:dev": "eas build --profile preview:dev -p android", "eas:android:prod": "eas build --profile production -p android", "ios": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", "ios:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", @@ -23,9 +24,11 @@ "ios:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", "eas:ios:dev": "eas build --profile development -p ios", "eas:ios:preview": "eas build --profile preview -p ios", + "eas:ios:preview:dev": "eas build --profile preview:dev -p ios", "eas:ios:prod": "eas build --profile production -p ios", "eas:dev": "eas build --profile development", "eas:preview": "eas build --profile preview", + "eas:preview:dev": "eas build --profile preview:dev", "eas:prod": "eas build --profile production", "config:dev": "APP_VARIANT=development expo config", "config:preview": "APP_VARIANT=preview expo config", diff --git a/package.json b/package.json index 9b03728f3eb..81ca5a2a77f 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "sync:repos": "node scripts/sync-reference-repos.ts" }, "devDependencies": { + "@babel/plugin-transform-react-jsx": "7.28.6", "@effect/tsgo": "catalog:", "@oxlint/plugins": "^1.63.0", "@types/node": "catalog:", @@ -99,6 +100,7 @@ ] }, "patchedDependencies": { + "@expo/metro-config@56.0.13": "patches/@expo%2Fmetro-config@56.0.13.patch", "@pierre/diffs@1.1.20@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch", "effect@4.0.0-beta.73@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch", "react-native-nitro-modules@0.35.9@0.35.9": "patches/react-native-nitro-modules@0.35.9.patch" diff --git a/patches/@expo%2Fmetro-config@56.0.13.patch b/patches/@expo%2Fmetro-config@56.0.13.patch new file mode 100644 index 00000000000..c25173730c8 --- /dev/null +++ b/patches/@expo%2Fmetro-config@56.0.13.patch @@ -0,0 +1,93 @@ +diff --git a/build/serializer/sourceMap.js b/build/serializer/sourceMap.js +index 4cc9aa4..703dfe0 100644 +--- a/build/serializer/sourceMap.js ++++ b/build/serializer/sourceMap.js +@@ -20,6 +20,20 @@ function loadRemapping() { + } + return _remapping; + } ++let _sourceMapCodec; ++function loadSourceMapCodec() { ++ if (!_sourceMapCodec) { ++ _sourceMapCodec = require('@jridgewell/sourcemap-codec'); ++ } ++ return _sourceMapCodec; ++} ++let _traceMapping; ++function loadTraceMapping() { ++ if (!_traceMapping) { ++ _traceMapping = require('@jridgewell/trace-mapping'); ++ } ++ return _traceMapping; ++} + let _Generator; + function loadGenerator() { + if (!_Generator) { +@@ -199,6 +213,58 @@ function patchMetroSourceMapStringForPackedMaps() { + stock.sourceMapString = sourceMapString; + stock.sourceMapStringNonBlocking = sourceMapStringNonBlocking; + } ++// Hermes can emit mappings for Metro trailer/debug lines that have no ++// corresponding Metro source-map line. @jridgewell/remapping assumes every ++// referenced generated line exists, so strip those invalid bridge segments. ++function getGeneratedLineCount(map) { ++ const { TraceMap, decodedMappings } = loadTraceMapping(); ++ return decodedMappings(new TraceMap(map)).length; ++} ++function dropInvalidOriginalSegments(map, maxOriginalLine) { ++ const { TraceMap, decodedMappings } = loadTraceMapping(); ++ const decoded = decodedMappings(new TraceMap(map)); ++ let didChange = false; ++ const filtered = decoded.map((line) => { ++ const filteredLine = line.filter((segment) => { ++ if (segment.length < 4) { ++ return true; ++ } ++ const sourceIndex = segment[1]; ++ const sourceLine = segment[2]; ++ const sourceColumn = segment[3]; ++ return (sourceIndex >= 0 && ++ sourceIndex < map.sources.length && ++ sourceLine >= 0 && ++ sourceLine < maxOriginalLine && ++ sourceColumn >= 0); ++ }); ++ if (filteredLine.length !== line.length) { ++ didChange = true; ++ } ++ return filteredLine; ++ }); ++ if (!didChange) { ++ return map; ++ } ++ return { ++ ...map, ++ mappings: loadSourceMapCodec().encode(filtered), ++ }; ++} ++function sanitizeSourceMapsForComposition(maps) { ++ let sanitized = maps; ++ for (let index = 1; index < maps.length; index++) { ++ const maxOriginalLine = getGeneratedLineCount(sanitized[index - 1]); ++ const next = dropInvalidOriginalSegments(sanitized[index], maxOriginalLine); ++ if (next !== sanitized[index]) { ++ if (sanitized === maps) { ++ sanitized = maps.slice(); ++ } ++ sanitized[index] = next; ++ } ++ } ++ return sanitized; ++} + // `maps[0]` is the original-most transform; `maps[maps.length - 1]` is + // the most recent. Built on `@jridgewell/remapping` instead of mozilla's + // `SourceMapConsumer`-based composer. +@@ -226,7 +292,7 @@ function composeSourceMaps(maps) { + return { ...map, ignoreList: map.x_google_ignoreList }; + }); + // Metro convention is original-first; remapping is most-recent first. +- const reversed = normalized.slice().reverse(); ++ const reversed = sanitizeSourceMapsForComposition(normalized).slice().reverse(); + const composed = loadRemapping()(reversed, () => null); + // Re-emit as a plain object — remapping returns a `SourceMap` class + // instance, which doesn't round-trip JSON cleanly. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4bab3509ea..bbdbeb076ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ overrides: packageExtensionsChecksum: sha256-MDpMSm2Rk8Y7FDIbaAgFkT45PZBPV7JBzjTAft+noEM= patchedDependencies: + '@expo/metro-config@56.0.13': + hash: 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 + path: patches/@expo%2Fmetro-config@56.0.13.patch '@pierre/diffs@1.1.20': hash: e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0 path: patches/@pierre%2Fdiffs@1.1.20.patch @@ -63,6 +66,9 @@ importers: .: devDependencies: + '@babel/plugin-transform-react-jsx': + specifier: 7.28.6 + version: 7.28.6(@babel/core@7.29.7) '@effect/tsgo': specifier: 'catalog:' version: 0.11.4 @@ -1223,6 +1229,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx@7.29.7': resolution: {integrity: sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==} engines: {node: '>=6.9.0'} @@ -8196,6 +8208,17 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8607,7 +8630,7 @@ snapshots: '@expo/json-file': 10.2.0 '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) '@expo/metro': 56.0.0 - '@expo/metro-config': 56.0.13(expo@56.0.8)(typescript@6.0.3) + '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(expo@56.0.8)(typescript@6.0.3) '@expo/metro-file-map': 56.0.3 '@expo/osascript': 2.6.0 '@expo/package-manager': 1.12.1 @@ -8800,7 +8823,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) stacktrace-parser: 0.1.11 - '@expo/metro-config@56.0.13(expo@56.0.8)(typescript@6.0.3)': + '@expo/metro-config@56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(expo@56.0.8)(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -9831,7 +9854,7 @@ snapshots: '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) @@ -11092,7 +11115,7 @@ snapshots: '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) @@ -11959,7 +11982,7 @@ snapshots: '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) '@expo/metro': 56.0.0 - '@expo/metro-config': 56.0.13(expo@56.0.8)(typescript@6.0.3) + '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(expo@56.0.8)(typescript@6.0.3) '@ungap/structured-clone': 1.3.1 babel-preset-expo: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@56.0.8)(react-refresh@0.14.2) expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0db3d4d8293..0a680160864 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -57,6 +57,7 @@ packageExtensions: dependencies: "@vitest/runner": "catalog:" patchedDependencies: + "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch "@pierre/diffs@1.1.20": patches/@pierre%2Fdiffs@1.1.20.patch effect@4.0.0-beta.73: patches/effect@4.0.0-beta.73.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch