warden

App builds

Install the native build matching your Expo fingerprint — from the device, cache, EAS or a local build.

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

Worktrees: warden dev instead of expo run:*

expo run:ios rebuilds natively in every worktree: Xcode's DerivedData is keyed by the checkout's path, so a fresh worktree compiles every pod again even when the native code is identical. warden caches the built app by native fingerprint instead, shared by every worktree of the repo:

warden dev ios              # claim a sim, install the cached dev build, start Metro, open the dev client
warden dev ios -- --clear   # extra args go to `expo start`

Same fingerprint as any earlier build (any worktree, EAS or local) → no prebuild, no Xcode, seconds to a running app. A native change → one local build, cached for every other worktree. Two worktrees missing the same hash at once share one build (build lock).

Variants: a dev client next to an e2e build

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. 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).

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):

PlatformCacheHow it crosses worktrees
iOSccache in $WARDEN_HOME/ccacheCCACHE_BASEDIR = git toplevel rewrites absolute paths to relative ones
iOS (Xcode 26+)compilation caching (COMPILATION_CACHE_ENABLE_CACHING=YES)content-addressed
AndroidGradle 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.

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

Installed — the device already has this fingerprint hash.
Cache — ~/.warden/builds/<projectKey>/<platform>/<hash>, then legacy caches. All worktrees of a repo share one cache.
EAS — build:list --fingerprint-hash, then download. Waits for builds already in flight; triggers the workflow only if eas.trigger is set.
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).

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:

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. Globs that match nothing are an error. The post-build check uses the same key.

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 flows).
  • 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

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

On this page