Skip to content

Building & Submitting to Stores ​

This guide covers building the mobile app with EAS Build and submitting to the Google Play Store and Apple App Store using EAS Submit.

Prerequisites ​

  • eas-cli installed globally: pnpm add -g eas-cli
  • Logged in: eas login
  • Project linked: eas init (already done, project ID in app.json)

Build Profiles ​

ProfileAndroidiOSAPI URL
stagingAAB, internal trackStoreStaging backend
productionAAB, production trackStoreProduction backend

Both profiles produce AAB (Android App Bundle) files β€” APKs are not accepted by Google Play.

Two independent version knobs:

  • Build number (CFBundleVersion / versionCode) β€” auto-incremented remotely via appVersionSource: "remote" (managed by EAS). Never bump by hand.
  • Marketing version (CFBundleShortVersionString / versionName) β€” comes from package.json version (read by app.config.ts). Bump it only per public release via pnpm release:production (see Production releases), not per build.

Building ​

Staging (internal testing) ​

bash
# Android
eas build --profile staging --platform android

# iOS
eas build --profile staging --platform ios

# Both platforms
eas build --profile staging --platform all

Production ​

bash
# Android
eas build --profile production --platform android

# iOS
eas build --profile production --platform ios

# Both platforms
eas build --profile production --platform all

CI (non-interactive) ​

bash
eas build --profile staging --platform android --non-interactive

Submitting ​

Google Play Store ​

Submissions use a Google Play Service Account key stored as an EAS secret (GOOGLE_SERVICE_ACCOUNT_KEY). This key is managed via Terraform (infra/play-store/ module) and stored in 1Password.

bash
# Submit the latest staging build to internal testing track
eas submit --profile staging --platform android --latest

# Submit the latest production build to production track
eas submit --profile production --platform android --latest

# Submit a specific build
eas submit --profile staging --platform android --id <build-id>

Apple App Store ​

bash
# Submit the latest staging build to App Store Connect
eas submit --profile staging --platform ios --latest

# Submit the latest production build
eas submit --profile production --platform ios --latest

NOTE

iOS submissions require an active Apple Developer account and App Store Connect API key or Apple ID credentials. EAS CLI will prompt for these on first submission.

Build + Submit in one step ​

bash
# Build and auto-submit when build completes
eas build --profile staging --platform android --auto-submit
eas build --profile production --platform all --auto-submit

Production releases (marketing version) ​

A public release needs a marketing-version bump and a build + submit. Don't run these by hand separately β€” use the release scripts:

bash
pnpm release:production       # both platforms
pnpm release:production:ios   # iOS only

Each script runs scripts/release-bump.mjs then build:submit:*. The bump script:

  1. Asserts a clean tree and that you're on main, in sync with origin/main β€” bumping from a stale main would ship a marketing version lower than what's already live, which Apple rejects (CFBundleShortVersionString must contain a higher version).
  2. Bumps the patch version (package.json), commits it on a release/mobile-v<ver> branch, pushes, and opens a PR via gh.
  3. Runs build:submit:production β€” app.config.ts reads version from package.json, so the local bump is what ships regardless of when the PR merges.

IMPORTANT

Merge the bump PR. main has branch protection, so the bump can't be pushed directly β€” it lands only via that PR. If it's left unmerged, origin/main drifts behind the store release and the next release bumps from the stale version (the ENG-1222 failure). If gh pr create fails, the script prints the manual gh pr create command β€” run it.

NOTE

Bump the marketing version only per public release, not per build. Multiple TestFlight/internal builds share one marketing version; the remote build-number auto-increment is enough between public releases. Apple closes a pre-release train once its marketing version goes public (Invalid Pre-Release Train … is closed).

EAS Secrets ​

Secrets are stored at the project level in EAS. Manage them with:

bash
eas secret:list                          # List all secrets
eas secret:create                        # Create (interactive)
eas secret:delete                        # Delete (interactive)

Current secrets:

NameTypePurpose
GOOGLE_SERVICE_ACCOUNT_KEYFileGoogle Play Store API authentication

Google Play Service Account Setup ​

The service account is provisioned via Terraform (infra/play-store/ module):

  1. terraform apply creates the GCP service account and stores the JSON key in 1Password
  2. Download the JSON key from 1Password ([dev] Google Play Store - Service Account Key)
  3. Save it to a temp file and create the EAS secret:
    bash
    eas secret:create  # select "file", name it GOOGLE_SERVICE_ACCOUNT_KEY, point to the JSON file
  4. The service account must be invited in Google Play Console > Users and permissions with release permissions on the app

Play Store Tracks ​

ProfileTrackDescription
staginginternalUp to 100 testers, available within minutes
productionproductionPublic release (requires review on first submission)

IMPORTANT

Internal testing track builds are installed via the Play Store, not downloaded as APKs. Testers must be added to the internal testing group in the Play Console and accept the invite link.

Troubleshooting ​

"The apk has permissions that require a privacy policy" ​

Set a privacy policy URL in Google Play Console > App content > Privacy policy before submitting.

Photo/video permission flag (READ_MEDIA_IMAGES / READ_MEDIA_VIDEO) ​

expo-media-library over-injects broad media-read permissions, which Play flags under the Photo & Video Permissions policy. The app only saves photos (write-only) and picks via the system Photo Picker, so it needs none of them. They are intentionally stripped from the manifest by plugins/with-blocked-media-permissions.js (READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO). Do not re-add them β€” keep only READ_MEDIA_VISUAL_USER_SELECTED. Re-verify after any expo-media-library upgrade: grep READ_MEDIA android/app/src/main/AndroidManifest.xml.

"The service account is missing the necessary permissions" ​

Ensure the service account has been invited in Play Console > Users and permissions with at minimum Releases permissions on the app.

"Uploaded a google-services.json instead of your service account key" ​

The EAS secret contains the wrong file. Delete and recreate it with the correct service account JSON key (the one with "type": "service_account").