Submit to the stores
Take a finished build to the App Store and Google Play from the terminal: version records, listing text, screenshots, privacy declarations, test tracks and the review submission itself.
EAS builds and uploads the binary. Everything after that runs from the terminal. The App
Store half is driven by asc, Rork’s App Store Connect CLI; the
Play half is a set of scripts in the repo that call the Play Developer API directly, with
no extra dependency and nothing added to package.json.
Nothing is submitted for review, promoted to production, or published as a reply to a store review until you say so. Everything else on this page is reversible.
Prerequisites
- Store credentials: the App Store Connect key, an Apple ID web session, and the Google Play service account
- A production build that finishes and uploads
- Listing images from the
store-assetsskill, understore-assets/ios/andstore-assets/android/ ascinstalled withbrew install asc, for the App Store half only
Which steps have an API, and which do not
The two stores are not equally scriptable, and the gaps run in opposite directions.
| App Store | Google Play | |
|---|---|---|
| Create the app record | No public API. asc web apps create drives the website over an Apple ID session | No API and no web-session fallback. Play Console → Create app |
| Listing text and screenshots | asc, with the API key | play-listing.mjs, with the service account |
| Privacy declaration | No public API at all. An Apple ID web session, with a two-factor code | A real endpoint. applications.dataSafety takes the Play Console CSV |
| Age and content rating | Age rating is in the API; content rights is a single field on the app | IARC questionnaire has no API. Play Console → Policy → App content |
| Submit for review | asc review submit | Promoting to production is the equivalent, and the API does it |
| Answer store reviews | Resolution Center, website only | play-reviews.mjs, over the API |
Apple’s App Store Connect API spec carries no data-usage endpoints of any kind, so the privacy label has nothing to call and goes through the same web session that creates the app record. Google’s Data safety form is a documented public endpoint. For content rating the gap runs the other way: Apple’s age rating is in the API, and Google’s IARC questionnaire is not.
Build and upload the binary
Run the preflight
yarn lint
yarn typecheck
yarn expo:doctor
node .claude/skills/submit/scripts/preflight.mjsPreflight checks the things that waste a build cycle: REPLACE_ME placeholders in
eas.json, store.config.json and play.config.json, the bundle identifier and package
name, whether the icon is 1024 × 1024 and whether it carries an alpha channel, whether
asc is installed, and whether both credentials are present and readable. It also
lists the declarations still waiting on a human, and the Play App content answers that
have no API, so they get decided rather than improvised.
A production build takes its EXPO_PUBLIC_* values from the production EAS environment,
never from .env.local, which is not uploaded. Confirm they are there before spending a
build:
eas env:list --environment productionBump the version
Bump version in app.config.js, and only that. cli.appVersionSource: "remote" plus
autoIncrement on the production profile mean EAS raises buildNumber and versionCode
on every build. Setting either by hand fights the tooling.
Build
source credentials/asc.env
eas build -p ios --profile production --non-interactive
eas build -p android --profile production --non-interactiveThe very first iOS build is the exception. --non-interactive will not generate the
initial distribution certificate, so run that one in your own terminal;
Production builds shows its two prompts.
Upload
eas submit -p ios --profile production --latest --non-interactive
eas submit -p android --profile production --latest --non-interactive--latest takes the newest finished build. iOS lands in TestFlight processing; Android
lands on the internal track as a draft.
Verify
asc builds list --app "$ASC_APP_ID" --limit 1 --processing-state VALID --output table
node .claude/skills/submit/scripts/play-release.mjs --statusiOS is ready when processingState reads VALID. On Android, the internal track should
show the new versionCode.
Release on the App Store
Create the version record
asc versions create --app "$ASC_APP_ID" --version 1.2.0 --platform IOSWhen only the release notes changed, carry the previous release’s text forward instead:
asc versions create --app "$ASC_APP_ID" --version 1.2.0 \
--copy-metadata-from 1.1.0 --exclude-fields whatsNewlisting.mjs in the next step creates the record itself if it is missing, so this is
optional unless you want the copy-forward.
Push the listing
node .claude/skills/submit/scripts/listing.mjs --dry-run
node .claude/skills/submit/scripts/listing.mjsOne command covers text metadata per locale, categories, copyright, the App Review contact
and demo account, and every screenshot set from store-assets/ios/. It resolves the
version record before it changes anything and creates only what is missing, so re-running
after a failed step adds nothing twice.
Three things it handles that are easy to get wrong by hand:
- The display-slot names lag the marketing sizes by a generation. A 6.9” iPhone
screenshot goes to the slot Apple still calls 6.7”, and the 13” iPad to the 12.9” Pro
slot. There is no slot named for 6.9” or 13”. The script maps
store-assets/ios/6.9,6.7,6.5,ipad-13andipad-12.9explicitly, and fails with the valid list if a directory maps to nothing. A silently skipped set is a missing iPad listing discovered at review time. - Apple rejects release notes on a first version, with an error that does not say so.
store.config.jsonalways carriesreleaseNotes; the script omits the field when this is the app’s only version. - Keywords that repeat the app name or subtitle spend budget for nothing. Apple indexes
both already. The script flags the overlap locally, then runs
asc metadata keywords auditagainst the live listing.
It refuses to run on any REPLACE_ME left in store.config.json. Fill those in or delete
the key; Apple shows that text to every customer.
store.config.json keeps EAS Metadata’s shape, so eas metadata:push still works on the
text half. It cannot do screenshots, categories or the review contact, which is why this
script exists.
Declare privacy, age rating and content rights
Covered on their own pages, because each is a statement you are signing rather than a value to fill in:
- Privacy declarations, for both stores
- Age rating and content rights
Attach the build
asc builds list --app "$ASC_APP_ID" --limit 1 --processing-state VALID --output json
asc versions attach-build --version-id "<VERSION_ID>" --build-id "<BUILD_ID>"Send it to TestFlight, if you stage releases
asc testflight groups list --app "$ASC_APP_ID" --output table
asc builds add-groups --app "$ASC_APP_ID" --latest --group "<GROUP_ID>"--group takes a group name as well as an ID. An external group needs Beta App Review,
which --submit --confirm starts.
Submit for review
Everything above is reversible. This is not.
asc validate --app "$ASC_APP_ID" --version 1.2.0 --strict --output tableasc validate is the gate. It is the one command that aggregates every blocking condition
into an ordered remediation plan: metadata limits, missing localizations, review details,
category, whether a processed build is attached, the encryption declaration, pricing and
territories, screenshot presence per size, and age-rating completeness. Run it until it is
clean.
asc review submit --app "$ASC_APP_ID" --version 1.2.0 --build-id "<BUILD_ID>" --dry-run
asc review submit --app "$ASC_APP_ID" --version 1.2.0 --build-id "<BUILD_ID>" --confirmasc review submit wraps attaching the build, creating the submission, adding the item and
submitting it. Show the dry run to whoever is deciding, then run it with --confirm.
If validate is clean and submission still refuses, asc review doctor --app "$ASC_APP_ID"
explains why. asc validate --deep also checks App Privacy publication and the program
agreements, but it needs a cached web session.
Verify
asc status --app "$ASC_APP_ID" --output tableRelease on Google Play
Push the listing
node .claude/skills/submit/scripts/play-listing.mjs --dry-run
node .claude/skills/submit/scripts/play-listing.mjsThe mirror of the Apple listing step, reading play.config.json and
store-assets/android/. The Android copy is a separate file rather than an android
section of store.config.json, because that file follows EAS Metadata’s schema and a key
it does not recognise breaks eas metadata:push.
Everything happens inside one edit, which is Play’s unit of change, so the contact details, every locale’s listing and every image set either all land or none do. A failure deletes the edit rather than leaving a half-written draft pinned to the app.
- The text limits belong to Play Console, not the API. Exceeding one comes back as a 400 that does not name the field. The script checks title ≤ 30, short description ≤ 80 and full description ≤ 4000 before anything is sent, and says which one was long.
- Images replace rather than append. The API has no reorder, so the only way to control
listing order is to clear the type and re-upload in filename order.
store-assets/android/phonegoes up asphoneScreenshots,tabletortablet-10astenInchScreenshots,tablet-7assevenInchScreenshots, and the loosefeature-graphic.pngandicon.pngasfeatureGraphicandicon. A folder that maps to nothing fails with the valid list. - Play caps screenshots at 8 per type. The script stops rather than letting Google decide which ones to drop.
Declare Data safety and App content
Data safety is scripted, on the same source file as Apple’s App Privacy. See Privacy declarations.
The IARC content rating, target audience, ads and the news and financial-app flags have no
API. play.config.json’s playConsoleOnly block records the answers so they are decided
once in the repo, and preflight lists whichever are still unanswered. Entering them is a
Play Console job.
Promote through the test tracks
On Android the tracks are the beta programme. eas submit already put the build on
internal; alpha and beta are closed and open testing. Testers are managed in Play
Console, not over the API.
node .claude/skills/submit/scripts/play-release.mjs --status
node .claude/skills/submit/scripts/play-release.mjs --to betaThe version codes are read off the source track rather than passed in, so a promotion cannot ship a build nobody tested.
Promote to production
Play has no review submission to ask for. Promoting to production is the irreversible
step, so it is gated the same way Apple’s review submission is: a version that has been
downloaded cannot be taken off those devices.
node .claude/skills/submit/scripts/play-release.mjs --to production --dry-run
node .claude/skills/submit/scripts/play-release.mjs --to production --rollout 0.1 --confirm--rollout is a fraction of users, strictly between 0 and 1. Without it the release goes
to everyone at once. Release notes come from play.config.json’s release.releaseNotes,
per locale, capped at 500 characters.
Google still reviews the release after promotion, usually within hours and sometimes days, and the track shows it.
Verify
node .claude/skills/submit/scripts/play-release.mjs --statusEvery play-*.mjs script takes --dry-run, which prints the exact requests and sends
none of them. listing.mjs takes it too, printing the asc commands it would run.
After the release
Watch the App Store submission:
asc status --app "$ASC_APP_ID" --watch --poll-interval 5m
asc review history --app "$ASC_APP_ID" --output tableOn approval the version releases according to its release type. A manual one goes live with
asc versions release --version-id "<VERSION_ID>" --confirm, and asc versions phased-release
controls a phased rollout. Rejections are handled in the Resolution Center:
asc web review show reads the reviewer’s message over a web session, and replying is a
website job.
A Play staged rollout is steered after the fact. Watch the crash rate, then widen it or stop it:
node .claude/skills/submit/scripts/play-release.mjs --track production --halt --confirm
node .claude/skills/submit/scripts/play-release.mjs --track production --resume --rollout 0.5 --confirmHalting freezes the rollout where it is. It does not take the app back from anyone who already has it, and it only applies to a release that is a staged rollout in the first place. A fully released version is stopped by rolling out a new one.
Answering store reviews is the one thing Play does that Apple does not:
node .claude/skills/submit/scripts/play-reviews.mjs
node .claude/skills/submit/scripts/play-reviews.mjs --reply <REVIEW_ID> --text "…" --confirmA reply is published on the store page under the developer name and emailed to the
reviewer, so it needs --confirm and it should be your words rather than an agent’s. Play
shows at most 350 characters and silently cuts the rest, so the script refuses longer text.
The endpoint only returns reviews created or edited in roughly the last week, which makes
this a habit rather than an audit. Play Console exports the full history as CSV.
Still done by hand
| Why | |
|---|---|
| Apple Developer and Play Console enrolment | Manual, paid, and can take days |
| Banking and tax agreements | Legal, and website only. A version sits blocked without them |
| App Store pricing and availability, first setup | Cannot be bootstrapped over the public API. Set a price tier and territories in App Store Connect; asc pricing handles later edits |
| In-app purchase products | asc iap setup and asc subscriptions setup work once pricing exists. RevenueCat does not create store products for you |
| The Play app record | Not in Google’s API, and no web-session fallback. Until it exists every call returns 404 with the package name in it, which is the same message a typo produces |
| Play content rating, target audience, ads and news declarations | No API. Recorded in play.config.json, entered in Play Console |
| Play closed testing for new personal accounts | Policy rather than tooling. A new personal developer account has to run a continuous closed test with real testers before production opens, and the threshold changes |
| Play pricing and in-app products | inappproducts and monetization.subscriptions are real endpoints, but nothing here wires them up |
| Resolution Center replies | Website only |
Files
- SKILL.md - The runbook this page follows
- preflight.mjs
- listing.mjs - App Store text, categories, review contact, screenshots
- privacy.mjs - App Privacy
- declarations.mjs - Age rating and content rights
- play-listing.mjs
- play-privacy.mjs - Data safety
- play-release.mjs - Track promotion and staged rollout
- play-reviews.mjs
- store.config.json - The App Store listing, in EAS Metadata's shape
- play.config.json - The Play listing, plus the answers with no API
- privacy.config.json - One privacy declaration, both stores
- declarations.config.json - Age rating and content rights
Generated files go to .asc/, which is gitignored and easignored. Editing one is pointless
because it is rewritten on every run.
Next
First release checklist covers the one-time work that comes before any of this, and never comes back afterwards.