An EAS Update swaps the JavaScript inside a build, but it cannot add native code. An agent that installs expo-camera and then runs eas update ships a crash to every user on that build. This loadout gives your agent a check it runs before any update. It records the native fingerprint when a store build is cut, then compares the current project against it with expo-updates' own fingerprint tool. JavaScript and version bumps pass. A new native module, config plugin or permission fails, and the output names what changed. A config check makes sure runtimeVersion uses the fingerprint policy, updates.url matches the project and every build profile has its own channel. It validates eas.json with Expo's own parser. CI publishes to preview only. Production goes out as a staged rollout run by a person. The verifier runs it on Expo SDK 58 with expo-updates 58.0.17.
What's in it
- OTA-safe or needs a new build, from the expo-updates native fingerprint
- Names what changed: added native modules, config, autolinking
- Fingerprint runtime policy, matching updates.url, one channel per build profile
- CI publishes to preview only; production is a person's staged rollout
- Verified on Expo SDK 58, expo-updates 58.0.17 and @expo/eas-json 25.1.1
You'll need
- Tools:
node,npm
Verification
| Check | Result | What we found |
|---|---|---|
| Listinglisting.yaml matches the schema and declares every file in the folder. | Pass | Schema valid; 8 files declared, none missing or extra
|
| Files parseJSON parses; SKILL.md and .mdc front matter is valid YAML with the fields each agent needs; code fences are closed. | Pass | All 8 files parse; front matter valid |
| AdaptersCLAUDE.md imports @AGENTS.md, GEMINI.md imports @./AGENTS.md, and both match the generator. | Pass | CLAUDE.md and GEMINI.md import AGENTS.md and are up to date
|
| Install pathsEvery file lands where each agent documents it, with no collisions, and the laid-out project is re-discovered correctly. | Pass | Correct paths for all 6 agents; layouts re-discovered
|
| MCP configmcp.json is structurally valid, converts to each agent's format, declares no literal secrets, and referenced packages exist on npm/PyPI. | Not applicable | No MCP servers in this loadout |
| Safety scanThe intake scanner finds no credentials, risky shell, hidden Unicode or prompt-injection wording, and the listing's network flag is honest. | Pass | Clean: no credentials, risky shell or hidden text
|
| ReferencesLinks and file paths between the loadout's files resolve, including for each agent's installed layout. | Pass | 4 references between files resolve for every agent |
| Size limitsFile count and sizes are within the submission limits; SKILL.md bodies stay under 500 lines. | Pass | 8 files, 14.7 KB total; largest skills/eas-update-release/scripts/ota_check.mjs 6.2 KB
|
| LicenceA recognised SPDX licence, with the licence text available. | Pass | MIT, by Agent Loadout; text in the repository LICENSE |
| Starter buildA minimal fixture project with the loadout applied installs and builds or type-checks, and the loadout's own code examples compile. | Pass | The eas-update-release skill's app.json and eas.json pass ota_check config in an Expo SDK 58 app (expo-updates 58.0.17, @expo/eas-json 25.1.1); a JS edit and a version bump are OTA-safe, adding expo-haptics needs a new build, a profile with no channel and a misspelt key are caught, and the bundle ex
|
What this means: a script checked the files themselves: they parse, they install where each agent looks, any MCP config is sound, the safety scan is clean, references between files resolve, and, where a free and repeatable fixture exists, a small starter project builds with the loadout applied. No AI agent was run. Per-agent results ("tested with agent") are separate and stay untested until an agent actually runs.
Install
Choose your agent. Run the commands from your project root, or download a zip already laid out in the right folders.
Claude Code reads CLAUDE.md (imports AGENTS.md with @AGENTS.md) and skills from .claude/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/AGENTS.md curl -fsSL --create-dirs -o CLAUDE.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/CLAUDE.md curl -fsSL --create-dirs -o .claude/skills/eas-update-release/SKILL.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/SKILL.md curl -fsSL --create-dirs -o .claude/skills/eas-update-release/scripts/ota_check.mjs https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/scripts/ota_check.mjs curl -fsSL --create-dirs -o .claude/skills/eas-update-release/references/app.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/app.json curl -fsSL --create-dirs -o .claude/skills/eas-update-release/references/eas.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas.json curl -fsSL --create-dirs -o .claude/skills/eas-update-release/references/eas-update-preview.yml https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas-update-preview.yml
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
CLAUDE.md | CLAUDE.md |
skills/eas-update-release/SKILL.md | .claude/skills/eas-update-release/SKILL.md |
skills/eas-update-release/scripts/ota_check.mjs | .claude/skills/eas-update-release/scripts/ota_check.mjs |
skills/eas-update-release/references/app.json | .claude/skills/eas-update-release/references/app.json |
skills/eas-update-release/references/eas.json | .claude/skills/eas-update-release/references/eas.json |
skills/eas-update-release/references/eas-update-preview.yml | .claude/skills/eas-update-release/references/eas-update-preview.yml |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want.
Codex CLI reads AGENTS.md and skills from .agents/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/AGENTS.md curl -fsSL --create-dirs -o .agents/skills/eas-update-release/SKILL.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/SKILL.md curl -fsSL --create-dirs -o .agents/skills/eas-update-release/scripts/ota_check.mjs https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/scripts/ota_check.mjs curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/app.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/app.json curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/eas.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas.json curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/eas-update-preview.yml https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas-update-preview.yml
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
skills/eas-update-release/SKILL.md | .agents/skills/eas-update-release/SKILL.md |
skills/eas-update-release/scripts/ota_check.mjs | .agents/skills/eas-update-release/scripts/ota_check.mjs |
skills/eas-update-release/references/app.json | .agents/skills/eas-update-release/references/app.json |
skills/eas-update-release/references/eas.json | .agents/skills/eas-update-release/references/eas.json |
skills/eas-update-release/references/eas-update-preview.yml | .agents/skills/eas-update-release/references/eas-update-preview.yml |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want.
Cursor reads AGENTS.md + .cursor/rules/*.mdc and skills from .agents/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/AGENTS.md curl -fsSL --create-dirs -o .agents/skills/eas-update-release/SKILL.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/SKILL.md curl -fsSL --create-dirs -o .agents/skills/eas-update-release/scripts/ota_check.mjs https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/scripts/ota_check.mjs curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/app.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/app.json curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/eas.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas.json curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/eas-update-preview.yml https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas-update-preview.yml
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
skills/eas-update-release/SKILL.md | .agents/skills/eas-update-release/SKILL.md |
skills/eas-update-release/scripts/ota_check.mjs | .agents/skills/eas-update-release/scripts/ota_check.mjs |
skills/eas-update-release/references/app.json | .agents/skills/eas-update-release/references/app.json |
skills/eas-update-release/references/eas.json | .agents/skills/eas-update-release/references/eas.json |
skills/eas-update-release/references/eas-update-preview.yml | .agents/skills/eas-update-release/references/eas-update-preview.yml |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want.
Gemini CLI reads GEMINI.md (imports AGENTS.md with @./AGENTS.md) and skills from .agents/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/AGENTS.md curl -fsSL --create-dirs -o GEMINI.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/GEMINI.md curl -fsSL --create-dirs -o .agents/skills/eas-update-release/SKILL.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/SKILL.md curl -fsSL --create-dirs -o .agents/skills/eas-update-release/scripts/ota_check.mjs https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/scripts/ota_check.mjs curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/app.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/app.json curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/eas.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas.json curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/eas-update-preview.yml https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas-update-preview.yml
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
GEMINI.md | GEMINI.md |
skills/eas-update-release/SKILL.md | .agents/skills/eas-update-release/SKILL.md |
skills/eas-update-release/scripts/ota_check.mjs | .agents/skills/eas-update-release/scripts/ota_check.mjs |
skills/eas-update-release/references/app.json | .agents/skills/eas-update-release/references/app.json |
skills/eas-update-release/references/eas.json | .agents/skills/eas-update-release/references/eas.json |
skills/eas-update-release/references/eas-update-preview.yml | .agents/skills/eas-update-release/references/eas-update-preview.yml |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want.
Antigravity CLI reads AGENTS.md (read natively; GEMINI.md is also read) and skills from .agents/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/AGENTS.md curl -fsSL --create-dirs -o .agents/skills/eas-update-release/SKILL.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/SKILL.md curl -fsSL --create-dirs -o .agents/skills/eas-update-release/scripts/ota_check.mjs https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/scripts/ota_check.mjs curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/app.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/app.json curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/eas.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas.json curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/eas-update-preview.yml https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas-update-preview.yml
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
skills/eas-update-release/SKILL.md | .agents/skills/eas-update-release/SKILL.md |
skills/eas-update-release/scripts/ota_check.mjs | .agents/skills/eas-update-release/scripts/ota_check.mjs |
skills/eas-update-release/references/app.json | .agents/skills/eas-update-release/references/app.json |
skills/eas-update-release/references/eas.json | .agents/skills/eas-update-release/references/eas.json |
skills/eas-update-release/references/eas-update-preview.yml | .agents/skills/eas-update-release/references/eas-update-preview.yml |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want.
GitHub Copilot reads AGENTS.md and skills from .agents/skills/.
curl -fsSL --create-dirs -o AGENTS.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/AGENTS.md curl -fsSL --create-dirs -o .agents/skills/eas-update-release/SKILL.md https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/SKILL.md curl -fsSL --create-dirs -o .agents/skills/eas-update-release/scripts/ota_check.mjs https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/scripts/ota_check.mjs curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/app.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/app.json curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/eas.json https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas.json curl -fsSL --create-dirs -o .agents/skills/eas-update-release/references/eas-update-preview.yml https://loadout.jnw-it.com/raw/expo-eas-update/1.0.0/skills/eas-update-release/references/eas-update-preview.yml
| File | Goes to |
|---|---|
AGENTS.md | AGENTS.md |
skills/eas-update-release/SKILL.md | .agents/skills/eas-update-release/SKILL.md |
skills/eas-update-release/scripts/ota_check.mjs | .agents/skills/eas-update-release/scripts/ota_check.mjs |
skills/eas-update-release/references/app.json | .agents/skills/eas-update-release/references/app.json |
skills/eas-update-release/references/eas.json | .agents/skills/eas-update-release/references/eas.json |
skills/eas-update-release/references/eas-update-preview.yml | .agents/skills/eas-update-release/references/eas-update-preview.yml |
Already have an AGENTS.md or CLAUDE.md? The commands above overwrite it — download the file instead and merge the sections you want.
Files
Rules AGENTS.md Rules for an Expo app updated over the air with EAS Update, where native changes always get a new build. 25 lines
# AGENTS.md: Expo app shipped with EAS Update
An Expo SDK 58 app (React Native 0.88) built with EAS Build and updated over the air with EAS Update. Check
`package.json` for exact versions, and add packages with `npx expo install` so versions match the SDK.
## Commands
- Typecheck: `npx tsc --noEmit`.
- Bundle check: `npx expo export --platform ios --platform android --output-dir dist`.
- Update config check: `node .agents/skills/eas-update-release/scripts/ota_check.mjs config`.
- OTA safety: `node .agents/skills/eas-update-release/scripts/ota_check.mjs compare` (against the fingerprint
recorded when the current store build was cut).
## Rules
- Use the `eas-update-release` skill before any change is published as an update, and after adding or upgrading
a package or editing app.json, app.config or eas.json.
- `runtimeVersion` uses the fingerprint policy. If `compare` reports a native change, the change needs a new
build: say so, do not publish an update.
- Agents may publish to the `preview` channel through CI. Production updates, rollouts and rollbacks are run by
a person.
- Never run `eas update --auto`, `--force-end-active-rollout`, `eas update:rollback` or `eas channel:*`.
- Never edit or regenerate the committed build fingerprint to make a check pass. It is recorded when a build is cut.
- `EXPO_TOKEN` and other secrets live in CI secrets or EAS environment variables, never in the repository.
- Code that depends on a new native module checks it exists before calling it, so an older build degrades
instead of crashing. Adapter CLAUDE.md Imports AGENTS.md for Claude Code. 11 lines
# CLAUDE.md
@AGENTS.md
## Claude Code notes
- Skills from this loadout live in `.claude/skills/`: `eas-update-release`. Use them when their description matches the task.
- Follow AGENTS.md above; where this file and AGENTS.md disagree, AGENTS.md wins.
<!-- From the Agent Loadout "Expo EAS Update without shipping native changes over the air" v1.0.0 — https://loadout.jnw-it.com/loadouts/expo-eas-update/ --> Adapter GEMINI.md Imports AGENTS.md for Gemini CLI. 11 lines
# GEMINI.md
@./AGENTS.md
## Gemini CLI notes
- Skills from this loadout live in `.agents/skills/`: `eas-update-release`. Use them when their description matches the task.
- Follow AGENTS.md above; where this file and AGENTS.md disagree, AGENTS.md wins.
<!-- From the Agent Loadout "Expo EAS Update without shipping native changes over the air" v1.0.0 — https://loadout.jnw-it.com/loadouts/expo-eas-update/ --> Skill skills/eas-update-release/SKILL.md Config, the OTA-safe check, and who publishes where. 82 lines
---
name: eas-update-release
description: Decide whether an Expo change can ship as an EAS Update or needs a new store build, check the update config, and prepare a staged rollout. Use before publishing with eas update, after adding or upgrading a package, or when editing app.json, app.config or eas.json.
---
# Shipping with EAS Update
An EAS Update replaces the JavaScript and assets inside a build. It cannot change native code. If an update
calls a native module the build does not contain, it crashes on users' phones, and `expo-updates` falls back
to the previous update only if it notices. This skill keeps that from happening.
## 1. Config once: fingerprint runtime version and one channel per profile
```bash
node .agents/skills/eas-update-release/scripts/ota_check.mjs config
```
It reads the resolved app config (`npx expo config --type public`) and checks:
- `runtimeVersion` is `{ "policy": "fingerprint" }` (top level or per platform). The fingerprint changes
whenever anything that affects the native layer changes. A manual string or `appVersion` policy only works
if someone remembers to bump it, so the script notes it.
- `updates.url` is `https://u.expo.dev/<projectId>` and matches `extra.eas.projectId`.
- `eas.json` parses with Expo's own validator (`@expo/eas-json`), and every non-development build profile has
its own `channel`, including one called `production`.
Start from `references/app.json` and `references/eas.json`.
## 2. Every change: is it OTA-safe?
When a store build is cut, record its native fingerprint and commit the file:
```bash
node .agents/skills/eas-update-release/scripts/ota_check.mjs record # writes .eas/build-fingerprint.json
```
Before any update, compare:
```bash
node .agents/skills/eas-update-release/scripts/ota_check.mjs compare
```
- `OTA-safe` (exit 0): only JavaScript, assets or non-native config changed. An update can carry it.
- `needs a new build` (exit 1): it lists what changed, for example `added: node_modules/expo-camera/package.json`
or `changed: expoConfig`. Do not publish an update. Say a new build is needed and why.
Typical "needs a build" changes: adding or upgrading any package with native code (`npx expo install` of an
`expo-*` module), a new config plugin, permissions, icons and splash, bundle identifiers, an SDK upgrade.
A version bump alone does not change the fingerprint.
With an Expo account the same check is `eas fingerprint:compare --build-id <id>`. The script is for CI and
for machines without a token.
## 3. Publishing: preview by the pipeline, production by a person
- Preview: CI publishes to the `preview` channel when `compare` passes. See
`references/eas-update-preview.yml`. `--environment` is required for SDK 55 and later.
- Production, done by a person after testing on a preview build, as a staged rollout:
```bash
eas update:republish --branch preview --destination-channel production --rollout-percentage 10 -m "..."
eas update:edit # raise the percentage once crash-free rates hold
eas update:revert-update-rollout # or back out
```
- One rollout per branch and runtime at a time. Never pass `--force-end-active-rollout` to get past one.
- Roll back with `eas update:rollback`. A person runs it; it affects every user on that runtime.
## What the agent does not do
- No `eas update` to the `production` channel or branch, no `--auto` (it publishes to a branch named after
the git branch), no `update:rollback`, `channel:edit` or `channel:rollout`.
- No publishing when `compare` says a build is needed, and no editing the baseline file to make it pass.
- No `EXPO_TOKEN` in files, logs or commands. It lives in CI secrets only.
## Before you call it done
1. `npx tsc --noEmit` is clean.
2. `npx expo export --platform ios --platform android --output-dir dist` succeeds (the same bundle step an
update runs).
3. `ota_check.mjs compare` result is stated in the summary, with the changed sources if a build is needed. Skill file skills/eas-update-release/scripts/ota_check.mjs config, record and compare: fingerprint and update config checks. 114 lines
#!/usr/bin/env node
// Decide whether a change can ship as an EAS Update or needs a new store build.
// Uses the project's own expo-updates CLI (fingerprint:generate) and app config; no Expo account needed.
//
// node ota_check.mjs config check app config + eas.json are set up for safe updates
// node ota_check.mjs record [file] save the current native fingerprint (run when a build is cut)
// node ota_check.mjs compare [file] exit 0 "OTA-safe" if native fingerprint unchanged, 1 if a build is needed
//
// file defaults to .eas/build-fingerprint.json. Platforms: ios and android (AL_PLATFORMS to override).
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
const [cmd = "", fileArg] = process.argv.slice(2);
const file = fileArg ?? ".eas/build-fingerprint.json";
const platforms = (process.env.AL_PLATFORMS ?? "ios,android").split(",").map((s) => s.trim()).filter(Boolean);
const npx = process.platform === "win32" ? "npx.cmd" : "npx";
function runJson(args) {
const r = spawnSync(npx, ["--no-install", ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) {
console.error(`failed: npx ${args.join(" ")}\n${(r.stderr || r.stdout || "").trim().slice(0, 2000)}`);
process.exit(2);
}
const out = r.stdout.slice(r.stdout.indexOf("{"));
return JSON.parse(out);
}
const keyOf = (s) => s.id ?? s.filePath ?? `${s.type}:${s.name ?? "?"}`;
function fingerprint(platform) {
const fp = runJson(["expo-updates", "fingerprint:generate", "--platform", platform]);
return { hash: fp.hash, sources: Object.fromEntries(fp.sources.map((s) => [keyOf(s), s.hash])) };
}
async function checkConfig() {
const problems = [];
const notes = [];
const cfg = runJson(["expo", "config", "--json", "--type", "public"]);
const rv = cfg.runtimeVersion;
for (const p of platforms) {
const prv = cfg[p]?.runtimeVersion ?? rv;
if (prv === undefined) problems.push(`${p}: no runtimeVersion; set { "policy": "fingerprint" }`);
else if (typeof prv === "object" && prv.policy !== "fingerprint") notes.push(`${p}: runtimeVersion policy "${prv.policy}" relies on people bumping versions when native code changes; "fingerprint" does it for you`);
else if (typeof prv === "string") notes.push(`${p}: runtimeVersion "${prv}" is manual; bump it with every native change or switch to { "policy": "fingerprint" }`);
}
const url = cfg.updates?.url;
if (!url || !/^https:\/\/u\.expo\.dev\/[0-9a-f-]{36}$/.test(url)) problems.push(`updates.url is ${url ? `"${url}"` : "missing"}; expected https://u.expo.dev/<projectId> (run eas update:configure once)`);
const projectId = cfg.extra?.eas?.projectId;
if (url && projectId && !url.endsWith(projectId)) problems.push(`updates.url does not match extra.eas.projectId (${projectId})`);
if (cfg.updates?.enabled === false) problems.push("updates.enabled is false: builds will never download an update");
if (!fs.existsSync("eas.json")) problems.push("no eas.json");
else {
// Validate and resolve "extends" with Expo's own eas.json parser (the @expo/eas-json dev dependency).
let easJson;
try { easJson = createRequire(path.resolve("package.json"))("@expo/eas-json"); }
catch { problems.push("@expo/eas-json is not installed: add it as a dev dependency pinned to your eas-cli version"); }
if (easJson) {
const { EasJsonAccessor, EasJsonUtils } = easJson;
const acc = EasJsonAccessor.fromProjectPath(process.cwd());
const channels = new Map();
try {
for (const name of await EasJsonUtils.getBuildProfileNamesAsync(acc)) {
const prof = await EasJsonUtils.getBuildProfileAsync(acc, platforms[0], name);
if (prof.developmentClient) continue;
if (!prof.channel) { problems.push(`eas.json build.${name}: no channel, so its builds cannot receive updates`); continue; }
if (channels.has(prof.channel)) problems.push(`eas.json build.${name} and build.${channels.get(prof.channel)} share channel "${prof.channel}"`);
channels.set(prof.channel, name);
}
if (!channels.has("production")) problems.push('eas.json: no build profile on channel "production"');
} catch (e) {
problems.push(e.message.replace(/\n/g, " "));
}
}
}
for (const n of notes) console.log(`note: ${n}`);
for (const p of problems) console.log(`problem: ${p}`);
if (problems.length) { console.log(`config: ${problems.length} problem(s)`); process.exit(1); }
console.log(`config: ok (runtimeVersion ${JSON.stringify(rv)}, channels checked)`);
}
if (cmd === "config") await checkConfig();
else if (cmd === "record") {
const data = { recordedAt: new Date().toISOString(), platforms: Object.fromEntries(platforms.map((p) => [p, fingerprint(p)])) };
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
for (const p of platforms) console.log(`recorded ${p}: ${data.platforms[p].hash}`);
} else if (cmd === "compare") {
if (!fs.existsSync(file)) { console.log(`no baseline at ${file}: run "record" when you cut the build`); process.exit(2); }
const base = JSON.parse(fs.readFileSync(file, "utf8")).platforms;
let changed = false;
for (const p of platforms) {
const now = fingerprint(p);
const was = base[p];
if (!was) { console.log(`${p}: no baseline recorded`); changed = true; continue; }
if (now.hash === was.hash) { console.log(`${p}: unchanged (${now.hash})`); continue; }
changed = true;
console.log(`${p}: native fingerprint changed ${was.hash} -> ${now.hash}`);
const keys = new Set([...Object.keys(was.sources), ...Object.keys(now.sources)]);
for (const k of [...keys].sort()) {
if (!(k in was.sources)) console.log(` added: ${k}`);
else if (!(k in now.sources)) console.log(` removed: ${k}`);
else if (was.sources[k] !== now.sources[k]) console.log(` changed: ${k}`);
}
}
if (changed) { console.log("needs a new build: do not ship this as an EAS Update"); process.exit(1); }
console.log("OTA-safe: native layer unchanged, an EAS Update can carry this change");
} else {
console.log("usage: ota_check.mjs config | record [file] | compare [file]");
process.exit(2);
} Skill file skills/eas-update-release/references/app.json App config with the fingerprint policy and updates.url. 19 lines
{
"expo": {
"name": "Release Notes",
"slug": "release-notes",
"version": "1.4.0",
"owner": "acme",
"ios": { "bundleIdentifier": "com.acme.releasenotes" },
"android": { "package": "com.acme.releasenotes" },
"runtimeVersion": { "policy": "fingerprint" },
"updates": {
"url": "https://u.expo.dev/00000000-0000-4000-8000-000000000000",
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0
},
"extra": { "eas": { "projectId": "00000000-0000-4000-8000-000000000000" } },
"plugins": ["expo-updates"]
}
} Skill file skills/eas-update-release/references/eas.json Build profiles with one channel each. 24 lines
{
"cli": {
"version": ">= 25.1.1",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"autoIncrement": true,
"channel": "production"
}
},
"submit": {
"production": {}
}
} Skill file skills/eas-update-release/references/eas-update-preview.yml GitHub workflow: publish to preview only when OTA-safe. 43 lines
# .github/workflows/eas-update-preview.yml
# On every push to main: typecheck, export the JS bundle, and publish an EAS Update to the
# preview channel ONLY if the native fingerprint matches the last recorded build.
# Production is promoted by a person (see the skill), never from this workflow.
name: EAS Update (preview)
on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: eas-update-preview
cancel-in-progress: false
jobs:
update:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx tsc --noEmit
- name: Native fingerprint unchanged since the last build
run: node .agents/skills/eas-update-release/scripts/ota_check.mjs compare
- name: Bundle builds
run: npx expo export --platform ios --platform android --output-dir dist
- name: Publish to preview
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
# Passed through the environment, never interpolated into the script.
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: >
npx --yes eas-cli@25.1.1 update
--channel preview --environment preview
--message "$COMMIT_MESSAGE" --non-interactive All files: source zip · Licence MIT · By Agent Loadout · First listed
Credits
Public work that inspired this loadout. The wording here is our own; follow the links for the originals.
Installing and testing
How do I install Expo EAS Update without shipping native changes over the air in Claude Code?
From your project root, put AGENTS.md, CLAUDE.md, skills/eas-update-release/SKILL.md at .claude/skills/eas-update-release/SKILL.md, skills/eas-update-release/scripts/ota_check.mjs at .claude/skills/eas-update-release/scripts/ota_check.mjs, skills/eas-update-release/references/app.json at .claude/skills/eas-update-release/references/app.json, skills/eas-update-release/references/eas.json at .claude/skills/eas-update-release/references/eas.json, skills/eas-update-release/references/eas-update-preview.yml at .claude/skills/eas-update-release/references/eas-update-preview.yml. The "Claude Code" tab on this page has the curl commands, or download https://loadout.jnw-it.com/download/expo-eas-update/claude-code.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
How do I install Expo EAS Update without shipping native changes over the air in Codex CLI?
From your project root, put AGENTS.md, skills/eas-update-release/SKILL.md at .agents/skills/eas-update-release/SKILL.md, skills/eas-update-release/scripts/ota_check.mjs at .agents/skills/eas-update-release/scripts/ota_check.mjs, skills/eas-update-release/references/app.json at .agents/skills/eas-update-release/references/app.json, skills/eas-update-release/references/eas.json at .agents/skills/eas-update-release/references/eas.json, skills/eas-update-release/references/eas-update-preview.yml at .agents/skills/eas-update-release/references/eas-update-preview.yml. The "Codex CLI" tab on this page has the curl commands, or download https://loadout.jnw-it.com/download/expo-eas-update/codex.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
How do I install Expo EAS Update without shipping native changes over the air in Cursor?
From your project root, put AGENTS.md, skills/eas-update-release/SKILL.md at .agents/skills/eas-update-release/SKILL.md, skills/eas-update-release/scripts/ota_check.mjs at .agents/skills/eas-update-release/scripts/ota_check.mjs, skills/eas-update-release/references/app.json at .agents/skills/eas-update-release/references/app.json, skills/eas-update-release/references/eas.json at .agents/skills/eas-update-release/references/eas.json, skills/eas-update-release/references/eas-update-preview.yml at .agents/skills/eas-update-release/references/eas-update-preview.yml. The "Cursor" tab on this page has the curl commands, or download https://loadout.jnw-it.com/download/expo-eas-update/cursor.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
How do I install Expo EAS Update without shipping native changes over the air in Gemini CLI?
From your project root, put AGENTS.md, GEMINI.md, skills/eas-update-release/SKILL.md at .agents/skills/eas-update-release/SKILL.md, skills/eas-update-release/scripts/ota_check.mjs at .agents/skills/eas-update-release/scripts/ota_check.mjs, skills/eas-update-release/references/app.json at .agents/skills/eas-update-release/references/app.json, skills/eas-update-release/references/eas.json at .agents/skills/eas-update-release/references/eas.json, skills/eas-update-release/references/eas-update-preview.yml at .agents/skills/eas-update-release/references/eas-update-preview.yml. The "Gemini CLI" tab on this page has the curl commands, or download https://loadout.jnw-it.com/download/expo-eas-update/gemini-cli.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
How do I install Expo EAS Update without shipping native changes over the air in Antigravity CLI?
From your project root, put AGENTS.md, skills/eas-update-release/SKILL.md at .agents/skills/eas-update-release/SKILL.md, skills/eas-update-release/scripts/ota_check.mjs at .agents/skills/eas-update-release/scripts/ota_check.mjs, skills/eas-update-release/references/app.json at .agents/skills/eas-update-release/references/app.json, skills/eas-update-release/references/eas.json at .agents/skills/eas-update-release/references/eas.json, skills/eas-update-release/references/eas-update-preview.yml at .agents/skills/eas-update-release/references/eas-update-preview.yml. The "Antigravity CLI" tab on this page has the curl commands, or download https://loadout.jnw-it.com/download/expo-eas-update/antigravity-cli.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
How do I install Expo EAS Update without shipping native changes over the air in GitHub Copilot?
From your project root, put AGENTS.md, skills/eas-update-release/SKILL.md at .agents/skills/eas-update-release/SKILL.md, skills/eas-update-release/scripts/ota_check.mjs at .agents/skills/eas-update-release/scripts/ota_check.mjs, skills/eas-update-release/references/app.json at .agents/skills/eas-update-release/references/app.json, skills/eas-update-release/references/eas.json at .agents/skills/eas-update-release/references/eas.json, skills/eas-update-release/references/eas-update-preview.yml at .agents/skills/eas-update-release/references/eas-update-preview.yml. The "GitHub Copilot" tab on this page has the curl commands, or download https://loadout.jnw-it.com/download/expo-eas-update/copilot.zip, which is already laid out. Do not overwrite an existing AGENTS.md or CLAUDE.md; merge it.
Has Expo EAS Update without shipping native changes over the air been tested?
It passed the Level 1 automated install checks on 2026-10-17: the files parse, install to each agent's documented paths, and pass the safety scan. No AI agent has run it yet, so no agent pass or fail is claimed.
More for Claude Code, Codex, Cursor, Gemini CLI, Antigravity CLI and GitHub Copilot.