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
- One resolver decides the variant.
app.config.tstakes it fromresolveAppVariant(process.env)insrc/config/app-variant.ts: onlydevelopment,previeworproduction; an unknown name throws;APP_VARIANTandEXPO_PUBLIC_APP_VARIANTmust 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.envinto every localstart,exportandprebuild. - Every variant installs beside the others. Production keeps the store id; development and preview append
.devand.previewto 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. - 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 inassets/icons/production/, development and preview get badged copies), its own URL scheme, and the dev client's generatedexp+<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. - 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 setsdevelopmentClient; extra profiles extend a variant profile; a sharedbaseprofile the variants extend names no variant, environment ordevelopmentClienta 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.jsonenvreaches builds only;eas update --environment <name>reads the EAS environment and nothing else. EXPO_PUBLIC_*is public. Metro inlines each value into the JS bundle, where anyone with the app can read it, so noEXPO_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 intopublicEnvAllowwith 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.- App code reads the environment in one place, the way Metro inlines it. Only
src/config/readsprocess.env(except the literal comparison at an inclusionrequire(), rule 9), only asprocess.env.EXPO_PUBLIC_NAMEwith dot access; the variant comes fromcurrentAppVariant(), never fromConstants.expoConfig.extraor__DEV__. Server code is not app code: API routes (src/app/**/*+api.ts), server middleware and the helper folders named inserverDirsread their secrets fromprocess.envat request time, and app code never imports them. Why: brackets, destructuring and non-public names areundefinedin a release build while Jest still sees them, and a second source of truth drifts. - 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.jsonenvand 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. - Secret files are git-ignored.
.gitignorecovers.env*.local,*.p8,*.p12,*.jks,*.keystore,*.mobileprovision,credentials.jsonwhen a profile uses local credentials, and every key file eas.json points at. Why: onegit add .publishes a signing key, and keys cannot be unpublished. - Code is left out of a bundle only by a literal comparison at the
require()(this skill owns that rule;debug-menu-and-test-hooksowns the gate file,architecture-and-boundariesthe import side). Test-only code sits in one ternary withprocess.env.EXPO_PUBLIC_APP_VARIANT === 'production', never behind an imported constant, a helper call,__DEV__,NODE_ENVor anifblock; 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 intodevOnlyRequires. Why: verified on Expo SDK 57, Metro folds only literals in the same expression, so an importedIS_TEST_BUILDorSTORYBOOK_ENABLEDkept its module in the production bundle;__DEV__folds but also strips preview builds. - Metro's cache is keyed on the public values.
metro.config.jssetscacheVersionfrom everyEXPO_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 everyexpo exportand every release build on a machine withCIset; onlyeas update --environmentclears the cache by itself. - The exported bundle proves the strip. After a change to variants, gates, Metro or variables, export development and production back to back without
--clearand 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.