Builds & Releases
The mobile app ships through EAS Build and EAS Submit, not through the Kubernetes cluster.
Why you cannot use Expo Go
The app depends on native modules that Expo Go does not include:
@livekit/react-native-webrtc— voice and video roomsexpo-notifications— pushreact-native-purchases— in-app purchases
You need a development build: a custom binary containing the app's native modules, which then loads your JavaScript from Metro. Install it once and iterate on JS normally.
Build profiles
apps/mobile/eas.json defines three. Each one names an EAS environment and an update channel of the same name:
| Profile | Distribution | EAS environment / channel | EXPO_PUBLIC_ENV |
|---|---|---|---|
development | internal, iOS simulator | development | development |
preview | internal | preview | staging |
production | store | production | production |
The EXPO_PUBLIC_* variables (API and socket hosts, Stripe and RevenueCat keys, PostHog) live in the EAS environments, not in eas.json. Builds and over-the-air updates both read them from there, so a build and the updates published to its channel always agree. List them with eas env:list --environment <name> and change them with eas env:set.
Never add an env block to an eas.json build profile
eas update ignores it. An update would then bundle whatever apps/mobile/.env holds instead — localhost hosts and EXPO_PUBLIC_ENV=development — and ship it to every installed build on the channel.
production sets autoIncrement: true, so the build number rises automatically.
appVersionSource: "remote" means EAS owns the build number, not the repo — do not hand-edit it.
App identity
| Value | |
|---|---|
| Slug | playpalz |
| iOS bundle id | com.playpalzinc.playpalz |
| Android package | com.playpalzinc.playpalz |
| Version | 1.0.0 (app.json) |
app.config.ts overrides the display name and URL scheme per environment on top of app.json:
EXPO_PUBLIC_ENV | Name | Scheme |
|---|---|---|
development | PlayPalz Dev | playpalz-dev |
staging | PlayPalz Staging | playpalz-staging |
production | PlayPalz | playpalz |
Per-environment bundle identifiers are commented out
app.config.ts contains commented-out bundleIdentifier and package overrides. As it stands, all three environments share one identifier, so a dev, staging, and production build cannot coexist on one device — installing one replaces another. Uncommenting them (and registering the extra ids with Apple and Google) is what makes side-by-side installs possible.
Building
npm i -g eas-cli
eas login
cd apps/mobile
eas build --profile development --platform ios
eas build --profile preview --platform all
eas build --profile production --platform allA development build for the simulator (ios.simulator: true) produces a .app you drag onto a running simulator. Device builds need the device UDID registered.
Store listing (EAS Metadata)
The App Store listing is kept in the repo and pushed with EAS Metadata. EAS Metadata only supports Apple; the Google Play listing is still edited by hand in Play Console.
| File | Owns |
|---|---|
store.config.json | Static listing: title, subtitle, description, keywords, release notes, promo text, URLs, categories, age-rating answers, release strategy |
store.config.js | The entry point (submit.production.ios.metadataPath in eas.json). Adds the copyright year and the App Review contact and demo login, read from env |
The App Review values are secrets and are never committed. Set them in apps/mobile/.env (gitignored) or the shell before pushing:
EAS_METADATA_REVIEW_FIRST_NAME=
EAS_METADATA_REVIEW_LAST_NAME=
EAS_METADATA_REVIEW_EMAIL=
EAS_METADATA_REVIEW_PHONE= # "+1 555 123 4567" — country code required
EAS_METADATA_REVIEW_DEMO_USERNAME=
EAS_METADATA_REVIEW_DEMO_PASSWORD=If any of them is missing, the review block is omitted with a warning, so linting still works.
cd apps/mobile
eas metadata:lint # validate lengths and schema — no Apple login needed
eas metadata:push # write to App Store Connect
eas metadata:pull # pull edits made in App Store Connect back into store.config.jsonmetadata:push needs a binary that has already been uploaded with eas submit. Screenshots are uploaded in App Store Connect. The age-rating answers aim for 17+ / 18+ because the app has user-generated content, open messaging and voice with strangers, and livestreams. Change them only if moderation changes that.
Update releaseNotes (and the description, if features changed) for each store release.
Submitting
eas submit --profile production --platform ios # → App Store Connect / TestFlight
eas submit --profile production --platform android # → Google PlayOver-the-air updates
EAS Update ships JavaScript, styling and asset changes to installed builds without a store release. Anything native — a new native dependency, a config plugin or its options, permissions, Info.plist, the app name or scheme, an SDK upgrade — still needs a new build (see the native change checklist).
Only builds that include expo-updates can be updated
Store builds made before EAS Update was added can never receive an update. Build and submit a new production binary first.
How builds pick up updates
- Channel. Each build is tied to the channel of its profile (
preview,production) at build time. - Runtime version.
runtimeVersion.policyisfingerprint(app.json): the runtime version is a hash of everything native — native dependencies, the resolved app config,eas.json. An update only reaches builds with the same hash, so it can never land on a binary without the native code it needs.fingerprint.config.jsleaves out what can't change native code (package.jsonscripts,expo.extra). - Preview and production have different runtime versions, because their app names and schemes differ. An update tested on preview can't be republished to production; publish the same commit to each channel.
- On the device,
expo-updateschecks on launch without delaying it, and the app checks again when it returns to the foreground (hooks/useAppUpdates.ts, at most every 30 minutes). Once an update has downloaded, a snackbar offers Restart. Otherwise it applies on the next cold launch. - Development builds don't take updates automatically. Load one from the dev client's Extensions tab.
The settings screen shows the running update next to the version (1.0.0 (update abc1234)), and crash reports carry updateId and runtimeVersion.
Publishing
cd apps/mobile
pnpm ota:preview -m "Fix the feed's empty state" # → preview builds
pnpm ota:production -m "Fix the feed's empty state" # → store builds, after testing on previewscripts/publish-update.js runs eas update --channel <name> --environment <name>. First it checks that the EAS environment sets every EXPO_PUBLIC_* the app reads, plus any set in your local .env, and refuses to publish if one is missing. Extra flags pass through to eas update, for example --platform ios.
Test on a preview build: fully close the app, reopen it, and wait for the Restart prompt. If it doesn't appear, close and reopen once more — the first launch may only download the update.
Rolling back
eas update:list --branch production # find the bad update group
eas update:rollback # interactive: back to the previous update or the embedded oneInstalled builds pick up the rollback the same way as any update. A JS crash on launch is also caught by expo-updates itself, which falls back to the previous working update on that device.
Checking a runtime version
eas env:exec production 'npx expo-updates fingerprint:generate --platform ios' # what a production build would get
eas fingerprint:compare # compare against a buildConfig plugins
app.json declares the plugins that shape the native project:
| Plugin | Purpose |
|---|---|
expo-router | File-based routing |
expo-splash-screen | Splash, background #160b2d |
expo-video | Background playback and picture-in-picture |
expo-image-picker | Photo library and camera |
@config-plugins/react-native-webrtc | WebRTC native setup |
@livekit/react-native-expo-plugin | LiveKit native setup |
app.config.ts adds what depends on the environment, or what a plugin would otherwise get wrong:
- iOS
NSUserNotificationsUsageDescriptionandNSMicrophoneUsageDescription. Libraries ship the generic "Allow $(PRODUCT_NAME) to access your microphone", which App Review rejects (5.1.1). NSAppTransportSecurity: { NSAllowsArbitraryLoads: false }and, on Android,usesCleartextTraffic: false(expo-build-properties) for every build exceptdevelopment, which needs plain HTTP to reach the local API.- Outside
development,android.blockedPermissionsstrips what libraries merge in but the app never uses:SYSTEM_ALERT_WINDOW, external storage, biometrics, and the camera and microphone foreground-service types. Play reviews each foreground-service type, so onlyFOREGROUND_SERVICE_MEDIA_PLAYBACK(expo-video background playback) is requested.
The app's own Android permissions all live in the android.permissions list in app.json. Face ID is off (expo-secure-store with faceIDPermission: false), and iPad is not supported (supportsTablet: false), so App Review tests on iPhone only. Verify the resolved values with EXPO_PUBLIC_ENV=production npx expo config --type introspect.
Changing a plugin requires a new build. Config plugins run at prebuild time and modify native projects; a Metro reload will not pick them up.
Native change checklist
You need a new development build whenever you:
- Add or remove a native dependency
- Change a config plugin or its options
- Change the bundle identifier, app name, or scheme
- Change permissions or Info.plist entries
- Upgrade the Expo SDK
You do not need one for JavaScript, styling, or asset changes — those can also ship to installed builds as an over-the-air update.
Crash reporting
JavaScript errors go to PostHog Error Tracking when EXPO_PUBLIC_POSTHOG_KEY is set in the EAS environment. Native crashes and source-map upload aren't wired yet. See Observability → Mobile crash reporting.
Release checklist
pnpm check-typesandpnpm lintpass.- Backend changes the release depends on are deployed and migrated.
versioninapp.jsonbumped for a user-visible release.eas build --profile production --platform all.- Smoke-test the resulting build via TestFlight / internal track: login, feed, post with an image, DM, a voice room, a purchase.
eas submit --profile production --platform all.
For a JS-only fix between releases, publish an over-the-air update instead: pnpm ota:preview, test, then pnpm ota:production.
Local development builds
You can also build locally if you have the toolchains:
cd apps/mobile
npx expo run:ios
npx expo run:androidSlower than EAS the first time and it requires a working Xcode / Android Studio setup, but it does not consume EAS build minutes and gives you native logs directly.
