app-variants-and-environment

Installation
SKILL.md

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

Installs
5
GitHub Stars
12
First Seen
13 days ago