feat: keep replay session active on request #441
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 trusted artifact yet. Runs on every | |
| # push to main and every PR so a same-head consumer can wait for a scheduled | |
| # producer (workflow_dispatch cannot reach a workflow that is not yet on main). | |
| on: | |
| push: | |
| branches: | |
| - main | |
| pull_request: | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| # Look up whether this fingerprint's artifact already exists across runs. | |
| actions: read | |
| jobs: | |
| fingerprint: | |
| name: Resolve native fingerprint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| outputs: | |
| matrix: ${{ steps.fingerprint.outputs.matrix }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Setup toolchain | |
| uses: ./.github/actions/setup-node-pnpm | |
| with: | |
| cache-dependency-path: examples/test-app/pnpm-lock.yaml | |
| - name: Setup test app dependencies | |
| uses: ./.github/actions/setup-test-app-dependencies | |
| - name: Typecheck test app | |
| run: pnpm test-app:typecheck | |
| - name: Resolve native fingerprint | |
| id: fingerprint | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| ARTIFACT_NAME_RESOLVER=".github/actions/setup-fixture-app/resolve-artifact-name.sh" | |
| IOS_NAME="$(sh "$ARTIFACT_NAME_RESOLVER" ios)" | |
| ANDROID_NAME="$(sh "$ARTIFACT_NAME_RESOLVER" android)" | |
| EXPECTED_HEAD_SHA="${{ github.event.pull_request.head.sha || github.sha }}" | |
| TRUSTED_ARTIFACT=".github/actions/setup-fixture-app/trusted-artifact.mjs" | |
| IOS_ARTIFACT="$(node "$TRUSTED_ARTIFACT" find "${{ github.repository }}" "$IOS_NAME" "$EXPECTED_HEAD_SHA")" | |
| ANDROID_ARTIFACT="$(node "$TRUSTED_ARTIFACT" find "${{ github.repository }}" "$ANDROID_NAME" "$EXPECTED_HEAD_SHA")" | |
| IOS_BUILD=true | |
| if [ -n "$IOS_ARTIFACT" ] || | |
| { [ "${{ github.event_name }}" = "pull_request" ] && | |
| [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; }; then | |
| IOS_BUILD=false | |
| fi | |
| ANDROID_BUILD=true | |
| if [ -n "$ANDROID_ARTIFACT" ]; then | |
| ANDROID_BUILD=false | |
| fi | |
| MATRIX="$(jq -cn \ | |
| --arg iosName "$IOS_NAME" \ | |
| --argjson iosBuild "$IOS_BUILD" \ | |
| --arg androidName "$ANDROID_NAME" \ | |
| --argjson androidBuild "$ANDROID_BUILD" \ | |
| '{ | |
| include: [ | |
| { | |
| name: "iOS Release", | |
| platform: "ios", | |
| runsOn: (if $iosBuild then "macos-26" else "ubuntu-latest" end), | |
| artifactName: $iosName, | |
| build: $iosBuild | |
| }, | |
| { | |
| name: "Android Release", | |
| platform: "android", | |
| runsOn: "ubuntu-latest", | |
| artifactName: $androidName, | |
| build: $androidBuild | |
| } | |
| ] | |
| }')" | |
| echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" | |
| release: | |
| name: ${{ matrix.name }} | |
| needs: fingerprint | |
| runs-on: ${{ matrix.runsOn }} | |
| timeout-minutes: 60 | |
| concurrency: | |
| group: test-app-${{ matrix.artifactName }}-${{ github.event.pull_request.number || github.ref_name }} | |
| cancel-in-progress: false | |
| strategy: | |
| # Independent caches; one platform failing must not withhold the other's. | |
| fail-fast: false | |
| matrix: ${{ fromJSON(needs.fingerprint.outputs.matrix) }} | |
| steps: | |
| - name: Checkout | |
| if: matrix.build | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Setup toolchain | |
| if: matrix.build | |
| uses: ./.github/actions/setup-node-pnpm | |
| with: | |
| cache-dependency-path: | | |
| pnpm-lock.yaml | |
| examples/test-app/pnpm-lock.yaml | |
| - name: Setup test app dependencies | |
| if: matrix.build | |
| uses: ./.github/actions/setup-test-app-dependencies | |
| # The generated Android project is safe to assemble without an emulator. | |
| # Keep Gradle's heavy dependency/transforms cache, but never let an | |
| # untrusted fork PR write it. | |
| - name: Setup Gradle cache | |
| if: matrix.platform == 'android' && matrix.build | |
| uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 | |
| with: | |
| cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} | |
| # `--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. | |
| # Fork PRs cannot safely publish this binary; their iOS workflow builds the | |
| # same Release app inline on a cache miss, so compiling it here would double | |
| # the macOS cost without adding signal. | |
| - name: Build the iOS Release app | |
| if: | | |
| matrix.platform == 'ios' && | |
| matrix.build && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository) | |
| 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" | |
| # Deliberately no `set -o pipefail`: `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' && matrix.build | |
| 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" | |
| # CNG's generated Android project exposes the ordinary Gradle Release task; | |
| # it builds and packages every ABI without device resolution. Installation | |
| # and launch are deliberately proven by the Android consumer instead. | |
| - name: Build the Android Release apk | |
| if: matrix.platform == 'android' && matrix.build | |
| run: | | |
| set -euo pipefail | |
| pnpm --dir examples/test-app exec expo prebuild --platform android --no-install | |
| ( | |
| cd examples/test-app/android | |
| ./gradlew :app:assembleRelease | |
| ) | |
| mkdir -p "${{ github.workspace }}/.tmp/test-app-build" | |
| cp examples/test-app/android/app/build/outputs/apk/release/app-release.apk \ | |
| "${{ github.workspace }}/.tmp/test-app-build/app-release.apk" | |
| - name: Stage the binary for upload | |
| id: stage | |
| if: | | |
| matrix.build && | |
| (matrix.platform == 'android' || | |
| github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository) | |
| 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 ${{ matrix.artifactName }} ($(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. | |
| # Android still builds on forks; iOS gets equivalent build/install signal | |
| # from the smoke workflow's inline cache-miss fallback. | |
| - name: Publish to the build cache | |
| if: | | |
| matrix.build && | |
| (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: ${{ matrix.artifactName }} | |
| path: .tmp/test-app-artifact/binary.tar.gz | |
| if-no-files-found: error | |
| compression-level: 0 # already gzipped |