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..172e1bc3 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==} @@ -8212,6 +8234,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 +10436,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': @@ -13677,6 +13732,8 @@ snapshots: appdirsjs@1.2.7: {} + arg@5.0.2: {} + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -15580,6 +15637,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: @@ -16402,7 +16461,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 +16493,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 @@ -19335,6 +19394,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);