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"