SuperAPI AI Directory

app-variants-and-environment

Deployment & operations

View repository

Updated 10-18 18:05

About this skill

App variants and environment

One Expo app builds as three variants, development (the dev client), preview (internal testing) and production (the stores), chosen by APP_VARIANT. Each variant installs beside the others and says what it is. Every EXPO_PUBLIC_* value is treated as public, secrets stay in EAS or CI, and test-only code never reaches a store bundle. Three checkers prove it: the config per variant, the source and files, and the exported bundle.

Rules that must hold

  1. One resolver decides the variant. app.config.ts takes it from resolveAppVariant(process.env) in src/config/app-variant.ts: only development, preview or production; an unknown name throws; APP_VARIANT and EXPO_PUBLIC_APP_VARIANT must be equal; both unset means development for local work and throws in a production bundle (NODE_ENV=production: expo export, eas update, expo start --no-dev) or on EAS Build (EAS_BUILD=true). The variant comes from the command, the eas.json profile or the EAS environment, never from a committed env file. Why: the config and the JS bundle are evaluated in different processes, so a typo or a lost variable otherwise ships the wrong app without an error, and Expo CLI loads a committed .env into every local start, export and prebuild.
  2. Every variant installs beside the others. Production keeps the store id; development and preview append .dev and .preview to the iOS bundle identifier and the Android package, and every id is valid on both platforms. Why: a shared id overwrites the store app and its data on a tester's phone. Services keyed by app id (push, Sign in with Apple, IAP products, universal links, Firebase files) need each id registered or are tested on production-id builds, and ids built on the app id (App Groups, iCloud and keychain groups, extension bundle ids) derive from the variant's id.
  3. Every variant shows what it is. Its own name (Tasks (Dev), Tasks (Preview), the store name for production), its own icon files on iOS and Android (which exist: production keeps the store icons in assets/icons/production/, development and preview get badged copies), its own URL scheme, and the dev client's generated exp+<slug> scheme only in development. Why: testers, screenshots and bug reports must name the build; a missing icon file stops prebuild and every EAS Build with ENOENT; with two installed apps on one scheme iOS opens either one (which is undefined) while Android asks the user to choose.
  4. Every EAS build profile names its variant twice and its environment once. env.APP_VARIANT = env.EXPO_PUBLIC_APP_VARIANT = environment = the variant; only development sets developmentClient; extra profiles extend a variant profile; a shared base profile the variants extend names no variant, environment or developmentClient a child contradicts. The same two variables also exist as plain-text EAS environment variables in each environment, with equal values (EAS CLI warns that the profile's value wins). Why: eas.json env reaches builds only; eas update --environment <name> reads the EAS environment and nothing else.
  5. EXPO_PUBLIC_* is public. Metro inlines each value into the JS bundle, where anyone with the app can read it, so no EXPO_PUBLIC_ name or value is a secret: SECRET, TOKEN, PASSWORD, private keys, payment secret keys, and paid or LLM API keys (OpenAI, Anthropic, Gemini, ElevenLabs, Twilio, ...). The app calls a server that holds the key, such as an Expo Router API route. A key that is public by design (a Maps key restricted to the app id, a Stripe publishable key) goes into publicEnvAllow with a reason. Why: nothing that ships in the binary can be hidden, minified or bytecode alike, and a leaked LLM key bills the developer's account.
  6. App code reads the environment in one place, the way Metro inlines it. Only src/config/ reads process.env (except the literal comparison at an inclusion require(), rule 9), only as process.env.EXPO_PUBLIC_NAME with dot access; the variant comes from currentAppVariant(), never from Constants.expoConfig.extra or __DEV__. Server code is not app code: API routes (src/app/**/*+api.ts), server middleware and the helper folders named in serverDirs read their secrets from process.env at request time, and app code never imports them. Why: brackets, destructuring and non-public names are undefined in a release build while Jest still sees them, and a second source of truth drifts.
  7. Secrets live in EAS or CI, never in the repo or the app. Build-time secrets (SENTRY_AUTH_TOKEN, store keys) are EAS variables with secret visibility or CI secrets; runtime secrets for API routes are EAS variables with sensitive visibility (EAS Hosting cannot deploy secret ones); eas.json env and committed env files hold none; no secret flows into the app config, whose public part (extra, plugin options) is embedded in every build. Why: committed and embedded values are readable for good; rotating the secret is the only cure.
  8. Secret files are git-ignored. .gitignore covers .env*.local, *.p8, *.p12, *.jks, *.keystore, *.mobileprovision, credentials.json when a profile uses local credentials, and every key file eas.json points at. Why: one git add . publishes a signing key, and keys cannot be unpublished.
  9. Code is left out of a bundle only by a literal comparison at the require() (this skill owns that rule; debug-menu-and-test-hooks owns the gate file, architecture-and-boundaries the import side). Test-only code sits in one ternary with process.env.EXPO_PUBLIC_APP_VARIANT === 'production', never behind an imported constant, a helper call, __DEV__, NODE_ENV or an if block; any other inclusion flag works the same way (process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === 'true' ? require(...) : null). Debug-bundle tooling (Reactotron, a devtools package) is not test-only code: its __DEV__ gate is right, and other such modules go into devOnlyRequires. Why: verified on Expo SDK 57, Metro folds only literals in the same expression, so an imported IS_TEST_BUILD or STORYBOOK_ENABLED kept its module in the production bundle; __DEV__ folds but also strips preview builds.
  10. Metro's cache is keyed on the public values. metro.config.js sets cacheVersion from every EXPO_PUBLIC_* name and value, so it changes with any public value and differs per variant. Why: verified on Expo SDK 57, a production export made after a development export reused the development bundle byte for byte, test-only code included. It bites every expo export and every release build on a machine with CI set; only eas update --environment clears the cache by itself.
  11. The exported bundle proves the strip. After a change to variants, gates, Metro or variables, export development and production back to back without --clear and check both: production holds no test-only sentinel and no secret; development holds every sentinel. Why: the gate, the cache key and the variables only work together, and only the artifact shows it.

Workflow

  1. Decide the identities. Read references/variant-matrix.md: the three variants, ids, names, icons, schemes, which services need each id registered, and the moments a variant enters the binary on iOS and Android.
  2. Merge the templates into the app (line 1 names each repo path; fill __APP_ID__, __APP_NAME__, __APP_SLUG__, __APP_SCHEME__). Nothing the app already configures is lost:
    • First save the production config: mkdir -p dist && APP_VARIANT=production EXPO_PUBLIC_APP_VARIANT=production npx expo config --type public --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.stringify(JSON.parse(s),null,2)))' > dist/config-before.json (dist/ is git-ignored).
    • Copy templates/src/config/app-variant.ts and its test (the canonical resolver, shared byte for byte with the build, release and ads skills), templates/src/config/current-app-variant.ts and its test, and templates/src/env.d.ts.
    • Build app.config.ts from templates/app.config.ts and move every value of the old app.json or app config into its returned object: owner, version, updates.url, runtimeVersion, extra.eas.projectId (the EAS project link), every plugin with its options, ios.infoPlist usage strings, android.permissions, colours, splash. Only the id, name, scheme, icons and dev-client scheme depend on the variant; an old extra.appVariant goes (the app asks currentAppVariant()).
    • Move the app's icon files into assets/icons/production/: icon.png, its Icon Composer folder (expo.icon in the SDK 57 template; set STORE_IOS_ICON to its name, or to 'icon.png' when there is none), and the adaptive layers renamed android-foreground.png, android-background.png, android-monochrome.png. Then node ${CLAUDE_SKILL_DIR}/scripts/make-variant-icons.mjs . writes badged copies to assets/icons/development/ and assets/icons/preview/ (amber band, violet hatched band), so every variant builds; icons-app-icon-and-splash may redraw them at the same paths.
    • Metro: copy templates/metro.config.js with templates/scripts/metro/public-env-cache-version.ts and compose-metro-config.ts (each with its test). Its Metro slot composes the optional kits of scripts/metro/metro-kits.ts (crash reporting's Sentry config is one; app-startup-and-bootstrap's slot contract), so an old file's wrappers (Sentry, NativeWind, SVG) move into a MetroKit there (baseConfig for one that builds on Expo's default, else transform). An app that keeps its own metro.config.js adds config.cacheVersion = publicEnvCacheVersion(process.env) on the object it finally exports.
    • templates/eas.json: merge its three build profiles into the app's eas.json (a shared base they extend may stay). When there is no eas.json yet, leave it: eas-build-and-submit creates it with these profiles, and until then check-variants prints the expected eas.json SKIP.
    • templates/dot-env.example to .env.example, and add !.env.example to .gitignore when .env* is ignored.
    • npx expo install expo-dev-client expo-splash-screen (the config names both plugins; the development profile builds the dev client).
    • Save dist/config-after.json the same way and diff dist/config-before.json dist/config-after.json: only icon paths, the expo-dev-client plugin and an old extra.appVariant may change. Then delete app.json.
  3. Route every read through src/config/. Replace variant checks with currentAppVariant(); give each other public value a typed reader like examples/environment.ts and a line in src/env.d.ts. Read references/environment-variables.md for inlining, .env loading, EAS environments and CI.
  4. Place every secret. Read references/secrets.md, list the build-time and runtime secrets, move any paid or LLM API call behind an API route, and hand the developer the eas env:set commands (secret visibility for build-time secrets, sensitive for API-route secrets, plain text for APP_VARIANT and EXPO_PUBLIC_APP_VARIANT in all three environments). They run in the developer's Expo account: stop and ask before creating, changing or deleting any EAS variable, and before rotating a leaked key.
  5. Gate test-only code with the literal comparison and export a sentinel from the test-only entry: references/stripping-test-code.md. The gate file and the debug registry belong to debug-menu-and-test-hooks.
  6. Run the app's gates: npm run typecheck, npx eslint . --max-warnings 0, npx jest src/config scripts/metro --ci --maxWorkers=2.
  7. Look at each variant: APP_VARIANT=preview EXPO_PUBLIC_APP_VARIANT=preview npx expo config --type public (and development, production), then APP_VARIANT=preview EXPO_PUBLIC_APP_VARIANT=preview npx expo prebuild --no-install --clean proves the icons and plugins resolve. Before a local build after switching variants, run that prebuild with the build's two variables: ios/ and android/ keep the previous variant until regenerated.
  8. Check: node ${CLAUDE_SKILL_DIR}/scripts/check-variants.mjs . and node ${CLAUDE_SKILL_DIR}/scripts/check-env.mjs .. Each FAIL line names the file, the rule and the fix; references/pitfalls-and-tests.md explains every rule. Fix and rerun until both print RESULT: PASS.
  9. Prove the strip when variants, gates, metro.config.js or variables changed: export both variants back to back into dist/variant-proof/development and dist/variant-proof/production (commands in examples/variant-proof.md; dist/ is already git-ignored), then node ${CLAUDE_SKILL_DIR}/scripts/check-variant-bundle.mjs . --bundle dist/variant-proof/development --variant development and the same with dist/variant-proof/production --variant production. Delete dist/variant-proof/ afterwards.
  10. Report the three ids and names, the EAS variables the developer still has to create, the SKIP lines, and every RESULT line.

Definition of done

  • src/config/app-variant.ts is the canonical file, app.config.ts resolves the variant through it, the production config diff shows only the intended keys, and there is no app.json.
  • npx expo config --type public shows three distinct ids, names, icons and schemes; an unknown or mismatched variant fails; every icon file exists and a preview prebuild succeeds.
  • When eas.json exists, it has development, preview and production profiles with APP_VARIANT, EXPO_PUBLIC_APP_VARIANT and environment equal; the developer has the eas env:set list for the EAS environments.
  • App code reads the variant only through currentAppVariant() and other public values only through src/config/ (an inclusion flag's literal comparison at its require() excepted); no secret (an LLM or paid API key included) has an EXPO_PUBLIC_ name or sits in a committed file; every plugin the config names is installed.
  • metro.config.js keys cacheVersion on the EXPO_PUBLIC_* values; the app's typecheck, lint and Jest pass.
  • When strip-relevant code changed: node ${CLAUDE_SKILL_DIR}/scripts/check-variant-bundle.mjs . --bundle dist/variant-proof/production --variant production and the development run print RESULT: PASS.
  • Expected SKIP lines only: SKIP eas.json [eas-profile] not yet created (eas-build-and-submit creates it) before EAS is set up, and SKIP src/test-only.ts [bundle-sentinel] not yet created (debug-menu-and-test-hooks creates it) while the app has no test-only gate. A note: line about unpinned submit ids is a hand-off to eas-build-and-submit, not a failure.
  • node ${CLAUDE_SKILL_DIR}/scripts/check-env.mjs . prints RESULT: PASS
  • node ${CLAUDE_SKILL_DIR}/scripts/check-variants.mjs . prints RESULT: PASS

Anti-patterns

  • Copying templates/app.config.ts over the app's config. The EAS project link, updates.url, runtimeVersion, plugin options, usage strings and permissions vanish without an error; merge, then diff the production config.
  • APP_VARIANT only in eas.json. eas update --environment production never reads eas.json: with this resolver the update fails, and with one that defaults it ships as development, test hooks included. Create the two variables in every EAS environment as well.
  • eas update without --environment in CI. EAS CLI asks for an environment only outside CI, and even then the answer only labels the update: the export gets no EAS variables, loads the runner's .env files and reuses a warm Metro cache. Always pass --environment <variant>.
  • const isDev = process.env.APP_VARIANT === 'development' in app code. APP_VARIANT is not inlined: it is undefined in every release build. Call currentAppVariant().
  • __DEV__ as "the development variant". __DEV__ says whether Metro served a debug bundle; preview and production are release builds, and a development build can load a release bundle. Ask the variant.
  • .env.staging or NODE_ENV=staging to pick an environment. npx expo export forces NODE_ENV=production; variants and EAS environments are the switch.
  • A secret behind EXPO_PUBLIC_ "because the bundle is minified". Hermes bytecode keeps string literals readable. Call a server, or keep the secret in a build step.
  • EXPO_PUBLIC_OPENAI_API_KEY so the app can call the model directly. Anyone can lift the key from the bundle and spend the developer's quota. Put the call in an API route (src/app/api/<name>+api.ts) that reads OPENAI_API_KEY from process.env, deployed with eas deploy --environment production.
  • A standalone eas submit with nothing exported. It evaluates the config without the build profile env, so the resolver gives the development id. Export the production pair for it, or pin the submit ids (eas-build-and-submit).
  • One bundle id for every variant to keep push or IAP simple. Every install replaces the store app. Register the extra ids, or test those services on production-id TestFlight or Play internal builds.
  • IS_TEST_BUILD ? require('./debug-menu') : null, or STORYBOOK_ENABLED ? require(...) with the flag read in src/config/. Metro cannot fold an imported constant: the module ships (both verified on SDK 57). Write the literal comparison at the call.
  • APP_VARIANT=production in a committed .env. Every developer's expo start and prebuild then builds the store identity; the variant belongs on the command line, in eas.json and in the EAS environments.
  • npx expo export --clear to fix a stale variant. It hides the missing cache key until the next build; key cacheVersion on the public values.
  • Passing a token into extra or plugin options to use it later. The public config is embedded in the app; tools read secrets from their own environment.
  • Exporting the variables for expo prebuild only, then building in a fresh shell. The Constants step and Metro run again during the native build and need the same two variables.

Files in this skill

File What it is Read/run when
references/variant-matrix.md The three variants, the resolver contract per command, ids, names, icons, schemes, services and entitlements keyed by id, EAS commands outside a build, the moments a variant enters the binary Workflow step 1
references/environment-variables.md How EXPO_PUBLIC_* inlining works (verified on SDK 57), .env loading, the readers in src/config/, server code (API routes), EAS environments, CI Workflow step 3
references/secrets.md Public versus secret (LLM and paid API keys), where each secret lives (API routes for runtime secrets), what ships in the binary, the canary check, .gitignore, leak response Workflow step 4
references/stripping-test-code.md The inclusion gate, the Metro cache trap, the SDK 57 experiments, sentinels, the bundle proof Workflow steps 5 and 9
references/pitfalls-and-tests.md Symptoms with causes and fixes, test recipes, every checker rule, rn-skills.config.json keys A check fails; writing tests
templates/src/config/app-variant.ts The canonical resolver: resolveAppVariant, parseAppVariant, variantIdentity, variantEnv (synced from the library; do not edit here) Workflow step 2
templates/src/config/app-variant.test.ts Its tests, including a fast-check property (synced; do not edit here) With the resolver
templates/src/config/current-app-variant.ts currentAppVariant(): the one runtime reader of the variant Workflow step 2
templates/src/config/current-app-variant.test.ts Its tests With the reader
templates/src/env.d.ts The typed EXPO_PUBLIC_* declarations both TypeScript programs read (synced from the library; do not edit here) Workflow step 2; each new public variable
templates/app.config.ts The app config: identity, icons (iconsFor, STORE_IOS_ICON), scheme and dev-client scheme per variant; comments show where merged values go Workflow step 2
templates/metro.config.js Metro config with cacheVersion keyed on the public values Workflow step 2
templates/scripts/metro/public-env-cache-version.ts The tested cache-key helper metro.config.js requires Workflow step 2
templates/scripts/metro/compose-metro-config.ts The Metro slot: readMetroKits (the optional scripts/metro/metro-kits.ts) and composeMetroConfig (Expo's default or one kit's base, then each kit's transform); metro.config.js requires it Workflow step 2; a kit that needs Metro
templates/scripts/metro/compose-metro-config.test.ts Its tests: no kits, one base and transforms in order, two bases refused, the kits file With the helper
templates/scripts/metro/public-env-cache-version.test.ts Its tests With the helper
templates/eas.json The three build profiles wired to their variant and EAS environment, merged into the app's eas.json (eas-build-and-submit creates the file) Workflow step 2
templates/dot-env.example .env.example: every variable by name, no values Workflow step 2
examples/environment.ts A typed public-environment reader for the Tasks app (shape only) Workflow step 3
examples/variant-proof.md The back-to-back export proof with real output Workflow step 9
scripts/make-variant-icons.mjs Writes badged development and preview icons from assets/icons/production/ (checks the sources are square PNGs, and that the two themed icons differ in alpha) Workflow step 2
scripts/check-variants.mjs Evaluates the config per variant and per broken environment (committed env files loaded): resolve, plugins installed, ids, names, icon files and icons, schemes, eas.json profiles (shared bases too), secrets in the config; loads metro.config.js to prove the cache key Workflow step 8
scripts/check-env.mjs Scans app code (src/, a root app/, the entry and everything they import) and files: public-env secrets (LLM and paid API keys included, the app config too), the one reader (server code exempt), the variant reader, inclusion decided at the require(), server imports, secrets and variants in eas.json and env files, .gitignore Workflow step 8
scripts/check-variant-bundle.mjs Checks an exported bundle for sentinels and secrets against its variant (secrets even before a test-only gate exists) Workflow step 9
scripts/lib/env-scan.mjs Shared helpers: secret names and values, dotenv, .gitignore matching, eas.json profiles Only when changing a checker
scripts/lib/source-scan.mjs Dependency-free source lexer (synced from the library; do not edit here) Only when changing a checker
scripts/check-lib.mjs Shared checker library (synced from the library; do not edit here) Never by hand
scripts/selftest.mjs Runs every checker against the fixtures After changing a checker or a template
tests/bases/ The base app and the planted decoys the fixtures assemble from When adding a fixture
tests/fixtures/ Overlay cases per script: good, bad cases per rule, pass and error cases When adding a rule
assets/shared.json The shared files this skill syncs in When adding a shared file
assets/template-placeholders.json Values for the __UPPER_SNAKE__ placeholders in template checks When a template gains a placeholder

Related skills

  • expo-prebuild-and-config-plugins - app.config.ts as the single source, config plugins, never editing native folders.
  • eas-build-and-submit - the rest of eas.json: credentials, versions, submit profiles; Expo's official eas-app-stores skill is the EAS API reference.
  • debug-menu-and-test-hooks - the test-only gate file, the debug registry and the sentinel this skill's bundle check reads.
  • icons-app-icon-and-splash - may redraw the badged development and preview icons at assets/icons/<variant>/ (icon.png 1024 x 1024, android-foreground/background/monochrome.png).
  • privacy-and-store-declarations - the SDK inventory, store privacy answers and repository-wide secret audit.
  • networking-and-server-state - the ApiClient that calls the app's API routes; Expo's official eas-hosting skill is the API-route and EAS Hosting reference.
  • crash-reporting-and-observability - Sentry release names per variant and the upload token as an EAS secret.
  • ota-updates - channels per variant and eas update --environment.
  • ci-pipeline-and-gates - CI secrets and the variables every CI job exports.
  • ios-simulator-build, android-emulator-build - building and proving each variant on a named device.