App builds
Install the native build matching your Expo fingerprint — from the device, cache, EAS or a local build.
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.
warden app fingerprint ios
warden app ensure ios --udid <udid> --json
# { "appPath": "…", "hash": "…", "source": "installed" | "cache" | "eas" | "build", "installed": true }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.
Variants: a dev client next to an e2e build
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
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)
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
~/.warden/builds/<projectKey>/<platform>/<hash>, then legacy caches. All worktrees of a repo share one cache.build:list --fingerprint-hash, then download. Waits for builds already in flight; triggers the workflow only if eas.trigger is 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.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:
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 withgit ls-filesfrom 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
jsInputsto 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 ofHEAD(eas build:list --git-commit-hash) and only on a clean working tree; otherwise EAS is skipped and the build is local.eas.triggeris ignored. warden app fingerprint --jsonprints the key underfingerprints, and the native hash undernative.
A build lock stops two agents from downloading or building the same hash twice.
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.
Managing the cache
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).
Headless e2e
How warden runs simulators without a window, and the --headless switch that guarantees it even with Simulator.app open.
Framework comparison
The same 8-test suite on one build and one device in warden, Argent, agent-device and tester.army. Suite time, per-test medians, cold start, resources and footprint.