Mobile builds, OTA updates, and store releases
How to ship fitkit-mobile to TestFlight, internal testers, the App Store, and Google Play. Covers EAS profiles, OTA updates, version bumps, and store metadata.
The mobile app lives in a separate repo (
fitkit-mobile, sibling to this monorepo on disk). All commands below run from that repo unless noted.
Quick reference
| Goal | Command |
|---|---|
| Run on a simulator with the dev server | pnpm start, then press i / a |
| Build a development client (rare; once per native dep change) | pnpm prebuild && pnpm ios |
| Ship JS/asset-only fix to existing installs | eas update --branch production --message "..." |
| Build a new TestFlight / internal preview | pnpm build:preview |
| Build a store-bound release | pnpm build:production |
| Submit the built archive to the stores | `eas submit —profile production —platform <ios |
Build profiles
eas.json defines three profiles. Each pins its own env (API URL, Sentry DSN, PostHog key, …).
| Profile | Channel | Distribution | API URL |
|---|---|---|---|
development | development | internal (dev client) | preview Railway |
preview | preview | internal | preview Railway |
production | production | App Store + Play | https://api.fitkit.fit |
appVersionSource: remote — EAS owns the version-number bumps for production builds (autoIncrement: true). Don’t hand-edit build numbers in app.config.ts.
OTA updates (eas update)
OTA pushes a JS+asset bundle to existing installs without going through review. Bound by runtimeVersion: { policy: 'appVersion' } in app.config.ts — updates only land on installs whose version matches.
# Production (live App Store / Play users):
eas update --branch production --message "Fix RTL flicker on schedule scroll"
# Preview (internal testers):
eas update --branch preview --message "..."Rules of thumb:
- OTA is for JS, assets, and config changes. Anything that requires
expo prebuild(new native module, native config plugin, permission, intent filter, associated domain) needs a fresh native build. - Don’t bump
versionfor an OTA. Bumpingversionmakes existing installs ineligible for the OTA — they’ll keep running the older bundle until they update from the store. SENTRY_AUTH_TOKENis required to upload source maps for the OTA bundle. Set it once as a secret env in the EAS dashboard.
Native builds (eas build)
Triggered when any of these change:
app.config.tspermissions, intent filters, plugins, associated domains- Any package that ships native code (most
expo-*packages,@sentry/react-native,react-native-*) - iOS or Android version bump for a store submission
# Internal preview — for TestFlight + Play internal testing
pnpm build:preview
# Production — App Store + Play production
pnpm build:productionBuilds run on EAS servers (~15–25 min). Output is an .ipa (iOS) or .aab (Android) downloadable from the EAS dashboard. CI handles upload of the artifact URL into the EAS history; the binary itself isn’t checked in.
Required env vars (EAS secrets)
| Var | Source | Used by |
|---|---|---|
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY | Per-env Clerk dashboard | Auth |
EXPO_PUBLIC_API_URL | Set in eas.json per profile | API client base URL |
EXPO_PUBLIC_WS_URL | Set in eas.json per profile | Socket.IO |
EXPO_PUBLIC_WEB_URL | Set in eas.json per profile | Web fallback URL builder |
EXPO_PUBLIC_SENTRY_DSN | Set in eas.json per profile | Sentry init |
EXPO_PUBLIC_POSTHOG_HOST | Set in eas.json per profile | PostHog init |
EXPO_PUBLIC_POSTHOG_KEY | EAS dashboard | PostHog init |
SENTRY_AUTH_TOKEN | EAS dashboard (secret) | Source-map upload on build + OTA |
GITHUB_TOKEN | EAS dashboard (secret) | @fitkit/shared pull from GitHub Packages |
Anything beginning with EXPO_PUBLIC_ is bundled into the JS, not a runtime secret. Don’t put private keys there.
Version + build number policy
version(1.2.0, etc.) — bump only for a store-bound release. Semver-ish; users see it in App Store / Play. Bumping breaks the OTA chain for existing installs (intentional — a new native build wants its own version).ios.buildNumber/android.versionCode— auto-incremented by EAS (appVersionSource: remote). Don’t hand-edit.runtimeVersion— driven byversion(policy: 'appVersion'). Eachversionvalue gets its own OTA channel slice; OTAs published for1.2.0only land on1.2.0installs.
Submitting to the stores
# After a successful production build:
eas submit --profile production --platform ios
eas submit --profile production --platform androidiOS
- Apple ID:
saarku@gmail.com(current owner) - Team ID:
P25F278UF8 - ASC App ID:
6773642976
The first time EAS submits to a new ASC App ID, App Store Connect needs the export-compliance answer (ITSAppUsesNonExemptEncryption: false in app.config.ts covers this for HTTPS-only traffic).
Then in ASC: attach to TestFlight build → external testers → submit for App Review when ready.
Android
- Package:
fit.fitkit.app - Internal testing: upload AAB to Play Console “Internal testing” track first
- Promote to Closed (preview) → Open (preview) → Production
Store metadata
App Store and Play Console screenshots, copy, what’s-new, and category metadata are not in this repo. They live in the respective consoles. Update them in lockstep with a version bump.
When App Store Review rejects:
- Common rejection: missing in-app account-deletion. We have it at
app/(tabs)/profile/delete-account.tsx— point reviewers at the path if asked. - Privacy questionnaire must match
PrivacyInfo.xcprivacy.sendDefaultPii: falsein Sentry init matchesNSPrivacyTracking=false. - Permissions strings must be intelligible — see
infoPlist.NS*UsageDescriptionkeys inapp.config.ts.
Deep-link verification
iOS associated domains and Android app links require server-side files on app.fitkit.fit:
- AASA:
https://app.fitkit.fit/.well-known/apple-app-site-association(no extension,application/jsoncontent type) - assetlinks.json:
https://app.fitkit.fit/.well-known/assetlinks.json
These are served by apps/web. See FIT-188. Verify after a release:
# iOS — Apple's CDN validator (caches ~24h):
curl https://app-site-association.cdn-apple.com/a/v1/app.fitkit.fit
# Android — Google's official validator:
# https://developers.google.com/digital-asset-links/tools/generatorCommon failures
| Symptom | Cause | Fix |
|---|---|---|
getExpoPushTokenAsync throws “Couldn’t get experience id” | Running on a simulator OR missing eas.projectId in app.config.ts.extra | Test on a physical device; verify EAS_PROJECT_ID env is set |
| Sentry source maps not uploading | SENTRY_AUTH_TOKEN missing on EAS, or wrong org/project slug | Re-set the secret; confirm app.config.ts plugin args |
| AASA / assetlinks return 200 but iOS / Android won’t open the app | Cached at the OS/CDN level | iOS: delete + reinstall app. Android: adb shell pm clear com.android.vending for verified links cache |
| OTA published but installs still on old bundle | Wrong runtimeVersion (mismatch with installed version) | OTAs only apply within the same version. Build native + ship to store. |
pnpm install fails on @fitkit/shared | Missing or expired $GITHUB_TOKEN PAT | Re-issue PAT with read:packages, export it in shell profile |
| Hebrew screens show LTR after locale switch | I18nManager.isRTL got stuck from a previous session | I18nProvider should auto-reset on cold start. If it persists, force-quit + reopen the app. |
Related
docs/architecture/mobile.md— mobile architecture deep divedocs/architecture/auth.md— Clerk model (same publishable key as web)docs/features/push-notifications/— fan-out + server-side delivery- FIT-188 — Universal-link + App Link asset files