Skip to content

Releasing a New Version ​

How a new build of the mobile app goes from a merged commit to users' devices β€” through store release (native builds) or OTA update (JS-only changes).

Two delivery paths, pick by what changed:

What changedPath
JS/TS, styles, translations, Metro-bundled assets, feature-flag featuresOTA (EAS Update)
Any native dependency, Expo SDK bump, config plugin, permission, Firebase blob, entitlement / Info.plist / AndroidManifestStore build

Rule of thumb: if it changes the native fingerprint, it needs a store build. If unsure, publish OTA anyway β€” a fingerprint mismatch means old binaries simply don't receive it (see What is OTA-shippable).

TIP

For the raw eas build / eas submit mechanics, profiles, and Play service-account setup, see Build & Submit. This page is the end-to-end workflow.

Version knobs ​

Two independent version numbers. Don't conflate them.

KnobiOSAndroidSourceWhen to bump
Marketing versionCFBundleShortVersionStringversionNameapp.config.ts version ← reads package.json version (single source)Once per public release
Build numberCFBundleVersionversionCodeAuto-incremented remotely (eas.json appVersionSource: "remote")Every build (automatic)

IMPORTANT

Bump marketing version only per public release, not per build. Apple closes a pre-release train once its marketing version is released (Invalid Pre-Release Train … is closed). Multiple TestFlight builds share one marketing version β€” the automatic build-number bump is enough until it goes public.

Store release (native build) ​

1. Cut from the right branch ​

Store builds embed the JS at build time. Always build from the branch matching the target environment, never a stale local checkout β€” otherwise fresh installs launch on old code.

  • staging branch β†’ staging backend, internal testers
  • production branch β†’ production backend, TestFlight + public

Promotion is merge-forward: main β†’ staging β†’ production (never skip an env).

2. Staging build (internal testing first) ​

bash
# Build both platforms + auto-submit to internal tracks
pnpm build:submit:staging          # eas build --platform all --profile staging --auto-submit --non-interactive
pnpm build:submit:staging:ios      # iOS only

Lands on Play internal track / TestFlight within minutes-to-review. Smoke-test here before promoting.

3. Production release ​

Use the release script β€” it bumps the marketing version, commits package.json, then builds + auto-submits in one shot:

bash
pnpm release:production        # version:patch β†’ commit β†’ build:submit:production (all platforms)
pnpm release:production:ios    # iOS only

What it runs under the hood:

version:patch          # pnpm version patch --no-git-tag-version (marketing version, e.g. 1.0.1 β†’ 1.0.2)
git commit package.json -m "πŸ”– chore(mobile): bump marketing version to X.Y.Z"
build:submit:production # eas build --platform all --profile production --auto-submit

NOTE

The version bump lives in release:production, not in build:submit:production β€” that script stays pure and re-runnable. If you need to rebuild the same version without bumping, call build:submit:production directly.

4. Store-side release (the human step) ​

--auto-submit uploads the build to the store; it does not make it public.

  • Google Play: build lands on the production track. First submission of an app requires review; the very first release of a brand-new app cannot be created by eas submit β€” upload that first AAB via the Play Console UI. Release #2+ works through eas submit.
  • Apple: build lands in App Store Connect / TestFlight. "Promote to public" is done inside the production ASC app (TestFlight β†’ public) β€” the backend URL is baked at build time, so there is no rebuild.

Store apps / bundles:

EnvBundleBackendDistribution
Stagingcom.tellia.mobile.stagingStagingInternal testers
Prodcom.tellia.mobileProductionTestFlight beta + public

5. Store listing metadata (App Store only) ​

If the release changes the "What's New" text, description, screenshots, or any other App Store Connect listing field, update apps/mobile/store.config.js and push it β€” don't hand-edit the ASC portal, it'll drift from the repo again.

bash
pnpm --filter @tellia-solutions/mobile-app metadata:pull   # check live ASC state first β€” see caveat below
pnpm --filter @tellia-solutions/mobile-app metadata:push   # needs a submitted binary first

WARNING

metadata:push overwrites the live listing with whatever's in store.config.js β€” always metadata:pull and diff before editing, and never push right after a schema/config change without a fresh pull. See apps/mobile/marketing/README.md for the full setup (env vars for the demo password / review phone, why it's a .js not .json config).

EAS Metadata is Apple-only (preview feature, no Play Store equivalent). Google Play listing text is still copy-pasted into Play Console by hand from apps/mobile/marketing/play-store/copy/*.md.

OTA updates (EAS Update) ​

JS-only changes ship over the air β€” no store round-trip. Full runbook lives in the app repo at apps/mobile/EAS_UPDATE.md.

Publish ​

bash
# From apps/mobile β€” prompts for an update message
pnpm update:staging      # channel "staging",    EAS env "preview"
pnpm update:production    # channel "production", EAS env "production"

Publishing is manual-only, on demand β€” no CI path by design.

Requirements:

  • SENTRY_AUTH_TOKEN in the shell (scripts upload source maps from dist/ after publish).
  • Authenticated EAS CLI (eas whoami).
  • Publish from a clean checkout of the branch matching the channel β€” the bundle is whatever your working tree contains.

WARNING

Never publish with local EXPO_PUBLIC_* overrides exported. Both scripts pin bundle env via eas update --environment <preview|production> (server-side EAS env, same values store builds bake). A bundle published with the wrong API URL repoints every installed app.

Runtime behavior ​

Native check at cold launch + foreground checks (hooks/use-ota-updates.ts). Download is silent and applies on the next cold launch β€” never blocks startup; a failed download keeps the current bundle.

How the fingerprint gates OTA ​

runtimeVersion: { policy: 'fingerprint' } hashes the native project. An update only reaches binaries with an identical native runtime. Any native change produces a new fingerprint β†’ old binaries are structurally unreachable by the new JS. No manual version discipline needed.

The publish-side fingerprint (your machine) and build-side fingerprint (EAS builder) must be identical or EAS fails the build with "Runtime version mismatch". Stabilizers that keep them equal (don't remove without understanding): .fingerprintignore (Firebase blobs), fingerprint.config.js (skips package.json scripts), and inline APP_VARIANT pinning in the update scripts. Truth for debugging drift:

bash
APP_VARIANT=preview GOOGLE_SERVICES_JSON=./firebase-configs/staging/google-services.json \
  eas fingerprint:compare --build-id <id>

What is OTA-shippable ​

  • OTA-safe: JS/TS, styles, translations, Metro-bundled assets, feature-flag-guarded features.
  • Store build required: any native-code dependency, Expo SDK bump, app.config.ts native config (plugins, permissions, expo-build-properties, Firebase blobs), anything touching entitlements / Info.plist / AndroidManifest.

Gradual rollout ​

bash
eas update --channel production --environment production --rollout-percentage 20
# then widen:
eas update:edit

Rollback ​

bash
eas update:republish --channel production          # republish a known-good group (interactive)
eas update:roll-back-to-embedded --channel production  # revert to JS embedded in the binary

Devices pick up the rollback on their next check (foreground or cold launch).

Verify an update landed ​

  • eas update:list --branch production β€” published update groups.
  • EAS Update insights (expo.dev β†’ Updates): embedded-vs-OTA adoption per channel, crash correlation.
  • Sentry events carry expo-update-id (or expo-is-embedded-update: true for binary JS).

Decision cheat-sheet ​

Merged a change β†’ need it on devices?
β”œβ”€ Only JS / styles / translations / assets / flags?
β”‚   └─ pnpm update:staging  β†’ verify β†’ pnpm update:production
└─ Touched anything native (deps, SDK, plugins, permissions, blobs)?
    └─ pnpm build:submit:staging β†’ smoke test
       β†’ pnpm release:production β†’ release in store console