Skip to Content
Working with the codebaseSubmit to the stores

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.

Use the submit skill to ship this release to both stores. Run the preflight first, show me every --dry-run before anything is sent, and stop and ask me before submitting for review or promoting to production.

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-assets skill, under store-assets/ios/ and store-assets/android/
  • asc installed with brew 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 StoreGoogle Play
Create the app recordNo public API. asc web apps create drives the website over an Apple ID sessionNo API and no web-session fallback. Play Console → Create app
Listing text and screenshotsasc, with the API keyplay-listing.mjs, with the service account
Privacy declarationNo public API at all. An Apple ID web session, with a two-factor codeA real endpoint. applications.dataSafety takes the Play Console CSV
Age and content ratingAge rating is in the API; content rights is a single field on the appIARC questionnaire has no API. Play Console → Policy → App content
Submit for reviewasc review submitPromoting to production is the equivalent, and the API does it
Answer store reviewsResolution Center, website onlyplay-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.mjs

Preflight 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 production

Bump 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-interactive

The 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 --status

iOS 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 IOS

When 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 whatsNew

listing.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.mjs

One 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-13 and ipad-12.9 explicitly, 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.json always carries releaseNotes; 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 audit against 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:

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 table

asc 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>" --confirm

asc 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 table

Release on Google Play

Push the listing

node .claude/skills/submit/scripts/play-listing.mjs --dry-run node .claude/skills/submit/scripts/play-listing.mjs

The 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/phone goes up as phoneScreenshots, tablet or tablet-10 as tenInchScreenshots, tablet-7 as sevenInchScreenshots, and the loose feature-graphic.png and icon.png as featureGraphic and icon. 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 beta

The 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 --status

Every 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 table

On 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 --confirm

Halting 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 "…" --confirm

A 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 enrolmentManual, paid, and can take days
Banking and tax agreementsLegal, and website only. A version sits blocked without them
App Store pricing and availability, first setupCannot be bootstrapped over the public API. Set a price tier and territories in App Store Connect; asc pricing handles later edits
In-app purchase productsasc iap setup and asc subscriptions setup work once pricing exists. RevenueCat does not create store products for you
The Play app recordNot 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 declarationsNo API. Recorded in play.config.json, entered in Play Console
Play closed testing for new personal accountsPolicy 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 productsinappproducts and monetization.subscriptions are real endpoints, but nothing here wires them up
Resolution Center repliesWebsite 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.

Last updated on