From 1bd7566a41f2c26645dacbcb04844d4865879dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 17 Jul 2026 13:37:59 +0200 Subject: [PATCH] =?UTF-8?q?ci(test-app):=20fingerprint-keyed=20build=20cac?= =?UTF-8?q?he=20=E2=80=94=20disk=20locally,=20Release=20artifacts=20in=20C?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the test app's build caching by context instead of running one remote cache for both. Locally, `expo run:*` caches the native build on disk via the expo-build-disk-cache provider, keyed by the Expo fingerprint. A second run with no native change reuses the first build; a screen edit never rebuilds, because Metro serves JS. This is the original ask — "next time we don't build unless native changes" — and needs no token, no network, and no custom provider. In CI, test-app-build-cache.yml builds a Release binary per platform when the fingerprint has no artifact yet, and publishes it as a GitHub Actions artifact named `fingerprint..`. Release, not dev-client, so the JS bundle is embedded and a consuming job needs no Metro. setup-fixture-app installs it by downloading the artifact and refreshing the JS with @expo/repack-app, so keying on the native-only fingerprint stays correct — a JS-only change reuses the same native binary in seconds. It falls back to an inline build when no artifact exists yet, so a caller is never left without an app. Release removes the sharp edges the dev-client cache needed. Its simulator .app is universal (x86_64+arm64) rather than the active-arch-only slice a debug build emits, so no architecture tag. It links against the SDK but loading is gated by the deployment target, which the fingerprint already covers, so no toolchain tag. And the CLI only narrows *debug* builds to the device ABI, so a Release APK spans every ABI without the undocumented --all-arch flag. The artifact name collapses to fingerprint plus platform. This deletes build-cache-provider.js entirely — with it goes the custom Expo provider that had to reach GitHub from inside @expo/cli, and every workaround that forced: the fetch-nodeshim User-Agent shim, the arch/Xcode identity, the upload-intent handoff. CI now talks to the artifacts API with plain `gh api` outside the patched fetch, and locally the disk cache never hits the network. The fingerprint comes from @expo/fingerprint's own `fingerprint:generate` (no --platform, matching what @expo/cli hashes). Gitignoring /ios and /android is what makes it machine-independent: the library asks the VCS whether the platform markers are ignored and, concluding CNG, skips hashing them — so a developer's prebuild output and a fresh CI checkout agree. conformance-differential consumes setup-fixture-app, so it gains `permissions: actions: read` for the artifact lookup. The artifact lookup is non-fatal: a query outage leaves the id empty and falls through to an inline build like a miss does, rather than exiting the composite under set -e and turning a cache blip into a caller failure. test/scripts/setup-fixture-app-fallback-smoke.sh drives that step's real shell against a failing gh and asserts source=build; ci.yml runs it. --- .github/actions/setup-fixture-app/action.yml | 136 ++++++++----- .github/workflows/ci.yml | 6 + .../workflows/conformance-differential.yml | 13 +- .github/workflows/test-app-build-cache.yml | 182 ++++++++++++++++++ examples/test-app/.gitignore | 11 +- examples/test-app/README.md | 24 +++ examples/test-app/app.config.js | 14 +- examples/test-app/package.json | 2 + examples/test-app/pnpm-lock.yaml | 21 ++ .../setup-fixture-app-fallback-smoke.sh | 77 ++++++++ 10 files changed, 418 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/test-app-build-cache.yml create mode 100755 test/scripts/setup-fixture-app-fallback-smoke.sh diff --git a/.github/actions/setup-fixture-app/action.yml b/.github/actions/setup-fixture-app/action.yml index 9a2cfc227..6043fea19 100644 --- a/.github/actions/setup-fixture-app/action.yml +++ b/.github/actions/setup-fixture-app/action.yml @@ -1,24 +1,24 @@ name: "Setup Fixture App" -description: "Build (or restore from cache) the examples/test-app fixture app and install it on the booted iOS simulator" +description: "Install a ready Release build of examples/test-app on the booted iOS simulator, from the shared build cache" # Any job that needs a controlled app to drive can use this instead of an Apple -# system app. Building it costs ~22 minutes, so the built .app is cached and the -# build only runs when its sources or the toolchain actually change. +# system app. It fetches the Release binary that test-app-build-cache.yml +# published for the current Expo fingerprint, refreshes its JS, and installs it — +# turning a ~22 minute build into a download plus a seconds-long repack. # -# The cache is shared: GitHub caches are per-repository and readable across -# workflows, and a run can restore caches from its own branch or the default -# branch. So once a run on main populates it, every workflow gets the hit. The -# key is computed here from a fixed input list, so all callers agree on it — -# do not fold caller-specific inputs into it, or the cache stops being shared. +# Keyed on the Expo fingerprint, which hashes the native build and nothing else. +# A hit therefore guarantees only that the native side is current, so the JS the +# Release build embedded may be stale — @expo/repack-app swaps in a bundle built +# from the current source. If no artifact exists yet (the producer has not run +# for this fingerprint), it falls back to building inline, so a caller is never +# left without an app. # +# The caller's job needs `permissions: actions: read` for the artifact lookup. # Relevant to #320 (move replay coverage off system apps onto a stable fixture). inputs: - runtime-version: - description: "iOS runtime version (participates in the cache key)" - required: true device-name: - description: "Simulator device name used for the build destination" + description: "Simulator device name used for the inline-build fallback" required: false default: "iPhone 17 Pro" install: @@ -28,57 +28,78 @@ inputs: outputs: app-path: - description: "Path to the built .app bundle" + description: "Path to the ready .app bundle" value: ${{ steps.locate.outputs.app-path }} app-id: - description: "Bundle identifier read from the built app's Info.plist" + description: "Bundle identifier read from the app's Info.plist" value: ${{ steps.locate.outputs.app-id }} - cache-hit: - description: "Whether the build was restored from cache" - value: ${{ steps.cache.outputs.cache-hit }} + source: + description: "Where the binary came from: 'artifact' or 'build'" + value: ${{ steps.fetch.outputs.source }} runs: using: "composite" steps: - - name: Resolve Xcode cache key - id: xcode + # Installed even on a hit, because the fingerprint is derived from the + # dependency graph and cannot be computed without it. + - name: Install test app dependencies shell: bash + run: pnpm test-app:install + + - name: Fetch the cached Release binary + id: fetch + shell: bash + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - XCODE_KEY="$(xcodebuild -version | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]$//' | tr ' ' '-' | tr -cd '[:alnum:]._-')" - echo "key=$XCODE_KEY" >> "$GITHUB_OUTPUT" + HASH="$(pnpm --dir examples/test-app exec fingerprint fingerprint:generate | jq -r .hash)" + NAME="fingerprint.${HASH}.ios" + DEST="${{ github.workspace }}/.tmp/fixture-app" + rm -rf "$DEST"; mkdir -p "$DEST" + + # A lookup failure (API outage, auth, transient 5xx) must not fail the + # caller: it degrades to an inline build exactly as a cache miss does. The + # assignment sits in an `if` so `set -e` cannot exit on it, and an empty + # ART_ID then takes the same path as "no artifact found". + if ! ART_ID="$(gh api "repos/${{ github.repository }}/actions/artifacts?name=${NAME}&per_page=1" \ + --jq '[.artifacts[] | select(.expired == false)][0].id // empty' 2>/dev/null)"; then + echo "::warning::Could not query the build cache for $NAME; building inline." + ART_ID="" + fi - - name: Cache fixture app build - id: cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 - with: - path: ${{ github.workspace }}/.tmp/fixture-app - # Everything that can change the binary: app sources, native config, - # dependency graph, this action's build logic, and the toolchain. - # Deliberately caller-independent so the cache is shared across workflows. - key: fixture-app-ios-${{ inputs.runtime-version }}-${{ steps.xcode.outputs.key }}-${{ hashFiles('examples/test-app/src/**', 'examples/test-app/app/**', 'examples/test-app/modules/**', 'examples/test-app/app.config.js', 'examples/test-app/package.json', 'examples/test-app/pnpm-lock.yaml', 'examples/test-app/pnpm-workspace.yaml', '.github/actions/setup-fixture-app/action.yml') }} + SOURCE=build + if [ -n "$ART_ID" ]; then + STAGE="$(mktemp -d)" + # Any failure along the download path falls through to an inline build + # rather than failing the caller. + if gh api "repos/${{ github.repository }}/actions/artifacts/${ART_ID}/zip" > "$STAGE/a.zip" \ + && unzip -q "$STAGE/a.zip" -d "$STAGE" \ + && tar -xzf "$STAGE/binary.tar.gz" -C "$DEST"; then + SOURCE=artifact + echo "restored $NAME from the build cache" + else + echo "::warning::Could not restore $NAME; building inline." + rm -rf "$DEST"/*.app + fi + rm -rf "$STAGE" + else + echo "$NAME not in the cache yet; building inline." + fi + echo "source=$SOURCE" >> "$GITHUB_OUTPUT" - - name: Build fixture app - if: steps.cache.outputs.cache-hit != 'true' + # Fallback only: the producer builds the same way. `--device generic` avoids + # needing a booted simulator; Release embeds the bundle so no Metro is needed. + - name: Build the Release app (fallback) + if: steps.fetch.outputs.source == 'build' shell: bash run: | set -euo pipefail - # Release embeds the JS bundle, so no Metro server is needed in CI. - pnpm test-app:install pnpm --dir examples/test-app exec expo run:ios \ --configuration Release \ - --device "${{ inputs.device-name }}" \ - --no-bundler - # Stash the bundle at a stable path: DerivedData paths are hashed per - # project, so the cache needs somewhere deterministic to keep it. - APP_PATH="$(find "$HOME/Library/Developer/Xcode/DerivedData" -type d -name '*.app' -path '*Release-iphonesimulator*' | head -1)" - if [ -z "$APP_PATH" ]; then - echo "::error::Built the fixture app but could not locate its .app bundle to cache." - exit 1 - fi - mkdir -p "${{ github.workspace }}/.tmp/fixture-app" - rm -rf "${{ github.workspace }}/.tmp/fixture-app/"*.app - cp -R "$APP_PATH" "${{ github.workspace }}/.tmp/fixture-app/" + --device generic \ + --no-bundler \ + --output "${{ github.workspace }}/.tmp/fixture-app" - name: Locate fixture app id: locate @@ -87,7 +108,7 @@ runs: set -euo pipefail APP="$(find "${{ github.workspace }}/.tmp/fixture-app" -maxdepth 1 -type d -name '*.app' | head -1)" if [ -z "$APP" ]; then - echo "::error::No fixture app bundle at .tmp/fixture-app (restored an empty cache?)." + echo "::error::No fixture app bundle at .tmp/fixture-app." exit 1 fi # Read the id from the bundle itself rather than duplicating it here, so @@ -95,7 +116,26 @@ runs: APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Info.plist")" echo "app-path=$APP" >> "$GITHUB_OUTPUT" echo "app-id=$APP_ID" >> "$GITHUB_OUTPUT" - echo "fixture app: $(basename "$APP") ($APP_ID)" + echo "fixture app: $(basename "$APP") ($APP_ID, source=${{ steps.fetch.outputs.source }})" + + # A cached artifact embeds the JS from whenever the producer built it. + # --js-bundle-only refreshes exactly the gap the native fingerprint leaves; + # an inline build already has current JS, so it is skipped there. + - name: Repack the cached app with the current JS + if: steps.fetch.outputs.source == 'artifact' + shell: bash + run: | + set -euo pipefail + APP="${{ steps.locate.outputs.app-path }}" + OUT="${{ github.workspace }}/.tmp/fixture-app-repacked/$(basename "$APP")" + rm -rf "$(dirname "$OUT")"; mkdir -p "$(dirname "$OUT")" + pnpm --dir examples/test-app exec repack-app \ + --platform ios \ + --source-app "$APP" \ + --output "$OUT" \ + --js-bundle-only + rm -rf "$APP" + mv "$OUT" "$APP" - name: Install fixture app if: inputs.install == 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79976c838..0980c195d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -262,6 +262,12 @@ jobs: - name: Check Provider-backed integration architecture progress run: pnpm test:integration:progress:check + # A build-cache lookup outage must degrade setup-fixture-app to an inline + # build, not fail the caller. This drives that step's real shell against a + # failing `gh`. + - name: Setup-fixture-app cache-failure fallback + run: sh ./test/scripts/setup-fixture-app-fallback-smoke.sh + web-smoke: name: Web Platform Smoke runs-on: ubuntu-latest diff --git a/.github/workflows/conformance-differential.yml b/.github/workflows/conformance-differential.yml index 7098cfc86..6a17c9a5b 100644 --- a/.github/workflows/conformance-differential.yml +++ b/.github/workflows/conformance-differential.yml @@ -24,6 +24,8 @@ on: permissions: contents: read + # setup-fixture-app looks up the cached test-app binary via the artifacts API. + actions: read concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -67,15 +69,14 @@ jobs: runtime-version: ${{ env.IOS_RUNTIME_VERSION }} preferred-device-name: iPhone 17 Pro - # Builds or restores the cached fixture app and installs it. Shared with any - # other job that needs a controlled app to drive (see #320) — the cache is - # repo-wide, so whichever workflow builds it first pays the ~22 minutes and - # the rest get a hit. + # Installs the cached Release test-app and refreshes its JS. Shared with any + # other job that needs a controlled app to drive (see #320) — the artifact + # is repo-wide, so once the producer builds a fingerprint every workflow + # gets it as a download. - name: Setup fixture app id: fixture-app uses: ./.github/actions/setup-fixture-app with: - runtime-version: ${{ env.IOS_RUNTIME_VERSION }} device-name: iPhone 17 Pro # The action guarantees *an* app is installed; this asserts it is the one @@ -87,7 +88,7 @@ jobs: EXPECTED=$(node --experimental-strip-types -e \ "import('./scripts/maestro-conformance/differential/scenarios.ts').then(m => console.log(m.DIFFERENTIAL_APP_ID))") ACTUAL="${{ steps.fixture-app.outputs.app-id }}" - echo "expected=$EXPECTED actual=$ACTUAL (cache-hit=${{ steps.fixture-app.outputs.cache-hit }})" + echo "expected=$EXPECTED actual=$ACTUAL (source=${{ steps.fixture-app.outputs.source }})" if [ "$ACTUAL" != "$EXPECTED" ]; then echo "::error::Installed fixture app is $ACTUAL but the scenarios target $EXPECTED; they would fail vacuously." exit 1 diff --git a/.github/workflows/test-app-build-cache.yml b/.github/workflows/test-app-build-cache.yml new file mode 100644 index 000000000..2c19476d9 --- /dev/null +++ b/.github/workflows/test-app-build-cache.yml @@ -0,0 +1,182 @@ +name: Test App Build Cache + +# Builds a Release binary of examples/test-app and publishes it as a workflow +# artifact keyed by the Expo fingerprint, so CI e2e jobs install a ready binary +# (via .github/actions/setup-fixture-app) instead of compiling from scratch. +# +# Release, not dev-client: the JS bundle is embedded, so a consuming job needs no +# Metro. Its JS is refreshed on retrieval with @expo/repack-app, so keying on the +# native-only fingerprint is correct -- a JS-only change reuses the same native +# binary. Local development does not use this; it caches on disk via the +# expo-build-disk-cache provider in app.config.js. +# +# Only builds when the fingerprint has no artifact yet. Runs on every push to +# main, and on PRs that touch the cache so a change here is exercised before it +# merges (workflow_dispatch cannot reach a workflow that is not yet on main). + +on: + push: + branches: + - main + pull_request: + paths: + - "examples/test-app/**" + - ".github/workflows/test-app-build-cache.yml" + - ".github/actions/setup-fixture-app/**" + workflow_dispatch: + +permissions: + contents: read + # Look up whether this fingerprint's artifact already exists across runs. + actions: read + +concurrency: + # One producer at a time so a fingerprint is never built twice over; queue + # rather than cancel, or a long build could be killed by the next merge and the + # cache would never populate. Queued runs are cheap: they find the artifact and + # skip the build. + group: ci-${{ github.workflow }} + cancel-in-progress: false + +jobs: + release: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 60 + strategy: + # Independent caches; one platform failing must not withhold the other's. + fail-fast: false + matrix: + include: + - name: iOS Release + platform: ios + runs-on: macos-26 + - name: Android Release + platform: android + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Install test app dependencies + run: pnpm test-app:install + + - name: Resolve fingerprint and whether it is already cached + id: fp + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + HASH="$(pnpm --dir examples/test-app exec fingerprint fingerprint:generate | jq -r .hash)" + NAME="fingerprint.${HASH}.${{ matrix.platform }}" + # A non-expired artifact under this name means the native inputs are + # unchanged; there is nothing to rebuild. + COUNT="$(gh api "repos/${{ github.repository }}/actions/artifacts?name=${NAME}&per_page=1" \ + --jq '[.artifacts[] | select(.expired == false)] | length')" + echo "name=$NAME" >> "$GITHUB_OUTPUT" + if [ "$COUNT" -gt 0 ]; then + echo "cached=true" >> "$GITHUB_OUTPUT" + echo "$NAME already cached; nothing to build." + else + echo "cached=false" >> "$GITHUB_OUTPUT" + echo "$NAME not cached; building." + fi + + # `--device generic` builds for the simulator without booting one or + # installing. Release embeds the bundle (no Metro) and links a universal + # x86_64+arm64 slice, so the artifact runs on any developer's simulator. + - name: Build the iOS Release app + if: matrix.platform == 'ios' && steps.fp.outputs.cached == 'false' + run: | + set -euo pipefail + pnpm --dir examples/test-app exec expo run:ios \ + --configuration Release \ + --device generic \ + --no-bundler \ + --output "${{ github.workspace }}/.tmp/test-app-build" + + - name: Setup Android host + if: matrix.platform == 'android' && steps.fp.outputs.cached == 'false' + uses: ./.github/actions/setup-android-replay-host + + # Deliberately no `set -o pipefail`, matching android.yml: `yes` is killed + # by SIGPIPE once sdkmanager stops reading, and pipefail would report that + # 141 as the step's own failure. + - name: Install Android SDK packages + if: matrix.platform == 'android' && steps.fp.outputs.cached == 'false' + run: | + SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$SDKMANAGER" ]; then + SDKMANAGER="$SDK_ROOT/cmdline-tools/bin/sdkmanager" + fi + if [ ! -x "$SDKMANAGER" ]; then + echo "sdkmanager not found under $SDK_ROOT" >&2 + exit 1 + fi + yes | "$SDKMANAGER" --licenses >/dev/null + "$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0" + + # Android has no build-only mode, so an emulator satisfies device + # resolution; it installs the APK as a bonus check that it loads. A Release + # build already spans every ABI (the CLI only narrows debug builds to the + # device ABI), so no --all-arch and no emulator-architecture coupling. + - name: Build the Android Release apk + if: matrix.platform == 'android' && steps.fp.outputs.cached == 'false' + uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0 + with: + api-level: 36 + arch: x86_64 + profile: pixel_7 + target: google_apis_playstore + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics + # This action runs each line in its own `sh -c`, so every line must be + # self-contained: no line continuations, and no variables shared between + # lines. `expo run:android` also installs and launches the APK, proving + # the Release build loads before it is published. + script: | + pnpm --dir examples/test-app exec expo run:android --variant release --no-bundler + mkdir -p "${{ github.workspace }}/.tmp/test-app-build" + cp "$(find examples/test-app/android/app/build/outputs/apk/release -name '*.apk' | head -1)" "${{ github.workspace }}/.tmp/test-app-build/app-release.apk" + + - name: Stage the binary for upload + id: stage + if: steps.fp.outputs.cached == 'false' + run: | + set -euo pipefail + SRC="${{ github.workspace }}/.tmp/test-app-build" + BINARY="$(find "$SRC" -maxdepth 1 \( -name '*.app' -o -name '*.apk' \) | head -1)" + if [ -z "$BINARY" ]; then + echo "::error::Build produced no .app or .apk under $SRC." + exit 1 + fi + # An APK must span both the maintainer's arm64 and CI's x86_64; a + # regression here (e.g. an unexpected abiFilter) is otherwise silent. + if [ "${{ matrix.platform }}" = "android" ] && ! unzip -l "$BINARY" | grep -q "lib/arm64-v8a/"; then + echo "::error::Release APK has no arm64-v8a libraries." + exit 1 + fi + # Tar the binary: artifact zips drop the executable bit, which would + # leave an installed .app unable to launch. + mkdir -p .tmp/test-app-artifact + tar -czf .tmp/test-app-artifact/binary.tar.gz -C "$SRC" "$(basename "$BINARY")" + echo "publishing ${{ steps.fp.outputs.name }} ($(du -h .tmp/test-app-artifact/binary.tar.gz | cut -f1))" + + # Fork PRs run with a read-only token but can still upload artifacts, and a + # consumer serves whatever it finds by name -- so a fork could publish a + # tampered binary that CI then executes. Only branches in this repo publish; + # a fork's run still builds, which is all the signal a fork PR needs. + - name: Publish to the build cache + if: | + steps.fp.outputs.cached == 'false' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ steps.fp.outputs.name }} + path: .tmp/test-app-artifact/binary.tar.gz + if-no-files-found: error + compression-level: 0 # already gzipped diff --git a/examples/test-app/.gitignore b/examples/test-app/.gitignore index 3d560e411..3feefb351 100644 --- a/examples/test-app/.gitignore +++ b/examples/test-app/.gitignore @@ -1,8 +1,9 @@ .expo/ node_modules/ -# expo prebuild output, regenerated by `expo run:ios` / `expo run:android` -# (what .github/actions/setup-fixture-app does). Untracked and generated, so -# leaving them visible lets a `git commit -a` sweep the whole native project in. -android/ -ios/ +# expo prebuild output, regenerated by `expo run:ios` / `expo run:android`. +# Leaving it visible lets a `git commit -a` sweep the whole native project in, +# and ignoring it is what makes @expo/fingerprint treat this app as CNG and skip +# hashing it. Anchored so it cannot also swallow modules/*/ios, which is source. +/ios +/android diff --git a/examples/test-app/README.md b/examples/test-app/README.md index e52684a0e..bea995d09 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -52,6 +52,30 @@ The app declares `@expo/dom-webview` directly to keep Expo's development runtime on the SDK 56 native module; Android verification failed when the dev client resolved an older transitive copy. +### Build cache + +Local `pnpm test-app:ios` / `test-app:android` cache the native build on disk via +the [`expo-build-disk-cache`](https://github.com/WookieFPV/expo-build-disk-cache) +provider (configured in `app.config.js`), keyed by the +[Expo fingerprint](https://docs.expo.dev/versions/latest/sdk/fingerprint/). A +second run with no native change reuses the first build instead of recompiling; +editing screens never needs a rebuild, because Metro serves JavaScript at +runtime. A fresh checkout still pays for the first native build — the disk cache +only spares you the repeats. + +That fingerprint is why `/ios` and `/android` are gitignored: ignoring the +prebuild output is what makes @expo/fingerprint treat this app as CNG and skip +hashing it. Un-ignore them and the fingerprint starts describing your machine +rather than the project. + +CI does not use the disk cache. `.github/workflows/test-app-build-cache.yml` +builds a **Release** binary (JS embedded, no Metro) per platform when the +fingerprint has no artifact yet, and publishes it as a GitHub Actions artifact +named `fingerprint..`. Jobs that drive the app install it through +`.github/actions/setup-fixture-app`, which downloads that artifact and refreshes +its JS with `@expo/repack-app` (~seconds) — so a JS-only change reuses the same +native binary. A consuming job needs `permissions: actions: read`. + ### iOS simulator From the repo root, install dependencies and run the development build on the diff --git a/examples/test-app/app.config.js b/examples/test-app/app.config.js index f94db38d0..386ca960e 100644 --- a/examples/test-app/app.config.js +++ b/examples/test-app/app.config.js @@ -1,8 +1,5 @@ const accessorySetupConfig = require('./accessory-setup.config.json'); -const buildRunCacheDir = - process.env.AGENT_DEVICE_EXPO_BUILD_CACHE_DIR?.trim() || './.expo/build-run-cache'; - const accessoryInfoPlist = { NSAccessorySetupBluetoothServices: [accessorySetupConfig.serviceUuid], NSAccessorySetupKitSupports: ['Bluetooth'], @@ -16,12 +13,11 @@ module.exports = { version: '1.0.0', orientation: 'default', userInterfaceStyle: 'automatic', - buildCacheProvider: { - plugin: 'expo-build-disk-cache', - options: { - cacheDir: buildRunCacheDir, - }, - }, + // Local `expo run:*` caches the native build on disk, keyed by the Expo + // fingerprint, so a second run with no native change reuses it instead of + // rebuilding. CI does not rely on this — it builds Release and shares the + // result through GitHub artifacts (see .github/workflows/test-app-build-cache.yml). + buildCacheProvider: { plugin: 'expo-build-disk-cache' }, plugins: ['expo-router'], ios: { supportsTablet: true, diff --git a/examples/test-app/package.json b/examples/test-app/package.json index 74db8a433..aa88c5900 100644 --- a/examples/test-app/package.json +++ b/examples/test-app/package.json @@ -29,6 +29,8 @@ "react-native-web": "^0.21.2" }, "devDependencies": { + "@expo/fingerprint": "^0.19.4", + "@expo/repack-app": "^0.7.2", "@types/react": "~19.2.2", "expo-build-disk-cache": "^0.7.4", "typescript": "~6.0.3" diff --git a/examples/test-app/pnpm-lock.yaml b/examples/test-app/pnpm-lock.yaml index 77b0b2d11..7b2cc13c3 100644 --- a/examples/test-app/pnpm-lock.yaml +++ b/examples/test-app/pnpm-lock.yaml @@ -68,6 +68,12 @@ importers: specifier: ^0.21.2 version: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) devDependencies: + '@expo/fingerprint': + specifier: ^0.19.4 + version: 0.19.4 + '@expo/repack-app': + specifier: ^0.7.2 + version: 0.7.2 '@types/react': specifier: ~19.2.2 version: 19.2.14 @@ -595,6 +601,10 @@ packages: '@expo/prebuild-config@56.0.16': resolution: {integrity: sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==} + '@expo/repack-app@0.7.2': + resolution: {integrity: sha512-QUqOH8gOatb3+goOUwa4JoFloeA9L4ef9bDcnDdCAUkociiD8Ae8FFwmgZ9Wvw3LKqeEGXw/ydREyn5fACLpqQ==} + hasBin: true + '@expo/require-utils@56.1.3': resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==} peerDependencies: @@ -1311,6 +1321,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3697,6 +3711,11 @@ snapshots: - supports-color - typescript + '@expo/repack-app@0.7.2': + dependencies: + commander: 14.0.3 + picocolors: 1.1.1 + '@expo/require-utils@56.1.3(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.0 @@ -4484,6 +4503,8 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@7.2.0: {} diff --git a/test/scripts/setup-fixture-app-fallback-smoke.sh b/test/scripts/setup-fixture-app-fallback-smoke.sh new file mode 100755 index 000000000..fc3cf84d9 --- /dev/null +++ b/test/scripts/setup-fixture-app-fallback-smoke.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# Regression test for setup-fixture-app: a build-cache lookup failure must +# degrade to an inline build, never fail the caller. +# +# The fetch step runs under `set -euo pipefail`, so a bare `ART_ID="$(gh api …)"` +# would exit the whole composite on any API outage — turning a cache-service +# blip into a conformance failure, the opposite of what the action promises. This +# extracts that step's actual shell from action.yml (so it cannot drift from a +# copy), runs it with a `gh` that fails the lookup, and asserts it still reaches +# `source=build` and exits 0. +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +ACTION="$ROOT/.github/actions/setup-fixture-app/action.yml" +WORK="$(mktemp -d)" +export WORK +trap 'rm -rf "$WORK"' EXIT + +# Extract the exact `run:` body of the fetch step, then substitute the GitHub +# expressions the composite would have expanded. +node -e ' + const fs = require("fs"); + const yaml = require("yaml"); + const doc = yaml.parse(fs.readFileSync(process.argv[1], "utf8")); + const step = doc.runs.steps.find((s) => s.id === "fetch"); + if (!step) { console.error("no fetch step in action.yml"); process.exit(2); } + let body = step.run + .replaceAll("${{ github.repository }}", "octo/repo") + .replaceAll("${{ github.workspace }}", process.env.WORK); + fs.writeFileSync(process.env.WORK + "/fetch.sh", body); +' "$ACTION" + +# Stubs on PATH: gh fails every call (simulated outage); pnpm yields a fixed +# fingerprint so the step has a name to look up without installing the app. +mkdir -p "$WORK/bin" +cat > "$WORK/bin/gh" <<'STUB' +#!/bin/sh +echo "gh: simulated API outage" >&2 +exit 1 +STUB +cat > "$WORK/bin/pnpm" <<'STUB' +#!/bin/sh +echo '{"hash":"deadbeefcafe"}' +STUB +chmod +x "$WORK/bin/gh" "$WORK/bin/pnpm" + +GITHUB_OUTPUT="$WORK/out" +: > "$GITHUB_OUTPUT" +export GITHUB_OUTPUT + +set +e +PATH="$WORK/bin:$PATH" bash "$WORK/fetch.sh" > "$WORK/log" 2>&1 +RC=$? +set -e + +echo "--- fetch step output ---" +sed 's/^/ /' "$WORK/log" +echo "--- exit code: $RC ---" + +FAIL=0 +if [ "$RC" -ne 0 ]; then + echo "FAIL: a lookup outage exited the step (rc=$RC) instead of falling back." >&2 + FAIL=1 +fi +if ! grep -q '^source=build$' "$GITHUB_OUTPUT"; then + echo "FAIL: expected source=build after a lookup failure; got: $(cat "$GITHUB_OUTPUT")" >&2 + FAIL=1 +fi +if ! grep -q "building inline" "$WORK/log"; then + echo "FAIL: expected a warning that it is building inline." >&2 + FAIL=1 +fi + +if [ "$FAIL" -eq 0 ]; then + echo "PASS: build-cache lookup failure degrades to an inline build." +fi +exit "$FAIL"