From 8cf8d1f6cb39a098649598a47bf30e5a66a9224e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 25 Oct 2026 20:54:19 +0100 Subject: [PATCH 1/4] ci(e2e): key playground app caches on native fingerprints PoC: compute @expo/fingerprint-based hashes once, build each app once per fingerprint, key Pods/ exactly on CocoaPods inputs, and seed caches on main. --- .github/workflows/e2e-tests.yml | 340 +++++++++++++++++++----------- package.json | 1 + pnpm-lock.yaml | 83 ++++++-- scripts/ci/native-fingerprint.mjs | 202 ++++++++++++++++++ 4 files changed, 488 insertions(+), 138 deletions(-) create mode 100644 scripts/ci/native-fingerprint.mjs diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index e4cc17f0..4284384f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -18,17 +18,214 @@ on: types: [ready_for_review] branches: - main + # Builds the apps on main so pull requests, which can only read caches from + # their own ref and the default branch, start from a cached app. + push: + branches: + - main permissions: contents: read pull-requests: read +# Toolchain settings. scripts/ci/native-fingerprint.mjs hashes them into the +# app cache keys, so a change here builds new apps. +env: + XCODE_VERSION: '26.4.1' + # The macOS runners are Apple silicon, so only arm64 is needed. + IOS_ARCHS: arm64 + JAVA_VERSION: '17' + # The CI emulator is x86_64, so skip compiling native code for the other + # ABIs listed in gradle.properties. + ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64 + jobs: + fingerprint: + name: Native fingerprints + runs-on: ubuntu-22.04 + timeout-minutes: 10 + if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android' || github.event.inputs.platform == 'ios' }} + outputs: + android: ${{ steps.fingerprint.outputs.android }} + ios: ${{ steps.fingerprint.outputs.ios }} + ios-pods: ${{ steps.fingerprint.outputs.ios-pods }} + android-hit: ${{ steps.android-lookup.outputs.cache-hit }} + ios-hit: ${{ steps.ios-lookup.outputs.cache-hit }} + steps: + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + + - name: Setup Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: '24.10.0' + cache: 'pnpm' + + - name: Install dependencies + run: | + pnpm install + + - name: Compute fingerprints + id: fingerprint + run: | + for target in android ios ios-pods; do + echo "::group::$target" + hash="$(node scripts/ci/native-fingerprint.mjs apps/playground "$target" --debug)" + echo "::endgroup::" + echo "$target: $hash" + echo "$target=$hash" >> "$GITHUB_OUTPUT" + done + + - name: Look up Android app + id: android-lookup + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk + key: apk-playground-${{ steps.fingerprint.outputs.android }} + lookup-only: true + + - name: Look up iOS app + id: ios-lookup + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + key: ios-app-playground-${{ steps.fingerprint.outputs.ios }} + lookup-only: true + + build-android: + name: Build Android app + needs: fingerprint + runs-on: ubuntu-22.04 + timeout-minutes: 30 + if: ${{ needs.fingerprint.outputs.android-hit != 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') }} + steps: + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + + - name: Setup Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: '24.10.0' + cache: 'pnpm' + + - name: Install dependencies + run: | + pnpm install + + - name: Set up JDK + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + + - name: Build Android app + working-directory: apps/playground + run: | + pnpm nx run @react-native-harness/playground:build-android --tasks=assembleDebug + + - name: Save APK to cache + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk + key: apk-playground-${{ needs.fingerprint.outputs.android }} + + build-ios: + name: Build iOS app + needs: fingerprint + runs-on: macos-latest + timeout-minutes: 30 + if: ${{ needs.fingerprint.outputs.ios-hit != 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} + steps: + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + + - name: Setup Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: '24.10.0' + cache: 'pnpm' + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Install dependencies + run: | + pnpm install + + # The keys come from the Linux fingerprint job; recomputing them here + # catches a fingerprint that differs between the two runners. + - name: Verify fingerprints + run: | + for target in ios ios-pods; do + case "$target" in + ios) expected='${{ needs.fingerprint.outputs.ios }}' ;; + ios-pods) expected='${{ needs.fingerprint.outputs.ios-pods }}' ;; + esac + actual="$(node scripts/ci/native-fingerprint.mjs apps/playground "$target" --debug)" + if [ "$actual" != "$expected" ]; then + echo "::warning::$target fingerprint on macOS ($actual) differs from Linux ($expected)" + else + echo "$target fingerprint matches: $actual" + fi + done + + # Pods/ is keyed exactly on the CocoaPods inputs: restoring a sandbox + # generated from another Podfile.lock can fail `pod install`. The + # download cache holds only versioned pod sources, so any recent entry + # is safe to reuse. + - name: Restore Pods + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ./apps/playground/ios/Pods + key: playground-pods-${{ needs.fingerprint.outputs.ios-pods }} + + - name: Restore CocoaPods download cache + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/Library/Caches/CocoaPods + key: cocoapods-downloads-${{ hashFiles('./apps/playground/ios/Podfile.lock') }} + restore-keys: | + cocoapods-downloads- + + - name: Install CocoaPods + working-directory: apps/playground/ios + run: | + pod install + + - name: Build iOS app + working-directory: apps/playground + run: | + pnpm react-native build-ios --buildFolder ./build --verbose --extra-params "ARCHS=$IOS_ARCHS" + + - name: Save app to cache + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + key: ios-app-playground-${{ needs.fingerprint.outputs.ios }} + e2e-android: name: E2E Android runs-on: ubuntu-22.04 timeout-minutes: 30 - if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main') || (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android')) }} + needs: [fingerprint, build-android] + if: ${{ !cancelled() && needs.fingerprint.result == 'success' && (needs.build-android.result == 'success' || needs.build-android.result == 'skipped') && ((github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main') || (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android'))) }} env: HARNESS_DEBUG: true RN_HARNESS_DIAGNOSTICS: true @@ -64,35 +261,18 @@ jobs: run: | pnpm nx run-many -t build --projects="packages/*" - - name: Set up JDK 17 + - name: Set up JDK uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: - java-version: '17' + java-version: ${{ env.JAVA_VERSION }} distribution: 'temurin' - name: Restore APK from cache - id: cache-apk-restore uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk - key: apk-playground-x86_64-${{ hashFiles('pnpm-lock.yaml', 'apps/playground/package.json', 'apps/playground/android/**', 'apps/playground/src/specs/**', 'packages/ui/android/**', 'packages/ui/src/Native*.ts', 'packages/ui/package.json', 'packages/ui/react-native.config.cjs') }} - - - name: Build Android app - if: steps.cache-apk-restore.outputs.cache-hit != 'true' - working-directory: apps/playground - env: - # The CI emulator is x86_64, so skip compiling native code for the - # other ABIs listed in gradle.properties. - ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64 - run: | - pnpm nx run @react-native-harness/playground:build-android --tasks=assembleDebug - - - name: Save APK to cache - if: steps.cache-apk-restore.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk - key: ${{ steps.cache-apk-restore.outputs.cache-primary-key }} + key: apk-playground-${{ needs.fingerprint.outputs.android }} + fail-on-cache-miss: true - name: Run React Native Harness uses: ./ @@ -127,7 +307,8 @@ jobs: name: E2E iOS runs-on: macos-latest timeout-minutes: 30 - if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main') || (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios')) }} + needs: [fingerprint, build-ios] + if: ${{ !cancelled() && needs.fingerprint.result == 'success' && (needs.build-ios.result == 'success' || needs.build-ios.result == 'skipped') && ((github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main') || (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios'))) }} env: HARNESS_DEBUG: true RN_HARNESS_DIAGNOSTICS: true @@ -147,10 +328,10 @@ jobs: node-version: '24.10.0' cache: 'pnpm' - - name: Setup Xcode 26.4.1 + - name: Setup Xcode uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '26.4.1' + xcode-version: ${{ env.XCODE_VERSION }} - name: Install Watchman run: brew install watchman @@ -174,44 +355,11 @@ jobs: node scripts/pack-xctest-agent-artifacts.mjs - name: Restore app from cache - id: cache-app-restore uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app - key: ios-app-playground-xcode26.4.1-arm64-${{ hashFiles('pnpm-lock.yaml', 'apps/playground/package.json', 'apps/playground/ios/**', 'apps/playground/src/specs/**', 'packages/ui/ios/**', 'packages/ui/HarnessUI.podspec', 'packages/ui/Package.swift', 'packages/ui/src/Native*.ts', 'packages/ui/package.json', 'packages/ui/react-native.config.cjs') }} - - - name: CocoaPods cache - if: steps.cache-app-restore.outputs.cache-hit != 'true' - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: | - ./apps/playground/ios/Pods - ~/Library/Caches/CocoaPods - ~/.cocoapods - key: playground-${{ runner.os }}-pods-${{ hashFiles('./apps/playground/ios/Podfile.lock') }} - restore-keys: | - playground-${{ runner.os }}-pods- - - - name: Install CocoaPods - if: steps.cache-app-restore.outputs.cache-hit != 'true' - working-directory: apps/playground/ios - run: | - pod install - - - name: Build iOS app - if: steps.cache-app-restore.outputs.cache-hit != 'true' - working-directory: apps/playground - # The generic simulator destination builds every simulator arch; the - # runner is Apple silicon, so only arm64 is needed. - run: | - pnpm react-native build-ios --buildFolder ./build --verbose --extra-params "ARCHS=arm64" - - - name: Save app to cache - if: steps.cache-app-restore.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app - key: ${{ steps.cache-app-restore.outputs.cache-primary-key }} + key: ios-app-playground-${{ needs.fingerprint.outputs.ios }} + fail-on-cache-miss: true - name: Run React Native Harness uses: ./ @@ -306,7 +454,8 @@ jobs: name: Crash Validation Android runs-on: ubuntu-22.04 timeout-minutes: 30 - if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') }} + needs: [fingerprint, build-android] + if: ${{ !cancelled() && needs.fingerprint.result == 'success' && (needs.build-android.result == 'success' || needs.build-android.result == 'skipped') && (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android')) }} env: HARNESS_DEBUG: true RN_HARNESS_DIAGNOSTICS: true @@ -343,35 +492,18 @@ jobs: run: | pnpm nx run-many -t build --projects="packages/*" - - name: Set up JDK 17 + - name: Set up JDK uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: - java-version: '17' + java-version: ${{ env.JAVA_VERSION }} distribution: 'temurin' - name: Restore APK from cache - id: cache-apk-restore uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk - key: apk-playground-x86_64-${{ hashFiles('pnpm-lock.yaml', 'apps/playground/package.json', 'apps/playground/android/**', 'apps/playground/src/specs/**', 'packages/ui/android/**', 'packages/ui/src/Native*.ts', 'packages/ui/package.json', 'packages/ui/react-native.config.cjs') }} - - - name: Build Android app - if: steps.cache-apk-restore.outputs.cache-hit != 'true' - working-directory: apps/playground - env: - # The CI emulator is x86_64, so skip compiling native code for the - # other ABIs listed in gradle.properties. - ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64 - run: | - pnpm nx run @react-native-harness/playground:build-android --tasks=assembleDebug - - - name: Save APK to cache - if: steps.cache-apk-restore.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk - key: ${{ steps.cache-apk-restore.outputs.cache-primary-key }} + key: apk-playground-${{ needs.fingerprint.outputs.android }} + fail-on-cache-miss: true - name: Run React Native Harness (expect crash) id: crash-test @@ -437,7 +569,8 @@ jobs: name: Crash Validation iOS runs-on: macos-latest timeout-minutes: 30 - if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} + needs: [fingerprint, build-ios] + if: ${{ !cancelled() && needs.fingerprint.result == 'success' && (needs.build-ios.result == 'success' || needs.build-ios.result == 'skipped') && (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios')) }} env: HARNESS_DEBUG: true RN_HARNESS_DIAGNOSTICS: true @@ -457,10 +590,10 @@ jobs: node-version: '24.10.0' cache: 'pnpm' - - name: Setup Xcode 26.4.1 + - name: Setup Xcode uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '26.4.1' + xcode-version: ${{ env.XCODE_VERSION }} - name: Install Watchman run: brew install watchman @@ -484,44 +617,11 @@ jobs: node scripts/pack-xctest-agent-artifacts.mjs - name: Restore app from cache - id: cache-app-restore uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app - key: ios-app-playground-xcode26.4.1-arm64-${{ hashFiles('pnpm-lock.yaml', 'apps/playground/package.json', 'apps/playground/ios/**', 'apps/playground/src/specs/**', 'packages/ui/ios/**', 'packages/ui/HarnessUI.podspec', 'packages/ui/Package.swift', 'packages/ui/src/Native*.ts', 'packages/ui/package.json', 'packages/ui/react-native.config.cjs') }} - - - name: CocoaPods cache - if: steps.cache-app-restore.outputs.cache-hit != 'true' - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: | - ./apps/playground/ios/Pods - ~/Library/Caches/CocoaPods - ~/.cocoapods - key: playground-${{ runner.os }}-pods-${{ hashFiles('./apps/playground/ios/Podfile.lock') }} - restore-keys: | - playground-${{ runner.os }}-pods- - - - name: Install CocoaPods - if: steps.cache-app-restore.outputs.cache-hit != 'true' - working-directory: apps/playground/ios - run: | - pod install - - - name: Build iOS app - if: steps.cache-app-restore.outputs.cache-hit != 'true' - working-directory: apps/playground - # The generic simulator destination builds every simulator arch; the - # runner is Apple silicon, so only arm64 is needed. - run: | - pnpm react-native build-ios --buildFolder ./build --verbose --extra-params "ARCHS=arm64" - - - name: Save app to cache - if: steps.cache-app-restore.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app - key: ${{ steps.cache-app-restore.outputs.cache-primary-key }} + key: ios-app-playground-${{ needs.fingerprint.outputs.ios }} + fail-on-cache-miss: true - name: Run React Native Harness (expect crash) id: crash-test diff --git a/package.json b/package.json index 364f7021..09431f09 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@babel/preset-env": "^7.25.3", "@babel/runtime": "7.27.6", "@eslint/js": "^9.8.0", + "@expo/fingerprint": "0.21.6", "@nx/eslint": "22.0.4", "@nx/eslint-plugin": "22.0.4", "@nx/js": "22.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c485e26..908fe385 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@eslint/js': specifier: ^9.8.0 version: 9.29.0 + '@expo/fingerprint': + specifier: 0.21.6 + version: 0.21.6 '@nx/eslint': specifier: 22.0.4 version: 22.0.4(@babel/traverse@7.27.4)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))(@zkochan/js-yaml@0.0.7)(eslint@9.29.0(jiti@2.4.2))(nx@22.0.4(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.9.3))(@swc/core@1.5.29(@swc/helpers@0.5.17))) @@ -1744,6 +1747,18 @@ packages: resolution: {integrity: sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@expo/env@2.5.3': + resolution: {integrity: sha512-dBGu8BAfvUcXvG4HvYth+EhcHEsdC+/fT6HTH/zFvvwfp50LG6uzpzT8TXObwEq/t2B/rvAMV5OolxQ46LWXHg==} + engines: {node: '>=20.12.0'} + + '@expo/fingerprint@0.21.6': + resolution: {integrity: sha512-EKh7ak+SYwXjvX/4P7BcyeLxip2z9KAsnN/55VP10g8H1Ua4Dm/BgLAhgBcbzbsYretYmNEynAcf9oKCK9pjxA==} + hasBin: true + + '@expo/spawn-async@1.8.0': + resolution: {integrity: sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==} + engines: {node: '>=12'} + '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -4065,6 +4080,9 @@ packages: appdirsjs@1.2.7: resolution: {integrity: sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw==} + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -5575,6 +5593,10 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -5595,11 +5617,6 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -8212,6 +8229,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -10409,6 +10431,34 @@ snapshots: '@eslint/core': 0.15.0 levn: 0.4.1 + '@expo/env@2.5.3': + dependencies: + chalk: 4.1.2 + debug: 4.4.1 + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/fingerprint@0.21.6': + dependencies: + '@expo/env': 2.5.3 + '@expo/spawn-async': 1.8.0 + arg: 5.0.2 + chalk: 4.1.2 + debug: 4.4.1 + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + minimatch: 10.2.6 + resolve-from: 5.0.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + '@expo/spawn-async@1.8.0': + dependencies: + cross-spawn: 7.0.6 + '@hapi/hoek@9.3.0': {} '@hapi/topo@5.1.0': @@ -13448,7 +13498,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -13677,6 +13727,8 @@ snapshots: appdirsjs@1.2.7: {} + arg@5.0.2: {} + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -15580,6 +15632,8 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + getenv@2.0.0: {} + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -15596,15 +15650,6 @@ snapshots: glob-to-regexp@0.4.1: {} - glob@10.4.5: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -16402,7 +16447,7 @@ snapshots: chalk: 4.1.2 ci-info: 4.3.0 deepmerge: 4.3.1 - glob: 10.4.5 + glob: 10.5.0 graceful-fs: 4.2.11 jest-circus: 30.2.0(babel-plugin-macros@3.1.0) jest-docblock: 30.2.0 @@ -16434,7 +16479,7 @@ snapshots: chalk: 4.1.2 ci-info: 4.3.0 deepmerge: 4.3.1 - glob: 10.4.5 + glob: 10.5.0 graceful-fs: 4.2.11 jest-circus: 30.2.0(babel-plugin-macros@3.1.0) jest-docblock: 30.2.0 @@ -16793,7 +16838,7 @@ snapshots: chalk: 4.1.2 cjs-module-lexer: 2.1.0 collect-v8-coverage: 1.0.2 - glob: 10.4.5 + glob: 10.5.0 graceful-fs: 4.2.11 jest-haste-map: 30.2.0 jest-message-util: 30.2.0 @@ -19335,6 +19380,8 @@ snapshots: semver@7.7.2: {} + semver@7.8.5: {} + send@0.19.0: dependencies: debug: 2.6.9 diff --git a/scripts/ci/native-fingerprint.mjs b/scripts/ci/native-fingerprint.mjs new file mode 100644 index 00000000..5604b982 --- /dev/null +++ b/scripts/ci/native-fingerprint.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +// Prints a fingerprint of everything that determines an app's native debug +// build for one platform, for use as a CI cache key. +// +// Usage: node scripts/ci/native-fingerprint.mjs [--debug] +// +// @expo/fingerprint covers the app's native directory, the package.json +// scripts, the autolinking config, and each autolinked package by its +// name@version. A workspace package keeps its version while its native code +// changes, so this script adds the git-tracked native files and codegen specs +// of every workspace dependency, plus the toolchain settings passed as +// environment variables by the workflow. +// +// `ios-pods` prints a narrower fingerprint for the CocoaPods sandbox: the +// Podfile, the lockfile, the podspecs and the autolinked packages, but not the +// app or workspace sources that Pods/ only references. +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { createFingerprintAsync } from '@expo/fingerprint'; + +// Bump to discard every cached app, e.g. after a change the fingerprint +// cannot see. +const CACHE_VERSION = '1'; + +const NATIVE_PATHS = { + ios: ['ios', '*.podspec', 'Package.swift'], + android: ['android'], +}; + +// Toolchain and build settings that change the binary without changing any +// file in the repository. +const TOOLCHAIN_ENV = { + ios: [ + 'XCODE_VERSION', + 'IOS_ARCHS', + 'RCT_USE_RN_DEP', + 'RCT_USE_PREBUILT_RNCORE', + 'USE_FRAMEWORKS', + ], + android: ['JAVA_VERSION', 'ORG_GRADLE_PROJECT_reactNativeArchitectures'], +}; + +const CODEGEN_SPEC = /(^|\/)Native[^/]*\.(js|jsx|ts|tsx)$/; + +// Settings that change what `pod install` generates. +const PODS_ENV = ['RCT_USE_RN_DEP', 'RCT_USE_PREBUILT_RNCORE', 'USE_FRAMEWORKS']; + +const [projectRootArg, target] = process.argv.slice(2); +const debug = process.argv.includes('--debug'); +const pods = target === 'ios-pods'; +const platform = pods ? 'ios' : target; + +if (!projectRootArg || !NATIVE_PATHS[platform]) { + console.error( + 'Usage: native-fingerprint.mjs [--debug]' + ); + process.exit(1); +} + +const projectRoot = path.resolve(projectRootArg); +const require = createRequire(path.join(projectRoot, 'package.json')); + +const gitLsFiles = (cwd, pathspecs) => + execFileSync('git', ['ls-files', '-z', '--', ...pathspecs], { + cwd, + encoding: 'utf8', + }) + .split('\0') + .filter(Boolean); + +const readJson = (file) => JSON.parse(readFileSync(file, 'utf8')); + +const codegenSources = (packageDir, packageJson, reason) => { + const { codegenConfig } = packageJson; + if (!codegenConfig) { + return []; + } + + const sources = [ + { + type: 'contents', + id: `${packageJson.name}:codegenConfig`, + contents: JSON.stringify(codegenConfig), + reasons: [reason], + }, + ]; + + if (codegenConfig.jsSrcsDir) { + for (const file of gitLsFiles(packageDir, [codegenConfig.jsSrcsDir])) { + if (CODEGEN_SPEC.test(file)) { + sources.push({ + type: 'file', + filePath: path.relative(projectRoot, path.join(packageDir, file)), + reasons: [reason], + }); + } + } + } + + return sources; +}; + +const projectPackageJson = readJson(path.join(projectRoot, 'package.json')); +const extraSources = [ + { + type: 'contents', + id: 'cacheVersion', + contents: CACHE_VERSION, + reasons: ['cacheVersion'], + }, + ...codegenSources(projectRoot, projectPackageJson, 'appCodegen'), +]; + +const workspaceDependencies = Object.entries({ + ...projectPackageJson.dependencies, + ...projectPackageJson.devDependencies, +}) + .filter(([, spec]) => spec.startsWith('workspace:')) + .map(([name]) => name) + .sort(); + +for (const name of workspaceDependencies) { + const packageDir = realpathSync( + path.dirname(require.resolve(`${name}/package.json`)) + ); + const nativeFiles = gitLsFiles(packageDir, NATIVE_PATHS[platform]); + if (nativeFiles.length === 0) { + continue; + } + + for (const file of nativeFiles) { + extraSources.push({ + type: 'file', + filePath: path.relative(projectRoot, path.join(packageDir, file)), + reasons: [`workspaceNative:${name}`], + }); + } + extraSources.push( + ...codegenSources( + packageDir, + readJson(path.join(packageDir, 'package.json')), + `workspaceCodegen:${name}` + ) + ); +} + +const toolchain = Object.fromEntries( + TOOLCHAIN_ENV[platform].map((name) => [name, process.env[name] ?? null]) +); +extraSources.push({ + type: 'contents', + id: 'toolchain', + contents: JSON.stringify(toolchain), + reasons: ['toolchain'], +}); + +const fingerprint = await createFingerprintAsync(projectRoot, { + platforms: [platform], + extraSources, + silent: true, +}); + +const sha1 = (value) => createHash('sha1').update(value).digest('hex'); + +const podsSources = () => { + const lines = fingerprint.sources + .filter( + (source) => + source.type === 'package' || + source.id === 'rncoreAutolinkingConfig' || + source.id === 'cacheVersion' || + source.filePath?.endsWith('.podspec') + ) + .map((source) => `${source.filePath ?? source.id} ${source.hash}`); + + for (const file of ['ios/Podfile', 'ios/Podfile.lock']) { + lines.push(`${file} ${sha1(readFileSync(path.join(projectRoot, file)))}`); + } + + const podsEnv = Object.fromEntries( + PODS_ENV.map((name) => [name, process.env[name] ?? null]) + ); + lines.push(`env ${sha1(JSON.stringify(podsEnv))}`); + + return lines.sort(); +}; + +const lines = pods + ? podsSources() + : fingerprint.sources.map( + (source) => `${source.filePath ?? source.id} ${source.hash}` + ); + +if (debug) { + console.error(lines.join('\n')); + console.error(`toolchain: ${JSON.stringify(toolchain)}`); +} + +console.log(pods ? sha1(lines.join('\n')) : fingerprint.hash); From 6ebca73e4755e81b14b4c01a7f01b2a6dd5d70e5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 25 Oct 2026 21:34:12 +0100 Subject: [PATCH 2/4] chore: PoC native-only iOS change (revert) --- packages/ui/ios/HarnessUI.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/ios/HarnessUI.mm b/packages/ui/ios/HarnessUI.mm index 9fc6964a..b03301eb 100644 --- a/packages/ui/ios/HarnessUI.mm +++ b/packages/ui/ios/HarnessUI.mm @@ -576,3 +576,4 @@ + (NSString *)moduleName { } @end +// PoC: native-only change to exercise the fingerprint (revert before merging) From dd851cdb92219e89531595fa1ffd170fb6578542 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 07:40:58 +0100 Subject: [PATCH 3/4] Revert "chore: PoC native-only iOS change (revert)" This reverts commit 6ebca73e4755e81b14b4c01a7f01b2a6dd5d70e5. --- packages/ui/ios/HarnessUI.mm | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/ios/HarnessUI.mm b/packages/ui/ios/HarnessUI.mm index b03301eb..9fc6964a 100644 --- a/packages/ui/ios/HarnessUI.mm +++ b/packages/ui/ios/HarnessUI.mm @@ -576,4 +576,3 @@ + (NSString *)moduleName { } @end -// PoC: native-only change to exercise the fingerprint (revert before merging) From c7cae6fe8186b5c18459e80b4ccb41d75dfde088 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 26 Oct 2026 07:41:40 +0100 Subject: [PATCH 4/4] chore: regenerate lockfile from main to drop unrelated churn --- pnpm-lock.yaml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 908fe385..172e1bc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5617,6 +5617,11 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -13498,7 +13503,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -15650,6 +15655,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -16838,7 +16852,7 @@ snapshots: chalk: 4.1.2 cjs-module-lexer: 2.1.0 collect-v8-coverage: 1.0.2 - glob: 10.5.0 + glob: 10.4.5 graceful-fs: 4.2.11 jest-haste-map: 30.2.0 jest-message-util: 30.2.0