# App builds (/docs/devices/app-builds)



warden installs the native build that matches your Expo fingerprint on a device, taken from the device, the shared cache, EAS or a local build, whichever is first; use it to skip a native rebuild.

```bash
warden app fingerprint ios
warden app ensure ios --udid <udid> --json
# { "appPath": "…", "hash": "…", "source": "installed" | "cache" | "eas" | "build", "installed": true }
```

## Worktrees: `warden dev` [#worktrees-warden-dev]

`expo run:ios` rebuilds natively in every worktree; warden caches the built app by native fingerprint, shared by every worktree of the repo. `warden dev` claims a sim, installs the cached build, starts Metro and opens the dev client: same fingerprint as any earlier build means no prebuild, no Xcode, seconds to a running app; a native change means one local build, cached for every other worktree. Two worktrees missing the same hash at once share one build (build lock). See [Dev client per worktree](/docs/agents/dev).

### Variants: a dev client next to an e2e build [#variants-a-dev-client-next-to-an-e2e-build]

```ts title="warden.config.ts"
projects: [{
  name: "app",
  bundleId: { ios: "com.example.app" },
  build: { configuration: "Release" },                                  // e2e: JS embedded
  fingerprint: { include: "native+js", jsInputs: ["src/**"] },
  variants: {
    dev: { build: { configuration: "Debug" }, fingerprint: { include: "native" } }, // dev client: JS from Metro
  },
}]
```

`warden dev` picks `dev` when it exists; `warden app ensure --variant <name>`, `run|batch --app --variant <name>` and `e2e.<suite>.app: { variant }` pick one explicitly. A variant's keys replace the project's own wholesale. The example app's `bench` variant is the EAS-aware one: it names the `bench` profile and workflow, and the benchmark's `bench app build --eas` uses it. A native-only Release key is salted, so Debug and Release builds of one fingerprint never share a cache entry or an install record (EAS is still looked up by the plain native hash). EAS is on for any project directory with an `eas.json`; set `eas: false` on a project or variant to never consult eas-cli.

### Faster misses: shared compiler caches [#faster-misses-shared-compiler-caches]

When a native build can't be avoided, warden local builds get compiler caches that, unlike DerivedData, hit across worktrees (opt out with `build.cache: false`):

| Platform        | Cache                                                        | How it crosses worktrees                                                 |
| --------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ |
| iOS             | ccache in `$WARDEN_HOME/ccache`                              | `CCACHE_BASEDIR` = git toplevel rewrites absolute paths to relative ones |
| iOS (Xcode 26+) | compilation caching (`COMPILATION_CACHE_ENABLE_CACHING=YES`) | content-addressed                                                        |
| Android         | Gradle build cache (`-Dorg.gradle.caching=true`)             | relocatable task outputs in `~/.gradle`                                  |

ccache only runs when the Podfile enables it: set `ios.ccacheEnabled: true` with `expo-build-properties` (written to `ios/Podfile.properties.json` as `apple.ccacheEnabled`) and `brew install ccache`. `warden doctor` checks both, and warns when React Native is built from source (`ios.buildReactNativeFromSource`) instead of using the prebuilt core. `warden builds prune` also runs `ccache --cleanup`.

### Prebuilt React Native (iOS) [#prebuilt-react-native-ios]

React Native 0.81 added a precompiled iOS core (`RCT_USE_PREBUILT_RNCORE=1`, `RCT_USE_RN_DEP=1` at `pod install`/build time), skipping a source build of React Native on every cache miss. Warden sets both for you, with no repo change, per installed `react-native` version:

| React Native | Behaviour                                                                                                                                            |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| \< 0.81      | not available, nothing set                                                                                                                           |
| 0.81 – 0.83  | opt-in upstream: warden sets both vars (the default build command runs `pod install` when the Pods sandbox is stale or missing, so they take effect) |
| ≥ 0.84       | on by default upstream (Expo SDK 54+ templates also default it): no-op, nothing set, key unchanged                                                   |

Warden never overrides a var you already set, and skips the injection when the project builds React Native from source (`ios.buildReactNativeFromSource: true` via `expo-build-properties`, read from `ios/Podfile.properties.json` or `app.json`). Opt out with `build.prebuiltReactNative: false` or `WARDEN_PREBUILT_RN=0`. Prebuilt output differs from a from-source build, so the cache / install key is salted (`react-native:prebuilt`) while the env is in effect; builds of the same fingerprint never mix. The precompiled core is incompatible with `use_frameworks!`, and you can't step into React Native internals.

Why not share DerivedData? Its build database and dependency files hold absolute paths, so another checkout looks entirely changed and rebuilds anyway, and two concurrent builds in one DerivedData corrupt or block each other.

## Resolution order [#resolution-order]

<Steps>
  <Step>
    **Installed**

     — the device already has this fingerprint hash.
  </Step>

  <Step>
    **Cache**

     — 

    `~/.warden/builds/<projectKey>/<platform>/<hash>`

    , then legacy caches. All worktrees of a repo share one cache.
  </Step>

  <Step>
    **EAS**

     — 

    `build:list --fingerprint-hash`

    , then download. Waits for builds already in flight; triggers the workflow only if 

    `eas.trigger`

     is set.
  </Step>

  <Step>
    **Local build**

     — verified against the fingerprint. Debug by default; set 

    `build.configuration: "Release"`

     to build and find a Release simulator build (the JS bundle is embedded, so no Metro is needed at test time). The artifact is the 

    `.app`

     / 

    `.apk`

     the build wrote; when Gradle or Xcode was up to date and rewrote nothing, it is this project's existing one (

    `android/app/build/outputs`

    , 

    `ios/build`

    , or the DerivedData dir whose workspace is 

    `<root>/ios`

    ). Two unchanged candidates (e.g. two Release flavors) are refused rather than guessed: delete the stale one.
  </Step>
</Steps>

## Release builds and JS changes [#release-builds-and-js-changes]

A Release build embeds its JS bundle, but the native fingerprint doesn't change when only JS does — so a JS edit would count as "already installed" and keep running stale code. Opt in to a JS-aware key:

```ts title="warden.config.ts"
import { defineConfig } from "@delacour/warden/config";

export default defineConfig({
  projects: [{
    name: "app",
    bundleId: { ios: "com.example.app" },
    build: { configuration: "Release" },
    fingerprint: { include: "native+js", jsInputs: ["src/**", "assets/**", "app.json"] }
  }]
});
```

The cache and install key becomes `sha256(native fingerprint + a content hash of the matching files)`. The files are git-tracked and untracked-not-ignored ones under the project root, hashed with their paths, so a rename counts and build output never does. A clean tracked file is identified by the blob id git already has in its index, so only edited, untracked or symlinked files are read (committing an edit does not change the key). The native fingerprint and this hash are computed side by side. Globs that match nothing are an error. The post-build check uses the same key. Upgrading from a release before this change rebuilds once: the key format is new.

Trade-offs:

* **Workspace packages count too.** A `../` glob (`"../../../packages/engine/src/**"`) hashes files outside the project root; they are listed with `git ls-files` from where they live and keyed by their root-relative path, so the key is identical in every checkout.
* **Any matched edit is a rebuild.** A Release build is minutes, so keep `jsInputs` to what the bundle really reads (not tests, docs or e2e specs).
* **EAS is looked up by commit, not fingerprint.** EAS indexes builds by the native hash, which can't see JS. With `native+js`, warden asks for a build of `HEAD` (`eas build:list --git-commit-hash`) and only on a clean working tree; otherwise EAS is skipped and the build is local. `eas.trigger` is ignored.
* `warden app fingerprint --json` prints the key under `fingerprints`, and the native hash under `native`.

A build lock stops two agents from downloading or building the same hash twice.

## Project detection [#project-detection]

With no config, warden detects a single project from `app.json` / `app.config.*` (pass `--bundle-id` for a dynamic config). For monorepos or custom commands, add a [`warden.config.ts`](/docs/reference/configuration).

## Managing the cache [#managing-the-cache]

```bash
warden builds ls
warden builds prune --max-size 20G        # LRU; build-locked entries stay
warden builds import App.app --hash <h>   # file a local build under a fingerprint
```

`--max-size` defaults to `20G` (or `$WARDEN_MAX_CACHE_SIZE`). `warden gc` runs the same prune (`--max-cache-size`), keeping builds used in the last 30 min, so the cache stays bounded without you calling it. The cache lives under `$WARDEN_HOME/builds` (`~/.warden/builds`).
