# React Native E2E tests in GitHub Actions (/docs/e2e/ci/github-actions)



`warden e2e` runs on a macOS GitHub Actions runner ([other CI](/docs/e2e/ci)). The smallest workflow that works is the e2e action, which installs warden, restores the build and golden caches and uploads the evidence when a spec fails:

```yaml title=".github/workflows/e2e.yml"
name: e2e
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  e2e:
    runs-on: macos-26
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: delacournz/warden/.github/actions/e2e@v0.16.0
        with:
          suite: my-app
          args: --base origin/main
          save-cache: ${{ github.event_name != 'pull_request' }}
```

`suite` is the name under `e2e` in `warden.config.ts` (optional with one suite). `--base origin/main` runs only the specs the diff needs ([Affected](/docs/e2e/affected)), so `fetch-depth: 0` is needed for the merge-base. The job fails when a required spec fails. Details of what the action does and every input: [Run e2e in GitHub Actions](#run-e2e-in-github-actions).

Without the action, install warden and call it yourself:

```yaml
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: oven-sh/setup-bun@v2
      - run: bun install
      - uses: delacournz/warden/.github/actions/install-warden@main
      - run: warden e2e my-app --base origin/main --count 2 --report e2e-report.json
```

The same command runs locally or in a pre-push hook.

## Install warden [#install-warden]

Two routes:

* **npm:** `npm i -g @delacour/warden`, or `bunx @delacour/warden`. It is a node shim plus one prebuilt binary per platform (macOS and Linux, `arm64` and `x64`); an install with `--omit=optional` leaves the shim without a binary.
* **Install action:** `delacournz/warden/.github/actions/install-warden` downloads the release binary for the runner (`warden-darwin-arm64` …), checks it against `checksums.txt`, installs it to `~/.local/bin/warden` and puts that on `PATH`. Inputs: `token` (default: the workflow's `github.token`, which can read the public releases), `version` (a tag such as `v0.1.0`; default `latest`) and `repo` (default `delacournz/warden`).

A private fork in `repo` needs a `token` with Contents: read on that repo.

`@delacour/warden-e2e` is a dev dependency of your app at the same version as warden; `bun install` brings it in. A mismatch makes the preload exit 2.

## What the runner needs [#what-the-runner-needs]

* macOS for iOS simulators, with Xcode and the iOS runtime installed; Android needs the SDK. There is no Windows support.
* No bun install: warden runs the specs with the bun built into its own binary, so CI needs no bun install (`WARDEN_BUN` picks a different one).
* No prior state. The `--profile` is created on claim, cloned from a golden image that is built on first use (about four minutes on a hosted runner); keep it between runs with `warden golden export` / `import` and `actions/cache` ([below](#reuse-a-prebuilt-app-and-the-golden)). If cloning fails it falls back to `simctl create`.

## Gate a pull request [#gate-a-pull-request]

`warden e2e <suite> --base origin/main` runs only the specs the diff needs ([Affected](/docs/e2e/affected)). It exits 0 when every required spec passed or nothing was affected (no device claimed), and 1 when a required one failed. Parsed imports are cached under `$WARDEN_HOME/affected`. The compiled `warden` binary carries no TypeScript compiler, so add `typescript` 5.0 or later as a dev dependency of the app.

Keep the evidence when a run fails:

```yaml
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: warden-e2e
          path: |
            e2e-report.json
            ~/.warden/batches
```

## Reuse a prebuilt app and the golden [#reuse-a-prebuilt-app-and-the-golden]

On a fresh runner, a `simulator`-kind suite (`app: true`) pays twice before the first spec runs: a native Release build of the simulator base, and the golden image's first boot. When another job already builds the Release simulator `.app`, import it; cache the golden between runs.

```yaml title=".github/workflows/ios.yml"
jobs:
  ios-build:
    runs-on: macos-26
    steps:
      - uses: actions/checkout@v4
      # … install dependencies, pod install …
      - run: |
          xcodebuild -workspace ios/App.xcworkspace -scheme App -configuration Release \
            -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
            ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO \
            -derivedDataPath ios/build/dd build
      # upload-artifact zips files and drops the executable bit: ship the .app as a tarball
      - run: tar -czf App.app.tgz -C ios/build/dd/Build/Products/Release-iphonesimulator App.app
      - uses: actions/upload-artifact@v4
        with: { name: ios-simulator-app, path: App.app.tgz }

  e2e:
    needs: ios-build
    runs-on: macos-26
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: oven-sh/setup-bun@v2
      - run: bun install
      - uses: delacournz/warden/.github/actions/install-warden@main

      - uses: actions/download-artifact@v4
        with: { name: ios-simulator-app }
      - run: tar -xzf App.app.tgz && warden builds import App.app --kind simulator-base

      - id: golden
        run: echo "name=$(warden golden key --profile iphone-17)" >> "$GITHUB_OUTPUT"
      - id: golden-cache
        uses: actions/cache/restore@v4
        with: { path: ~/warden-goldens, key: "${{ steps.golden.outputs.name }}" }
      - run: warden golden import ~/warden-goldens

      - run: warden e2e mobile --all --count 1

      - if: steps.golden-cache.outputs.cache-hit != 'true'
        run: warden golden export ~/warden-goldens --profile iphone-17
      - if: steps.golden-cache.outputs.cache-hit != 'true'
        uses: actions/cache/save@v4
        with: { path: ~/warden-goldens, key: "${{ steps.golden.outputs.name }}" }
```

**The base.** `warden builds import <App.app> --kind simulator-base` files the `.app` as the base every repack clones, under warden's own native fingerprint of the project (`--project <dir>` when the app is not in the working directory). The next `warden e2e` / `warden repack` bundles this checkout's JS into it in seconds instead of building. The import refuses a `.app` that is not a simulator build, has no slice for this Mac (`arm64` on Apple silicon), has another bundle id (`--bundle-id` overrides), is a Debug or expo-dev-client build, or whose executable lost its mode bits. A `main.jsbundle` embedded by the build (no `SKIP_BUNDLING`) is stripped from the cached copy; every repack writes its own.

The fingerprint is warden's, not `@expo/fingerprint`'s: they differ. To key an artifact or check which base a run will look for, print it with `warden app fingerprint ios --kind simulator --json` (`fingerprints.ios`, and `hashes.ios` = `<fp>:base`). `--hash <fp>` files the import under a fingerprint you computed earlier. A base whose fingerprint no longer matches the checkout is never used: the run builds one, as without an import.

**The golden.** `warden golden key` prints the golden's name, `warden-golden-<profile>-<key>-r<recipe>`, where the key covers the Xcode build, the runtime build, the device type and the recipe: a runner image with another Xcode or runtime gets another cache entry. `warden golden import <dir>` exits 0 whatever it finds: a cache miss, an archive for another Xcode or runtime, or a broken one is skipped with the reason, and the first claim builds the golden as before. Use the same `--profile` (and `--runtime`) as the suite's devices. Keep the export and save steps on the default `success()` condition (not `always()` or `!cancelled()`): a golden saved by a failed run is restored by every later run. `warden golden export` itself refuses a golden that never settled after its first boot. An archive is about 2 GB before compression; GitHub keeps 10 GB of caches per repository. See [Golden images](/docs/sims/golden-images#carry-a-golden-between-machines).

## Run e2e in GitHub Actions [#run-e2e-in-github-actions]

`delacournz/warden/.github/actions/e2e` is a composite action that runs a suite on a macOS runner with every cache and guard this page describes already wired up. Call it after `actions/checkout`:

```yaml title=".github/workflows/e2e.yml"
name: e2e
on:
  pull_request:
    paths: [apps/mobile/**, bun.lock]
  push:
    branches: [main]
    paths: [apps/mobile/**, bun.lock]

permissions:
  contents: read

jobs:
  ios:
    runs-on: macos-26
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 }
      - uses: delacournz/warden/.github/actions/e2e@v0.16.0
        with:
          working-directory: apps/mobile
          suite: mobile
          count: 1
          retry: 1
          args: --base origin/main
          save-cache: ${{ github.event_name != 'pull_request' }}
```

Pin the action to a commit SHA or release tag. In order, it:

1. Sets `WARDEN_TELEMETRY_DISABLED=1` and `DO_NOT_TRACK=1`, selects `xcode-path` through `DEVELOPER_DIR` when given, and lets `simctl` answer once so the first CoreSimulator call does not land inside a claim's 30 s timeout.
2. Installs bun from npm (`npm i -g bun@<version>`) when none is on `PATH`, so it needs no third-party action; `bun-version` defaults to the root `packageManager`. Runs `install-command` (default `bun install --frozen-lockfile`) with `npm-token` as `NPM_TOKEN`.
3. Installs the release binary of `warden-version` (checksum verified) and runs `warden doctor --fetch-helper`.
4. Restores the caches below and imports `app-path` when given. On a native build cache miss it says so first (a `native build cache miss` notice: the app is built from source before the first spec), installs `ccache` (a pinned release binary when none is on `PATH`, Homebrew if that download fails) and restores the compiler cache. Then it builds the app in its own step (`warden e2e --build-only` with the same suite, profile and `args`, so the run finds it cached), runs `warden e2e` with `--report` and the inputs as flags, and prints one line splitting the job's time up to the suite's end:

   ```text
   warden e2e: took 2m40s — setup 1m22s · tests 1m18s · teardown 0s
   warden e2e exited 0 after 161 s
   job timing: prep 1m46s (workflow steps 1m20s · action 26s) · app build 12s · warden e2e 2m41s · total 4m39s
   ```

   `workflow steps` is the job's steps before this action, `action` is its own install and cache steps, `app build` is the build step (seconds on a cache hit, minutes on a native build), and `warden e2e` matches warden's `took` line (claim, boot, install and the specs). The job's start comes from the jobs API with `github-token`, which needs `actions: read` in the workflow's `permissions`; without it the line reads `action prep 26s · warden e2e 2m41s (job start unknown: …)`.
5. Prints each native build log the run wrote into a collapsed `::group::`, with ccache's hit rate. While a build runs, warden logs `still building ios (6m 0s): <last build line>` every minute, so a long build never looks hung.
6. On failure prints every `screen-at-failure.txt` into the job log (one `::group::` each), then collects the run's [artifacts](#artifacts) and uploads each group as its own artifact (`retention-days`, default 3).
7. After a green run only, and only when `save-cache` is `true`, saves the caches. Restore and save are separate steps, so a failed run saves nothing. The compiler cache is the exception: it is saved after a failed run too, since its objects are content-addressed, but only when the build stored new objects, under a per-run key (`<key>-<run id>-<attempt>`) so it keeps growing.

| Input                             | Default                                                          | Meaning                                                                                                                                                                                                    |
| --------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `working-directory`               | `.`                                                              | where `warden.config.ts` lives                                                                                                                                                                             |
| `suite`                           | suite's only                                                     | `e2e.<suite>`                                                                                                                                                                                              |
| `profile`, `count`, `retry`       | the suite's                                                      | `--profile`, `--count`, `--retry`                                                                                                                                                                          |
| `args`                            | none                                                             | extra `warden e2e` flags (`--all`, `--base origin/main`, `--specs a,b`)                                                                                                                                    |
| `app-path`                        | none                                                             | a simulator `.app` built earlier in the workflow, imported as the base                                                                                                                                     |
| `warden-version`                  | `latest`                                                         | release tag, or `none` when warden is already on `PATH`                                                                                                                                                    |
| `bun-version`                     | root `packageManager`                                            | bun to install from npm when none is on `PATH`; `none` skips                                                                                                                                               |
| `install-command`                 | `bun install --frozen-lockfile`                                  | empty skips; `install-working-directory` picks where it runs                                                                                                                                               |
| `github-token`                    | `github.token`                                                   | reads the job's start for the `job timing` line (needs `actions: read`); never passed to warden                                                                                                            |
| `xcode-path`                      | runner default                                                   | e.g. `/Applications/Xcode_27.2.app`                                                                                                                                                                        |
| `headroom-gb`                     | 1 on hosts of 16 GB or less                                      | `WARDEN_CAPACITY_HEADROOM_GB`                                                                                                                                                                              |
| `suite-timeout-minutes`           | `35`                                                             | stops the suite (SIGINT, SIGKILL 60 s later) so a hung run still prints and uploads its evidence. The app build runs before it and is not counted: set the job timeout above a cold native build plus this |
| `report-path`                     | `warden-e2e-report.json`                                         | where the report goes                                                                                                                                                                                      |
| `cache`                           | `github`                                                         | `github` saves and restores with `actions/cache`; `none` leaves storing to you (see below)                                                                                                                 |
| `cache-prefix`                    | `warden-e2e-v1`                                                  | change it to drop every cache                                                                                                                                                                              |
| `js-inputs`                       | `src assets app.config.ts index.ts metro.config.js package.json` | git pathspecs that key the repack cache; mirror `kinds.simulator.jsInputs`                                                                                                                                 |
| `save-cache`                      | `true`                                                           | `false` on pull requests                                                                                                                                                                                   |
| `compiler-cache`                  | `true`                                                           | on a native build cache miss, install `ccache` and restore / save the compiler cache (skipped when the project sets `build.cache: false`); `false` skips both                                              |
| `artifacts`                       | `all`                                                            | groups to collect: `all`, `none`, or a list of `report,batches,logs,app,golden` (see [Artifacts](#artifacts))                                                                                              |
| `upload-artifacts`                | `true`                                                           | `false` collects without uploading: upload from `artifact-paths` yourself                                                                                                                                  |
| `retention-days`, `artifact-name` | `3`, `warden-e2e`                                                | how long the artifacts are kept; their name prefix (`warden-e2e-report`, …)                                                                                                                                |

Outputs: `report-path` (absolute), `passed` (`true` / `false`), `cache-groups` (the JSON below), `artifact-groups` (comma list) and `artifact-paths` (JSON, see [Artifacts](#artifacts)). The job fails when the gate fails.

### Artifacts [#artifacts]

After the run, pass or fail, the action collects what it left on the runner into groups and uploads each as `<artifact-name>-<group>`:

| Group     | What                                                                                                                                                                                                                   | Size          |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `report`  | `e2e-report.json`                                                                                                                                                                                                      | KB            |
| `batches` | `~/.warden/batches`: per-spec logs, `results.jsonl`, `screen-at-failure.txt`, `metro.log`                                                                                                                              | KB to MB      |
| `logs`    | `~/.warden/logs`: native build logs; `simd-logs/`: each device helper's `~/.warden/simd/*.log` (stalls, a wedged accessibility service)                                                                                | MB            |
| `app`     | `app.tar`: the `.app` / `.apk` builds the run used (`warden builds ls`, last used since the suite started), paths kept relative to `$HOME` (`.warden/builds/<project>/…/App.app`) so a base and its repack never clash | \~100 MB each |
| `golden`  | `golden.tar`: the golden simulator archive from `~/warden-goldens`. `tar -xf golden.tar -C ~/warden-goldens && warden golden import ~/warden-goldens` restores it on a Mac with the same Xcode and runtime             | GBs           |

`app` and `golden` are tarballs because `upload-artifact` drops the executable bit and follows symlinks: a raw `.app` would not launch and a raw golden would not import. They are written to `$RUNNER_TEMP/warden-artifacts`, so the golden needs its size again in free disk.

`all` (the default) is every group after a green run and `report,batches,logs,golden` after a failed one: a failed run's golden is what reproduces it. A green run whose golden was restored from the cache skips it: the cache already holds the same archive, and uploading GBs is the slowest step of a warm run. Name `golden` in a list to collect it anyway. List groups to pick your own (`artifacts: report,batches`), or `none` to skip the rest. `logs` is collected whatever you pick, even with `none`: it is small, and when the build broke it is the only evidence. The golden is never built just to upload it: a run that broke before building one has no `golden` group. When it was built but not archived, collecting it runs `warden golden export --allow-unsettled`, and the cache save reuses that archive.

To upload with your own steps, set `upload-artifacts: false` and read `artifact-paths`, `{report, batches, logs, app, golden}` each a list of absolute paths (empty when not collected):

```yaml
- id: e2e
  uses: delacournz/warden/.github/actions/e2e@<sha>
  with:
    working-directory: apps/mobile
    artifacts: report,batches,app
    upload-artifacts: false
- if: always()
  uses: actions/upload-artifact@v4
  with:
    name: mobile-app
    path: ${{ join(fromJSON(steps.e2e.outputs.artifact-paths).app, fromJSON('"\n"')) }}
```

### The caches [#the-caches]

| Cache             | Path                                         | Key                                                                                                              | Saved                                                                                  |
| ----------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Native build      | `~/.warden/builds` without the repacks       | runner + `warden app fingerprint ios --kind simulator --json` (`.fingerprints.ios`)                              | after a green run on a miss                                                            |
| JS repack         | `~/.warden/builds/*/ios/*/simulator/repacks` | the fingerprint + a hash of `js-inputs` (`git ls-files -s`)                                                      | after a green run on a miss                                                            |
| Golden simulator  | `~/warden-goldens`                           | `warden golden key --profile <p>` (Xcode build, runtime build, device type, recipe)                              | after a green run on a miss; only for a suite with `golden: true`                      |
| Compiler (ccache) | `~/.warden/ccache`                           | runner + the fingerprint + the run; restores the newest save for this fingerprint, else for any (`restore-keys`) | after any run whose native build stored new objects; capped at 2 GB (`CCACHE_MAXSIZE`) |

A native build is minutes of `xcodebuild`, the JS bundle about 3.5 minutes on a hosted runner, and a cold golden up to five more minutes of first boot and settling. A pull request restores what the default branch saved but never saves (`save-cache: false`), so a branch cannot evict the default branch's entries; a push to the default branch fills them. A native change (a new native dependency, a config plugin, an SDK upgrade) misses the native build cache: the app is built from source, 15-20 minutes on a 3-core hosted runner. The compiler cache restored from the previous fingerprint cuts that for the C, C++ and Objective-C sources that did not change (most pods); Swift is not cached. A JS-only change misses only the repack cache. A new Xcode or runtime image misses only the golden. The caches hold files, not `warden.db`: a base or repack dir that comes back complete with no row is registered on its first lookup (`warden builds --json` shows `"source": "restored"`) and used as it is.

### Bring your own cache [#bring-your-own-cache]

With `cache: none` the action never calls `actions/cache`. It still imports the golden from `~/warden-goldens` before the run, but never exports it for the cache: a golden your cache already restored would be exported for nothing. When your cache missed it, run the golden's `beforeSave` after a green run, then save the directory. Collecting the `golden` [artifact](#artifacts) (in the default `all`) already archives a missing golden there, so `beforeSave` then has nothing left to do. `warden ci cache --json` (the action's `cache-groups` output) lists them, on any CI:

```json
{
  "builds": { "kind": "paths", "key": "warden-e2e-v1-builds-macOS-ARM64-<fp>", "paths": ["/Users/runner/.warden/builds", "!/Users/runner/.warden/builds/*/ios/*/simulator/repacks"] },
  "repack": { "kind": "paths", "key": "warden-e2e-v1-repack-macOS-ARM64-<fp>-<js>", "paths": ["/Users/runner/.warden/builds/*/ios/*/simulator/repacks"] },
  "ccache": { "kind": "paths", "key": "warden-e2e-v1-ccache-macOS-ARM64-<fp>", "restoreKeys": ["warden-e2e-v1-ccache-macOS-ARM64-"], "paths": ["/Users/runner/.warden/ccache"] },
  "golden": {
    "kind": "archive",
    "key": "warden-e2e-v1-macOS-ARM64-warden-golden-iphone-17-<key>-r2",
    "paths": ["/Users/runner/warden-goldens"],
    "afterRestore": "warden golden import /Users/runner/warden-goldens",
    "beforeSave": "warden golden export /Users/runner/warden-goldens --allow-unsettled"
  }
}
```

A `paths` group is stored as it is (a `!` path is left out of the one before it). `restoreKeys` (only `ccache`) are prefixes to fall back to on a miss: any older compiler cache helps, and it only helps when `ccache` is on `PATH` (`brew install ccache`). An exact key is immutable in most CI caches, so save the compiler cache under a key of your own that starts with `key` (the action appends the run id), and only when `ccache --print-stats` reports `local_storage_write` above 0. `ccache` is `null` when the project sets `build.cache: false`: the build never calls it. An `archive` group needs its commands around it: `afterRestore` after you restore it, `beforeSave` before you save it. With `cache: none` the action runs `afterRestore` for you; `beforeSave` is yours. Run it from the project (`--project <dir>`), with the same `--prefix`, `--profile` and `--js-input` you pass the action. On a runner with a persistent cache volume (Namespace, a self-hosted Mac), mount the paths and skip the keys:

```yaml
- uses: namespacelabs/nscloud-cache-action@v1
  with:
    path: |
      ~/.warden/builds
      ~/warden-goldens
- uses: delacournz/warden/.github/actions/e2e@<sha>
  with:
    working-directory: apps/mobile
    cache: none
- name: Keep the golden on the volume
  if: success()
  working-directory: apps/mobile
  run: '[ -n "$(ls -A ~/warden-goldens 2>/dev/null)" ] || warden golden export ~/warden-goldens --allow-unsettled'
```

On another CI, key each group by its `key` and restore before `warden e2e`:

```bash
groups=$(warden ci cache --json --project apps/mobile)
# restore every group's paths under its key, then:
warden golden import ~/warden-goldens
warden e2e --project apps/mobile
# after a green run, only when the golden missed: warden golden export ~/warden-goldens --allow-unsettled; then save each group under its key
```

### Reuse an app built earlier in the workflow [#reuse-an-app-built-earlier-in-the-workflow]

Build the `.app` with ad-hoc signing and pass it in. `CODE_SIGNING_ALLOWED=NO` leaves the simulator app without entitlements, and every Keychain call then fails with `errSecMissingEntitlement` (-34018).

```yaml
      - run: |
          xcodebuild -workspace ios/App.xcworkspace -scheme App -configuration Release \
            -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
            ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGN_IDENTITY=- \
            -derivedDataPath ios/build/dd build
      - uses: delacournz/warden/.github/actions/e2e@v0.16.0
        with:
          app-path: ios/build/dd/Build/Products/Release-iphonesimulator/App.app
```

`warden builds import` prints a warning when the `.app` has no `application-identifier` entitlement. An ad-hoc simulator build signs with no entitlements and links them into the executable instead: `otool -X -s __TEXT __entitlements App.app/App` shows them, `codesign -d --entitlements :- App.app` shows an empty list. warden reads both.

### Troubleshooting [#troubleshooting]

| Symptom                                                                                                                          | Cause                                                                                                                        | Fix                                                                                                                                                                                                 |
| -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `queued #1 of 1 … mem binds: need 1792 of 0 memory`                                                                              | a 7 GB hosted runner leaves no budget after warden's 8 GB headroom                                                           | the action sets `WARDEN_CAPACITY_HEADROOM_GB=1`; a lone claim is admitted either way                                                                                                                |
| `xcrun simctl list devices … did not finish within 30s`                                                                          | the first CoreSimulator call of a cold image, under a native build's load                                                    | the action calls it once before the run; keep `count` at 1 on a 3-core runner                                                                                                                       |
| first spec on each simulator fails, the rest pass                                                                                | the in-sim bridge is up but silent                                                                                           | `retry: 1`; warm-up retries its snapshot for 120 s and warms one device at a time                                                                                                                   |
| one simulator's every spec fails `E_TIMEOUT` (`did not answer within the query's 10.0s timeout`), warm-up `timed out after 120s` | the sim's accessibility service in backboardd wedged on first use (its log: `Could not auto-register for pid status change`) | the warm-up restarts backboardd once on that line (`warm-<n>.log`: `accessibility service wedged: restarted backboardd`); upgrade warden if you still see it                                        |
| `errSecMissingEntitlement` / `-34018` in the app                                                                                 | simulator app built with `CODE_SIGNING_ALLOWED=NO`                                                                           | build with `CODE_SIGN_IDENTITY=-`                                                                                                                                                                   |
| golden "not settled after 300s"                                                                                                  | a loaded 3-core runner                                                                                                       | the run still works. With `cache: github` the action exports it with `warden golden export --allow-unsettled`, so the next run restores it instead of building it again. A bigger runner settles it |
| `warden: command not found`                                                                                                      | `warden-version: none` without an earlier install                                                                            | install warden first or leave the default                                                                                                                                                           |
| `bun: command not found` after a blocked `setup-bun`                                                                             | an allowlist blocks third-party actions                                                                                      | the action never uses one: leave `bun-version` empty or pin it                                                                                                                                      |
| cache never hits                                                                                                                 | the key moved: Xcode image, fingerprint or `js-inputs` changed                                                               | read the `Key the caches` step: it prints the fingerprint and golden name                                                                                                                           |

## Reuse an EAS build [#reuse-an-eas-build]

When EAS Build already makes a Release simulator build of the app (an `eas.json` profile with `ios.simulator: true`, `ios.buildConfiguration: "Release"` and `developmentClient: false`; see [Expo Workflows](/docs/e2e/ci/eas-workflows#add-a-build-profile)), the runner can download the one for this native fingerprint instead of building it. Add an Expo access token as the `EXPO_TOKEN` repository secret, set up EAS CLI with `expo/expo-github-action`, and hand the `.app` to the e2e action:

```yaml title=".github/workflows/e2e.yml"
name: e2e
on:
  pull_request:

permissions:
  contents: read

jobs:
  ios:
    runs-on: macos-26
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - uses: expo/expo-github-action@v9
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      - name: Download the EAS build for this fingerprint
        id: eas
        run: |
          set -euo pipefail
          profile=e2e-ios-simulator
          hash=$(eas fingerprint:generate --platform ios --build-profile "$profile" --json | jq -r .hash)
          id=$(eas build:list --platform ios --build-profile "$profile" --fingerprint-hash "$hash" \
            --status finished --simulator --limit 1 --json --non-interactive | jq -r '.[0].id // empty')
          if [ -n "$id" ]; then
            echo "app-path=$(eas build:download --build-id "$id" --json | jq -r .path)" >> "$GITHUB_OUTPUT"
          fi

      - uses: delacournz/warden/.github/actions/e2e@v0.16.0
        with:
          suite: my-app
          args: --base origin/main
          install-command: ""
          app-path: ${{ steps.eas.outputs.app-path }}
```

* `eas fingerprint:generate` hashes the native project the way EAS indexes builds (`@expo/fingerprint`), so it needs the dependencies installed first. It is not warden's fingerprint: `warden builds import` files the `.app` under warden's own.
* `eas build:download --build-id <id> --json` prints `{ "path": … }`, the extracted `.app`.
* No build for the fingerprint (a native change nobody built yet): `app-path` stays empty and the action builds the base on the runner, as without EAS.
* `install-command: ""` because the dependencies are already installed for the fingerprint.

Without the action, the same `.app` goes through `warden builds import "$APP" --kind simulator-base`, then `warden e2e my-app`, which repacks the JavaScript into it before the first install.

## Start an EAS workflow from GitHub Actions [#start-an-eas-workflow-from-github-actions]

To run the specs on an EAS macOS worker instead of a GitHub runner, keep the job in [Expo Workflows](/docs/e2e/ci/eas-workflows) and start it from GitHub Actions:

```yaml title=".github/workflows/e2e-eas.yml"
name: e2e on EAS
on:
  pull_request:

jobs:
  eas:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: expo/expo-github-action@v9
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: eas workflow:run .eas/workflows/e2e.yml --wait -F suite=my-app
```

`--wait` keeps the step running until the EAS run finishes. A Linux runner is enough: the build and the simulator run on EAS. With the Expo GitHub app linked to the repository, an `on.pull_request` trigger in the EAS workflow does the same without a GitHub Actions job.

## Headless and cleanup [#headless-and-cleanup]

Simulators boot without a window. On a CI runner nothing else is open, so no flag is needed; `--headless` guarantees it when Simulator.app is running ([Headless](/docs/sims/headless)). Under `CI`, a passed gate shuts down the simulators and emulators warden created for the run, and a failed run leaves them booted. `--no-shutdown` keeps them up. A leftover `test.only` fails the run under `CI`.
