Skip to Content
Living documentation — last reviewed 2026-05-28
RunbooksMobile builds, OTA updates, and store releases

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

GoalCommand
Run on a simulator with the dev serverpnpm 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 installseas update --branch production --message "..."
Build a new TestFlight / internal previewpnpm build:preview
Build a store-bound releasepnpm 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, …).

ProfileChannelDistributionAPI URL
developmentdevelopmentinternal (dev client)preview Railway
previewpreviewinternalpreview Railway
productionproductionApp Store + Playhttps://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 version for an OTA. Bumping version makes existing installs ineligible for the OTA — they’ll keep running the older bundle until they update from the store.
  • SENTRY_AUTH_TOKEN is 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.ts permissions, 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:production

Builds 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)

VarSourceUsed by
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEYPer-env Clerk dashboardAuth
EXPO_PUBLIC_API_URLSet in eas.json per profileAPI client base URL
EXPO_PUBLIC_WS_URLSet in eas.json per profileSocket.IO
EXPO_PUBLIC_WEB_URLSet in eas.json per profileWeb fallback URL builder
EXPO_PUBLIC_SENTRY_DSNSet in eas.json per profileSentry init
EXPO_PUBLIC_POSTHOG_HOSTSet in eas.json per profilePostHog init
EXPO_PUBLIC_POSTHOG_KEYEAS dashboardPostHog init
SENTRY_AUTH_TOKENEAS dashboard (secret)Source-map upload on build + OTA
GITHUB_TOKENEAS 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 by version (policy: 'appVersion'). Each version value gets its own OTA channel slice; OTAs published for 1.2.0 only land on 1.2.0 installs.

Submitting to the stores

# After a successful production build: eas submit --profile production --platform ios eas submit --profile production --platform android

iOS

  • 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: false in Sentry init matches NSPrivacyTracking=false.
  • Permissions strings must be intelligible — see infoPlist.NS*UsageDescription keys in app.config.ts.

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/json content 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/generator

Common failures

SymptomCauseFix
getExpoPushTokenAsync throws “Couldn’t get experience id”Running on a simulator OR missing eas.projectId in app.config.ts.extraTest on a physical device; verify EAS_PROJECT_ID env is set
Sentry source maps not uploadingSENTRY_AUTH_TOKEN missing on EAS, or wrong org/project slugRe-set the secret; confirm app.config.ts plugin args
AASA / assetlinks return 200 but iOS / Android won’t open the appCached at the OS/CDN leveliOS: delete + reinstall app. Android: adb shell pm clear com.android.vending for verified links cache
OTA published but installs still on old bundleWrong runtimeVersion (mismatch with installed version)OTAs only apply within the same version. Build native + ship to store.
pnpm install fails on @fitkit/sharedMissing or expired $GITHUB_TOKEN PATRe-issue PAT with read:packages, export it in shell profile
Hebrew screens show LTR after locale switchI18nManager.isRTL got stuck from a previous sessionI18nProvider should auto-reset on cold start. If it persists, force-quit + reopen the app.
  • docs/architecture/mobile.md — mobile architecture deep dive
  • docs/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