SUB/WAVE iOS release → TestFlight
Build a fresh iOS binary in the cloud and push it to TestFlight. All the
painful one-time setup is already done — signing credentials live on EAS, the
App Store Connect app record exists, and eas.json is wired — so a normal
release is a single command. This skill is about doing it again, reliably, and
knowing what to check.
Why this is now easy
The first release required interactive setup (creating a distribution
certificate, registering the App Store Connect app). That's finished. EAS stores
the credentials server-side and app/eas.json pins the submit target, so repeat
builds run non-interactively — no Xcode, no Apple login prompts, no TTY.
First: does this even need a new build? (OTA)
The app ships expo-updates (OTA). If the change is JS/TS only — components, hooks, styles, copy, logic, Metro-bundled assets — you do not need a TestFlight build at all. Push it over-the-air to the binaries already out there:
cd "$APP"
eas update --channel production --message "fix: …" # testers first: --channel preview
It reaches every installed build whose runtime version (fingerprint) matches —
i.e. any build with the same native code. Testers see it on the next cold
start (it fetches in the background; fallbackToCacheTimeout: 0 keeps launch
instant, so killing + relaunching twice is how you confirm it applied).
A new binary is only required when native inputs changed: a dependency
add/upgrade, anything under app/patches/, a config plugin, or app.json's
ios/infoPlist/plugins sections. The fingerprint policy guarantees an OTA
can't land on a binary with mismatched native code, so you can't break a build
this way. Rule of thumb: ran npx expo install or touched patches/? → build a
binary. Otherwise → OTA. Full decision table + fingerprint debugging:
app/docs/RELEASE.md.
The rest of this skill is the binary path (native change, or a store release).
Fixed facts about this app
Derive the repo root once; don't hardcode it. The Expo project and eas.json
live in app/, and every eas command must run from there.
REPO=$(git -C "<this skill's base directory>" rev-parse --show-toplevel)
APP="$REPO/app" # eas.json lives here — cd into it before any eas command
| Thing | Value |
|---|---|
| EAS project | @pinku1/subwave (Expo account pinku1) |
| Bundle identifier | com.getsubwave.app |
| App Store Connect App ID | 6778786696 |
| Apple Team | BU9RD766GN (Individual) |
| Build profile | production (store distribution, autoIncrement on) |
| OTA channel | production — JS-only updates via eas update (see OTA section above) |
| Runtime version | fingerprint policy — hashes native deps + patches/ so OTAs only reach matching binaries |
| Version source | remote — EAS owns the build number; never bump it by hand |
| TestFlight page | https://appstoreconnect.apple.com/apps/6778786696/testflight/ios |
eas.json → submit.production.ios already carries ascAppId + appleTeamId,
which is what makes submit non-interactive. Leave it intact.
Preflight (10 seconds)
cd "$APP"
eas whoami # expect: pinku1 (if not logged in: eas login)
If eas isn't found, install it: npm i -g eas-cli.
The release — one command
Build in the cloud and auto-submit to TestFlight when the binary is ready:
cd "$APP"
eas build --platform ios --profile production --auto-submit --non-interactive
That's the whole thing. It:
- Increments the build number on EAS (because
autoIncrement+ remote versions), so each release is a distinct TestFlight build. - Builds the
.ipaon EAS servers (~7–15 min) using the stored distribution cert + provisioning profile. - On success, submits straight to App Store Connect using the pinned
ascAppId+ EAS-stored App Store Connect API key.
The command stays attached and streams progress. If you'd rather not hold the
session, add --no-wait and monitor separately (see below) — but then submit
won't auto-chain, so you'd submit by build id afterwards.
Detached variant (don't block the session on a 15-min build)
cd "$APP"
# Queue the build, return immediately
eas build --platform ios --profile production --no-wait --non-interactive
# Grab the build id from the output, then watch it:
eas build:view <BUILD_ID> # Status: new → in queue → in progress → finished
# Once finished, submit that build:
eas submit --platform ios --profile production --id <BUILD_ID> --non-interactive
Poll to completion without babysitting:
BID=<BUILD_ID>
until eas build:view "$BID" --json 2>/dev/null \
| grep -qiE '"status":[[:space:]]*"(finished|errored|canceled)"'; do sleep 60; done
eas build:view "$BID" | grep -iE "Status|Application Archive"
After it submits
The binary uploads to App Store Connect, then Apple processes it (~5–15 min) before it shows in TestFlight — that part is out of our hands. Apple emails when processing finishes. Internal testers (already in a TestFlight group) can install as soon as it clears; external testers need Beta App Review first.
Confirm/track at the TestFlight page above, or
https://expo.dev/accounts/pinku1/projects/subwave/builds.
--auto-submit lands the build in TestFlight, NOT on the public App Store.
That's the right default — the build burns in with testers first. Releasing to
the public store is a deliberate, manual step (next section), so don't tell the
user the App Store is updated just because the build submitted.
Promoting to the public App Store (live app)
SUB/WAVE is live in production on the App Store. EAS only gets the binary into App Store Connect; pushing it to real users is a manual App Store Connect step — EAS doesn't do App Store release (only TestFlight delivery). Once the processed build appears under TestFlight:
- App Store Connect → SUB/WAVE → Distribution / App Store tab.
- Create a new version if this is a new marketing version (e.g.
1.0.1) — the number must matchexpo.versioninapp.json. For a same-version resubmission, edit the existing version. - Under Build, attach the processed build (the one EAS just submitted).
- Fill "What's New", then Add for Review → Submit. Apple review for an update is typically hours-to-a-day.
- Choose phased release or immediate; it goes live when Apple approves.
So a full public release = run the build command below, wait for processing, then do this promotion. A TestFlight-only push (testers only, no store change) stops after the build submits.
Bumping the version vs. just the build number
Decide which kind of release this is:
- Another TestFlight build of a version Apple hasn't released yet (a fix, a
tweak still in beta): change nothing.
autoIncrementgives it a fresh build number under the sameexpo.version. Just run the release command. - A new public App Store release (the live-app case): you must bump
expo.versioninapp/app.jsonfirst (e.g.1.0.0→1.1.0), commit, then release. The build number still auto-increments under it.
A version bump is four fields, not one: expo.version in app/app.json,
version in app/package.json, and both version fields in
app/package-lock.json (top-level .version and .packages[""].version —
npm writes the same number twice). app is deliberately outside
release-please-config.json, since its marketing version tracks store
submissions rather than the monorepo's release train, so nothing bumps these for
you. Edit the lockfile's two lines by hand; do not run npm install to do
it, which rewrites unrelated entries.
Live-app gotcha — once a marketing version is released on the App Store, you
can't ship to the public store again under that same version. --auto-submit
(or eas submit) will fail at the submission step with:
You've already submitted this version of the app.
Versions are identified by CFBundleShortVersionString (expo.version)…
The build still succeeds — only the submit is rejected — so the fix is: bump
expo.version, commit, rebuild. SUB/WAVE is live on the App Store, so treat a
version bump as mandatory for every public release, not optional. (TestFlight
alone would take another build under the same version; the public store will
not.) Don't query/guess the current version — read expo.version from
app/app.json and the released version from App Store Connect.
You only hand-edit app.json's version. You never set buildNumber —
appVersionSource: "remote" means EAS is the source of truth for it, and setting
it locally would fight EAS. The current build number isn't worth recording here
either — it auto-increments every build; check eas build:list for the live value.
When something needs Apple auth again (rare)
Stored credentials cover normal releases. EAS may need to re-touch Apple only on edge cases — the distribution certificate expiring (this one lasts until 2027-06-10), adding a device, or regenerating a profile. If a command starts asking to "log in to your Apple account" or for an "App Store Connect API Key", feed it the API key instead of an interactive login:
Keep the App Store Connect API key (.p8) outside the repo and never commit
it — the .p8, its Key ID, and Issuer ID are the credential. Point the env vars
at wherever you store it:
export EXPO_ASC_API_KEY_PATH=/path/to/your/AuthKey_<KEYID>.p8
export EXPO_ASC_KEY_ID=<KEY_ID>
export EXPO_ASC_ISSUER_ID=<ISSUER_ID>
export EXPO_APPLE_TEAM_ID=BU9RD766GN # already in eas.json; non-secret
# then re-run the eas command
Creating a brand-new distribution certificate is the one thing EAS refuses to do
headlessly — it needs a real interactive terminal. If you hit that, have the user
run the same eas build command via ! (so it gets a TTY) and answer the
cert/profile prompts (Yes), then it proceeds.
Things that bite
- Wrong directory.
easreadseas.json, which is inapp/. Run from anywhere else and it won't find the project config. Alwayscd "$APP"first. - Don't hand-edit the build number.
appVersionSource: remoteowns it. - Export compliance is already handled —
ITSAppUsesNonExemptEncryptionis set tofalseinapp.json, so TestFlight won't block on the encryption question. (The app only uses standard HTTPS.) --auto-submitvs--no-waitare mutually exclusive in spirit: auto-submit waits for the build then submits; no-wait returns early and skips the submit. Pick based on whether you want to hold the session.- A failed build still bumps the build number. That's fine — build numbers are cheap and monotonic; a gap doesn't matter.
Quick reference
| Want | Do |
|---|---|
| Ship a JS-only change (no new build) | cd "$APP" && eas update --channel production --message "…" |
| Ship a new build to TestFlight | cd "$APP" && eas build -p ios --profile production --auto-submit --non-interactive |
| Release to the public App Store | build+submit (above), wait for processing, then promote the build in App Store Connect (manual) |
| New app version first | edit expo.version in app/app.json, commit, then ship |
| Queue without waiting | add --no-wait, then eas submit -p ios --profile production --id <id> |
| Check a build | eas build:view <id> / list: eas build:list |
| See it in TestFlight | https://appstoreconnect.apple.com/apps/6778786696/testflight/ios |
| Confirm login | eas whoami (expect pinku1) |