Turns the Expo app into a Release simulator app with one command, npm run build:ios:sim -- --variant preview, and proves what was built: the pinned Xcode made it, the variant is the one asked for (test-only code present in development and preview, absent in production), and the app starts and draws on a simulator this repo owns. No Xcode GUI, no Metro, no signing. Verified on 2026-10-04 with Expo SDK 57 (expo 57.0.26), React Native 0.86.3, Xcode 26.6 and the iOS 26.5 simulator runtime; the Android twin is android-emulator-build.
Rules that must hold
- Every simulator build runs through one script and starts from a clean prebuild.
scripts/build/build-ios-sim.tspreflights every npm script it will run (exit 2, nothing built), thennpx expo prebuild --platform ios --clean, the contract's post-prebuild scripts, xcodebuild, install, launch and screenshot. Why:ios/is generated (Continuous Native Generation, owned byexpo-prebuild-and-config-plugins) and a staleios/from another variant builds the wrong identity; a missing audit script must stop the run before the slow, destructive prebuild. - Xcode is selected by version through
DEVELOPER_DIR, per process; neverxcode-selectorsudo.scripts/ios/toolchain.tspinsXCODE_VERSIONandIOS_RUNTIME;selectXcode()picks the matching/Applications/Xcode*.appand assertsxcodebuild -version. Why: a Mac often holds several Xcodes (the newest is the one namedXcode.app), and one global switch needs a password and changes every build on the machine. - The variant is exported once, for the whole run.
variantEnv(variant)fromsrc/config/app-variant.tssetsAPP_VARIANT=EXPO_PUBLIC_APP_VARIANTon the one environment every step gets. Why: the variant enters the binary three times, each in its own process: prebuild (Info.plist), the Constants build phase (EXConstants.bundle/app.config) and Metro (the JS bundle); a lost variable silently mixes variants. - Metro's cache is keyed on the public values before any Release build.
metro.config.jssetscacheVersionfrom theEXPO_PUBLIC_*values (the key and its proof belong toapp-variants-and-environment). Why: this build runs withCI=1, and@expo/clithen skips--reset-cachein the Release bundling phase, so without the key a production build made after a preview build can ship the preview bundle. - The simulator build is Release, for the simulator SDK, on one UDID, unsigned.
-configuration Release -sdk iphonesimulator -destination id=<udid> ONLY_ACTIVE_ARCH=YES ARCHS=arm64 CODE_SIGNING_ALLOWED=NO, DerivedData inbuild/ios-sim/dd. Why: Release embeds the Hermes bundle users run;expo start,expo run:iosand Debug builds load JS from Metro with a dev menu, and no signing material is ever touched. - Simulators are named
<prefix>-<purpose>, created once on the pinned runtime and targeted only by UDID. Only prefixed simulators are shut down, erased or deleted, one at a time, neverallorbooted; Maestro runs pass--device <udid>and their own driver port; Android tooling passesadb -s <serial>and starts emulators with-avd <name>. Why: several sessions, agents and CI jobs share a Mac, and an implicit device reaches someone else's. - Every tooling process runs with
EXPO_NO_TELEMETRY=1andCI=1. Why: no telemetry, and no interactive prompt that would hang an unattended run. - Wait on a signal, never a fixed sleep; then check the screenshot and look at it. The ready signal is the contract's
readyMarkerfile in the app's data container, else two identical screenshots after at least 3 s. Why: a build that "succeeded" can still launch to a black, white or empty screen; only the picture shows it. - The built
.appproves its variant.check-sim-appreads the artifact: the pinned Xcode (DTXcode), the simulator platform, the test-only sentinel (present in development and preview, absent in production), Info.plist andEXConstantsagreeing on the variant's bundle id, usage strings in every language, the privacy manifest, no ATS exception, and for production no placeholder, no test artefact and the encryption flag. Why:** BUILD SUCCEEDED **says nothing about what shipped inside. - A change to variants, Metro, test-only code or the build tooling is proven by a preview build and a production build made back to back, without clearing any cache. Why: only that order shows a missing cache key or a broken gate.
- Human steps are asked for, never worked around. Installing Xcode, accepting its licence and its first-launch setup need
sudoand an admin password; the agent stops and asks. Why: a workaround (another Xcode,xcode-select) builds with the wrong SDK and hides the problem. - Generated and output folders stay out of git:
ios/,android/,build/andreports/. Why: prebuild regenerates the native folders,build/holds DerivedData and logs,reports/the screenshots.
Workflow
-
First time in a repo, or after a setup check fails: read references/environment-and-xcode.md (toolchain, human steps, Xcode selection, the Xcode 27 move) and references/build-variants.md (the three moments, the Metro trap, what proves a variant).
-
Install the templates that are missing (line 1 of each names its repo path; fill
__SIMULATOR_PREFIX__and__APP_ID__in the contract):Template Goes to templates/scripts/ios/toolchain.ts+ test (canonical)scripts/ios/templates/scripts/ios/simulators.ts+ testscripts/ios/templates/scripts/build/*.ts(+ tests)scripts/build/templates/scripts/e2e/maestro-args.ts+ testscripts/e2e/(e2e-maestro owns it; skip when present)templates/contracts/ios-sim-build.json+.schema.jsoncontracts/templates/src/config/app-variant.ts+ test (canonical)src/config/(skip when present)Add
"build:ios:sim": "node scripts/build/build-ios-sim.ts"topackage.json, check the.gitignorelines (rule 12), and runnpm run typecheck,npx eslint scripts --max-warnings 0andnpx jest scripts --ci --maxWorkers=2. Metro's cache key comes fromapp-variants-and-environment; post-prebuild scripts (a privacy-manifest audit) go into the contract'spostPrebuild. -
Check the setup:
node ${CLAUDE_SKILL_DIR}/scripts/check-sim-setup.mjs .. Fix everyFAIL(each names the file, the rule and the fix) and rerun untilRESULT: PASS. references/contract-and-checks.md explains every rule and contract key. -
Build and run:
npm run build:ios:sim -- --variant preview(--helpprints the options;--sim <purpose>picks another simulator;--link <path> --wait-for <testID>also screenshots a deep-linked screen through a Maestro sub-flow, becausesimctl openurlleaves iOS's "Open in ...?" prompt up). Read references/simulator-commands.md when a step fails, when you run a step by hand, or before changing the script. The run prints the.apppath and the screenshot path. -
Prove the build:
node ${CLAUDE_SKILL_DIR}/scripts/check-sim-app.mjs . --app <printed .app path> --variant preview, thennode ${CLAUDE_SKILL_DIR}/scripts/check-screenshot.mjs reports/ios-sim/, then open the PNG and look at it (the intended screen, text drawn, no alert, no notification banner, no blank area). -
When the change touches variants,
metro.config.js, test-only code or the build tooling: run--variant productionright after the preview build without clearing any cache, andcheck-sim-app.mjson both. examples/variant-proof.md shows the commands and the output. -
EAS or Android: an EAS simulator build (
ios.simulator: true) is checked the same way after extracting its.app; the Android emulator build isandroid-emulator-build. Both lanes are in references/eas-and-android.md. -
On any failure, match the text in references/failures.md, fix the cause (never the check), rerun the failing step, then the checks. Stop and ask the developer for anything that needs a password.
-
Report in plain words: variant, simulator name and model, the
.appand screenshot paths, what the screenshot shows, every SKIP line, and the threeRESULTlines. Shut down this session's simulators by UDID at the end.
Definition of done
-
npm run build:ios:sim -- --variant <v>ran from a clean prebuild and printedready (...)with a screenshot path. - The screenshot was opened and looked at, and shows the intended screen (not black, white, empty or covered by an alert).
- For variant, Metro, test-only or tooling changes: a preview and a production build made back to back both pass
check-sim-app.mjs. - Only
<prefix>-<purpose>simulators were created or touched, each by its UDID; noxcode-select,sudo,bootedorallanywhere; this session's simulators are shut down. - Expected SKIP line only:
SKIP src/test-only.ts [test-code] not yet created (debug-menu-and-test-hooks creates it)while the app has no test-only gate. -
node ${CLAUDE_SKILL_DIR}/scripts/check-sim-app.mjs . --app <app> --variant <v>andnode ${CLAUDE_SKILL_DIR}/scripts/check-screenshot.mjs reports/ios-sim/printRESULT: PASS. -
node ${CLAUDE_SKILL_DIR}/scripts/check-sim-setup.mjs .printsRESULT: PASS
Anti-patterns
- Opening
ios/in Xcode to fix a build setting. The next prebuild erases it; put the setting inapp.config.tsor a config plugin. sudo xcode-select -s /Applications/Xcode.appbecause "xcodebuild picked the wrong one". PassDEVELOPER_DIRfromselectXcode(); installing and licensing Xcode is the developer's step.APP_VARIANT=production npx expo prebuild, thenxcodebuildin a fresh shell. The Constants phase and Metro see no variant; export once and keep one environment.npx expo start,npx expo run:iosor a Debug build to "check quickly". Metro, the dev menu and LogBox are not what users run, andrun:iosbuilds on whatever Xcodexcode-selectpoints at.sleep 10 && xcrun simctl io <udid> screenshot .... Too short on a cold Mac, wasted time on a warm one; poll the ready signal.xcrun simctl shutdown all,erase allor any command withbooted. Shut down or delete only your own<prefix>-<purpose>simulator, by UDID.eas build:run -p ios --latestwithout--simulator <udid>. It installs on the first booted simulator, which may be another session's.- Declaring success from
** BUILD SUCCEEDED **. Launch, screenshot, check and look. - Deleting a post-prebuild script because the preflight stopped the first build. Install the tooling it runs; the audit is what keeps an undeclared required-reason API out of the build.
npx expo export --clearor deleting$TMPDIR/metro-cacheto fix a stale variant. It hides the missing cache key until the next build; keycacheVersionon the public values.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/environment-and-xcode.md | Toolchain versions, human steps, Xcode selection, tool environment, the Xcode 27 move with scene support, .gitignore, disk space |
Workflow step 1, and when Xcode or the Mac changes |
| references/build-variants.md | The three variants on a simulator, the three moments, the Metro cache trap under CI=1, the test-only sentinel, the evidence check-sim-app reads, the back-to-back proof |
Workflow steps 1 and 6 |
| references/simulator-commands.md | Every command of the run with its flags, simulator models and sizes, the ready signal, verified pitfalls, other simctl commands, timings | Workflow step 4, and when running a step by hand |
| references/contract-and-checks.md | The contract keys, the rn-skills.config.json keys, every checker rule with its reason, when multi-package |
Workflow step 3, and when a check fails |
| references/eas-and-android.md | The EAS simulator lane (profiles, --local, build:run --simulator) and the Android counterpart of every step |
Workflow step 7 |
| references/failures.md | Symptom, cause and fix for environment, prebuild, xcodebuild, variant, simulator and screenshot failures | Workflow step 8, whenever something fails |
| examples/variant-proof.md | A real preview build of the reference Tasks app: timings, the checker output, a real finding, the production step and the report | Workflow steps 5 and 6 |
templates/scripts/ios/toolchain.ts |
The Xcode pin, selectXcode(), toolEnv(), xcodeBuildNumber() (synced from the library; do not edit here) |
Workflow step 2 |
templates/scripts/ios/toolchain.test.ts |
Its tests (synced; do not edit here) | With the toolchain |
templates/scripts/ios/simulators.ts |
<prefix>-<purpose> simulators: create, warm up, boot, status bar, install, launch, own-only shutdown and delete |
Workflow step 2 |
templates/scripts/ios/simulators.test.ts |
Its tests | With the simulators module |
templates/scripts/build/build-ios-sim.ts |
The CLI behind npm run build:ios:sim |
Workflow step 2 |
templates/scripts/build/build-steps.ts |
Its side effects: logged steps, workspace, plist values, stale DerivedData, package.json kept clean, the ready poll | Workflow step 2 |
templates/scripts/build/sim-build-args.ts |
The command line, parsed with the app's variant resolver | Workflow step 2 |
templates/scripts/build/sim-build-config.ts |
The contract reader (valibot), defaults filled | Workflow step 2 |
templates/scripts/build/sim-build-plan.ts |
Prebuild and xcodebuild arguments, paths, the preflight, the --link Maestro line |
Workflow step 2 |
templates/scripts/build/derived-data.ts |
Stale DerivedData detection (a copied or moved repo) | Workflow step 2 |
templates/scripts/build/ready-signal.ts |
When the launched app counts as ready | Workflow step 2 |
templates/scripts/build/ |
The build modules above and the tests of the pure ones (*.test.ts beside each) |
Workflow step 2 |
templates/scripts/e2e/maestro-args.ts |
maestroGlobalArgs, freeDriverPort, driverPortFor, maestroRunLine (synced from the library; do not edit here) |
Workflow step 2, when the repo has none |
templates/scripts/e2e/maestro-args.test.ts |
Its tests (synced from the library; do not edit here) | With maestro-args |
templates/contracts/ios-sim-build.json |
The contract: simulator prefix, device type, app id, post-prebuild scripts, usage strings, Info.plist keys, ready marker | Workflow step 2 |
templates/contracts/ios-sim-build.schema.json |
Its schema, with what each key does and its default | When editing the contract |
templates/src/config/app-variant.ts |
The canonical variant resolver (variantEnv, parseAppVariant; synced from the library; do not edit here) |
Workflow step 2, when the repo has none |
templates/src/config/app-variant.test.ts |
Its tests (synced; do not edit here) | With the resolver |
scripts/check-sim-setup.mjs |
Checks the repo setup: contract, Xcode pin, scene support, build arguments, variant environment, Metro key, npm scripts, device safety, .gitignore |
Workflow step 3 and the definition of done |
scripts/check-sim-app.mjs |
Checks a built .app against its variant |
Workflow steps 5-7 and the definition of done |
scripts/check-screenshot.mjs |
Checks screenshots for the device size, a blank screen and a light system banner | Workflow step 5 |
scripts/lib/sim-contract.mjs |
Reads the contract, the Xcode pin and package.json for both repo checkers | Never by hand |
scripts/lib/device-safety.mjs |
The no-xcode-select, sim-safety and adb-device rules |
Never by hand |
scripts/lib/sim-app-sentinels.mjs |
The test-code rule: sentinels and the test-only gate |
Never by hand |
scripts/lib/sim-app-rules.mjs |
Identity, placeholder, ads, usage-string, Info.plist and store rules of check-sim-app |
Never by hand |
scripts/lib/screen-measure.mjs |
Dominant colour and light-banner rows of a decoded screenshot | Never by hand |
scripts/lib/plist.mjs |
XML and binary plist reader (synced from the library; do not edit here) | Never by hand |
scripts/lib/usage-strings.mjs |
Usage descriptions per language (synced from the library; do not edit here) | Never by hand |
scripts/lib/ship-placeholders.mjs |
The placeholder values a ship gate refuses (synced from the library; do not edit here) | Never by hand |
scripts/lib/maestro-spawns.mjs |
The maestro-device rule (synced from the library; do not edit here) |
Never by hand |
scripts/check-lib.mjs |
Shared checker library (synced from the library; do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves all three checkers pass their good fixtures and catch every planted bug | After changing a checker or a template |
assets/shared.json |
The shared files this skill syncs in | When adding a shared file |
assets/template-placeholders.json |
Values for the template placeholders in template checks | When a template gains a placeholder |
tests/bases/ |
The repo, built .app and screenshot bases the fixtures assemble from |
When adding a fixture |
tests/fixtures/ |
Overlay cases per checker: good, a bad case per rule, pass and error cases | When adding a rule |
Related skills
app-variants-and-environment- the variant resolver, the Metro cache key and the exported-bundle proof.expo-prebuild-and-config-plugins-app.config.tsas the single source and never editingios/.debug-menu-and-test-hooks- the test-only gate, its entry and the sentinel this build looks for.ios-release-testflight- signed archive, export, validation and upload.eas-build-and-submit- EAS profiles, credentials and cloud builds.android-emulator-build- the same proof on an Android emulator.e2e-maestro- flows, the open-link sub-flow and the screenshot matrix on a preview build.privacy-and-store-declarations- the privacy-manifest audit a post-prebuild script runs.expo-sdk-upgrade- moving the Expo SDK or Xcode.troubleshooting-playbook- failures outside the simulator build.