← Ambasdr Platform Docs
Mobile · Release runbook

How a commit becomes a build in TestFlight and a release on Play

The backend and the web app deploy from a workflow on push. The mobile app does not — shipping it is a human running two commands and then finishing the job in two consoles. This is that process, written down because it had never been, and every release so far was re-derived from scratch including the wrong turns. Source of truth in the repo: docs/mobile-release.md.

01

Three commands, and what each one actually does

There is no step where you push to Expo. Builds start there.

CommandWhat happens
eas build EAS builds on its machines, from your local git state, and puts an .ipa / .aab on expo.dev.
eas submit Takes that artifact and uploads it to App Store Connect or Play Console.
console A human promotes it to testers. Neither store ships to anyone on the command's say-so.
eas and expo are different tools

npx expo is the local dev server — expo start on your laptop. It never produces a store binary. And use the installed eas, not npx eas-cli@latest: npx fetches a fresh CLI with its own empty session, reports a different account than the one you are logged in as, then reports the project as unauthorized. That has cost real debugging time.

The commands are aliased in mobile/package.json so the flags stop being folklore. Run them from mobile/.

# production profile, both platforms
npm run build:ios
npm run build:android

npm run submit:ios       # TestFlight
npm run submit:android   # Play — see the first-release rule in 06

mobile/__tests__/config/easProfiles.test.ts asserts each of these names the production profile, the right platform, and eas rather than expo. A script pointing at preview still builds, still succeeds, and still yields a submittable artifact — one built against the dev backend and the dev Auth0 tenant.

02

Before you build

Be on the commit you intend to ship

EAS builds from your local git state, not from origin/main. A dirty tree or a stale branch ships a binary nobody reviewed.

git checkout main && git pull
git status            # must be clean
git log --oneline -1  # this is what ships

Know what changed since the last build

Every EAS build records its commit. Compare it against what you are about to ship. If the answer is several commits of new user-facing surface, that is a release rather than a rebuild, and it wants a smoke test first.

eas build:list --platform ios --limit 1 --non-interactive --json \
  | python3 -c "import json,sys; b=json.load(sys.stdin)[0]; print(b['gitCommitHash'][:8], b['appBuildVersion'])"

git log --oneline <that-hash>..HEAD -- mobile/

Confirm which account you are

Two identities can be wrong here and both fail confusingly. eas whoami must list lennox-the-designer / juelz-holdings. gh auth status is machine-wide and switches without announcing itself.

03

Build numbers are not in the repo

Do not go looking for a number to bump. There isn't one.

eas.json sets cli.appVersionSource: "remote", which means EAS owns the build number, not app.json.

So two builds cut from the same commit still get different build numbers. That is working as intended, not a bug.

04

Which profile

These are resolved values, not what you will literally read in eas.json. production sets no distribution at all, and release says almost nothing beyond extends: production — both pick up EAS defaults and inherited fields.

ProfileResolves to
developmentinternal distribution, dev client, dev backend
previewinternal distribution, shareable test build, dev backend
productionstore distribution, autoIncrementthis is the one you ship
releasestore distribution, autoIncrement inherited, EXPO_PUBLIC_VERBOSE_LOGS: "0"

Check any profile yourself rather than reading it off the file:

eas config --platform android --profile release --non-interactive
Why this is asserted in tests

production and release both point at the prod API and the ambasdr-prod Auth0 tenant. The production profile once pointed at the dev backend for roughly three weeks and nothing failed — a store build would have written App Store users into the dev database. easProfiles.test.ts asserts the whole tuple for that reason.

05

Build

cd mobile
npm run build:ios       # eas build --platform ios --profile production
npm run build:android   # eas build --platform android --profile production

Both run on EAS infrastructure, take roughly 15–25 minutes, and can run at the same time. The command streams progress and prints an artifact URL at the end; you can also close it and check later with eas build:list, or pass --no-wait to queue and return immediately.

app.config.ts is evaluated at build time and throws if a profile mixes environments — a prod API with the dev Auth0 tenant, for example. That failure is deliberate, and it happens during the build rather than at a user's login screen.

Fetching an artifact URL afterwards:

eas build:list --platform android --limit 1 --non-interactive --json \
  | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['artifacts']['applicationArchiveUrl'])"
06

iOS → TestFlight

npm run submit:ios

eas.json carries submit.production.ios.ascAppId, so the target app is already known, and EAS holds the App Store Connect API key — you should not be asked for credentials.

Wait for processing

The build appears under TestFlight after Apple finishes processing, usually 5–15 minutes. It is not instant.

Answer export compliance if asked

It sits in Missing Compliance until the export-compliance question is answered. ITSAppUsesNonExemptEncryption: false is already in app.json, so this is normally auto-answered; if it is not, answer it in the console.

Distribute

Internal testers get it immediately. External testers require a Beta App Review.

07

Android → Google Play

The first release cannot be automated

Google's Play Developer API cannot create an app's first release. Not a permissions problem, not a config problem — the operation is not allowed. The first .aab must be uploaded by hand through Play Console. This is the most likely cause of the failed submit on 2026-06-08: the service account keys were created that same day, and the first thing the tooling attempted was the one operation the API cannot perform.

First release, by hand:

Download the artifact

Use the eas build:list snippet in section 05 to get the .aab URL.

Create the release

Play Console → Testing → Open testing → Create new release. Upload the .aab, fill the release notes, save.

Read Play's own blocker list

That page enumerates every remaining blocker by name. Trust it over any checklist, including this page.

Every release after the first is npm run submit:android.

Track names are not the words the console uses

eas.jsonPlay Console
internalInternal testing — capped at 100 people on an explicit email list
alphaClosed testing
betaOpen testing — anyone with the link can join
productionProduction

These differ by one word in config and by nothing at all in eas submit's output, which reports success either way. A launch shipped to internal looks shipped right up until nobody outside the list can install it. easProfiles.test.ts asserts the value for exactly that reason.

Google Cloud is not Play Console

A service account existing in Google Cloud does not mean Play recognizes it. Two more things must be true, and they live in a different console: Play Console → Setup → API access has the Cloud project linked, and Play Console → Users and permissions grants the service account Release apps to testing tracks and View app information.

Google lets a service account key be downloaded once, at creation. If no JSON exists on any machine, create a new key rather than hunting for the old one. Upload it with eas credentials so it lives on EAS's servers and every machine can submit — taking care to pick Play Store Submissions rather than the Push Notifications (FCM) entry beside it, which stores a key eas submit never reads.

That file is a live key to the Play account. Both .gitignore files carry patterns for the conventional names, but they match on filename and Google names the key after the service account — a download called EAS Submit Ambasdr.json matches none of them. Treat the ignore rules as a backstop, not a guarantee, and keep the key outside the repo.

serviceAccountKeyPath in eas.json also works, and must never be committed: it succeeds only on the machine holding the file and fails everywhere else with a missing-file error rather than a missing-credential one. easProfiles.test.ts asserts it is absent.

08

Open testing is gated like production

Internal testing is loose. Open testing is publicly joinable, so Play applies the full content bar and the release stays a draft until all of it is done.

The app icon (512×512) and feature graphic (1024×500) live in mobile/store-assets/play/ and are rebuilt by generate.py in that folder. The icon is a downsample of the icon the app ships rather than a redraw, so the store and the home screen cannot disagree.

App access, with SSO

Sign-in is SSO-only, so there is no username and password to hand a reviewer. Use the instructions field: tell them to tap Continue with Apple or Continue with Google and use their own account.

Also put the plaintext beta code in that field. The login screen renders a waitlist gate whenever the runtime waitlist_enabled flag is on — an operator setting in app_settings, toggled from /panel/settings, with nothing in git to show it moved. If it is switched on while an app is in review, the reviewer is locked out mid-review and the rejection comes back worded as an inability to access the app. Do not trust a written answer for its current value, including this page's — ask production with curl -s https://api.ambasdr.com/v1/public/config. Putting the code in the field costs nothing and covers you either way.