Automate this: /mobile-release:ship-beta uploads to the internal track;
/mobile-release:ship-release handles production and the staged-rollout decision, gating on approval
before anything irreversible. What is safe to re-run: skills/release-automation/references/idempotency.md.
Upload signed Android artifacts to Google Play and move them between tracks. Execute the steps in order. Load only the reference you need.
Confidence marks used throughout this skill and its references: [verified] — read off a primary vendor source on 2026-08-05. [snippet] — secondary source only, plausible but unconfirmed. [unverified] — not confirmed anywhere; run the check given before relying on it.
Publish process:
- Confirm the artifact is a signed
.aaband that the release is buildable. Do not attempt an upload against an unsigned or debug-signed bundle. - Set up and verify service-account authentication using
references/service-account.md. - Pick one upload path and stay on it:
fastlane supply(references/fastlane-supply.md), raw Publishing API v3 (references/publishing-api-v3.md), or ther0adkll/upload-google-playGitHub Action (references/github-actions.md). - Choose the track, release status, and rollout fraction using
references/tracks-and-rollout.md. - Upload the R8/ProGuard mapping and native debug symbols using
references/mapping-and-symbols.md. - Commit the edit. Nothing is live until commit succeeds.
- Verify the release landed on the intended track. On any failure, diagnose with
references/troubleshooting.mdbefore retrying — do not blindly re-run.
Before publishing anything to a public track, run the play-store-listing skill. Upload succeeds long before the listing is compliant.
Contents
- Prerequisites
- Choosing an upload path
- Tracks
- Mapping and native symbols
- Troubleshooting
- Core Instructions
- Output Format
- References
Prerequisites
-
Google Cloud project with the Google Play Android Developer API enabled.
-
Service account in that project, with a downloaded JSON key (
IAM → Service Accounts → Keys → Add key → JSON). -
Play Console access granted to the service-account email:
Users and permissions → Invite new users, add the…iam.gserviceaccount.comaddress, grant app-level access plus release permissions. Grants take minutes to propagate; a brand-new service account 401s or 403s at first. -
OAuth scope. There is exactly one, verified from the live
androidpublisher:v3discovery document:https://www.googleapis.com/auth/androidpublisherIts description is "View and manage your Google Play Developer account". Base URL is
https://androidpublisher.googleapis.com/. -
Verify the key before doing anything else:
fastlane run validate_play_store_json_key json_key:fastlane/play-store-key.jsonIf that fails, fix auth first. Every downstream error is misleading until it passes.
-
Never commit the JSON key. Store it as a CI secret and add it to
.gitignore.
Full walkthrough plus the permission-propagation failure modes: references/service-account.md.
Choosing an upload path
| Path | Use when | Reference |
|---|---|---|
fastlane supply |
The repo already uses fastlane, or you need to sync full multi-locale metadata, screenshots, and changelogs alongside the binary | references/fastlane-supply.md |
| Publishing API v3 direct | No Ruby toolchain, or you need control the wrappers do not expose (named tracks, countryTargeting, custom retention of version codes) |
references/publishing-api-v3.md + references/upload_aab.py + references/upload_aab.sh |
r0adkll/upload-google-play |
GitHub Actions CI and you want the least moving parts | references/github-actions.md |
All three drive the same transactional model: insert an edit, change things, commit the edit.
Tracks
Track identifiers as used by the API and by supply:
| Track | API name | Audience |
|---|---|---|
| Internal testing | internal |
Small tester list, fastest propagation |
| Closed testing | alpha (default closed track; named closed tracks also exist) |
Email lists / Google Groups |
| Open testing | beta |
Anyone, listed on Play |
| Production | production |
Everyone |
Promotion moves an existing versionCode to another track — it does not re-upload the binary. Staged rollout is status: "inProgress" plus a userFraction between 0 and 1 exclusive (a double: 0.1 means 10%, not 10). Halt with status: "halted", resume by setting inProgress again with a fraction, finish with status: "completed".
Two schema facts that bite:
versionCodeson aTrackRelease"must include version codes to retain from previous releases" — omitting older codes de-lists them.inAppUpdatePriorityis an integer 0–5 (5 highest, default 0) and cannot be updated once the release is rolled out. Set it on the first write or not at all.countryTargetingis "only allowed to be set forinProgressreleases in the production track".
Details, promotion commands, and full TrackRelease semantics: references/tracks-and-rollout.md.
Mapping and native symbols
Upload the deobfuscation file for every uploaded versionCode, in the same edit as the bundle:
POST /androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/{apkVersionCode}/deobfuscationFiles/{deobfuscationFileType}
deobfuscationFileType is proguard for the R8/ProGuard mapping.txt and nativeCode for a zip of unstripped .so files. Transport cap is 1 677 721 600 bytes. Without these, Play's crash reports stay obfuscated and native crashes stay unsymbolicated. See references/mapping-and-symbols.md.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "signed with the wrong key" / "not signed" | Bundle signed with a key Play does not recognise as the upload key | Compare keytool -printcert -jarfile app.aab against Play Console → Release → Setup → App signing. If the upload key is lost, request an upload key reset — the app signing key is unaffected |
| "Version code N has already been used" | versionCode collides with any previously uploaded artifact, including deleted drafts |
Bump versionCode. Version codes are never reusable |
| "Only releases with status draft may be created on draft app" | The app has never had a live release, so Play only accepts draft releases | Set the release status to draft, commit, then finish the listing and content declarations and publish once from the Console. Afterwards completed/inProgress work |
| "Track … could not be found" | Named closed track does not exist yet | Create it with edits.tracks.create, or create it in the Console first. Default names are internal, alpha, beta, production |
401 Invalid credentials / 403 insufficient permissions |
Service account not invited in Play Console, missing app-level access, API not enabled, or grant not yet propagated | Re-run validate_play_store_json_key, confirm the invite, wait a few minutes |
| "Changes cannot be sent for review automatically" | Pending Console changes or an app that has never been reviewed | Set changesNotSentForReview on commit, then submit from the Console |
| Edit rejected as expired/committed | Edits are transactional and short-lived; one edit cannot be committed twice | Insert a fresh edit and redo the operations |
Full error strings and the diagnostic order: references/troubleshooting.md.
Core Instructions
- The AAB is mandatory for new apps (since August 2021) and for TV apps (since June 2023). Play App Signing is required with it. Do not propose an APK upload path for a new app.
- Sign an
.aabwithjarsigner.apksigneris for.apkonly. - Check size before uploading:
bundletool get-size total --apks=app.apks. A single install's total compressed download is capped at 4 GB. - Google's own advice: "Don't publish alpha/beta updates more than once daily (production even less frequently)."
- Never print the contents of the service-account JSON key into the transcript or into CI logs.
- Report the resulting
editId,versionCode, track, and status after every upload.
Output Format
Report the release as a short status block, then the next action. Do not narrate every HTTP call.
Example output:
Play upload — com.example.app
| Field | Value |
|---|---|
| Path | Publishing API v3 (references/upload_aab.py) |
| Artifact | app/build/outputs/bundle/release/app-release.aab (48.2 MB) |
| versionCode | 143 |
| versionName | 2.7.0 |
| Track | internal |
| Status | completed |
| Mapping | uploaded (proguard, 6.1 MB) |
| Native symbols | uploaded (nativeCode, 22.4 MB) |
| Edit | 07714948113946731254 — committed |
Retained version codes: 141, 142, 143 — passed explicitly so the previous internal builds stay installable.
Next: promote to production at 10% once internal QA signs off:
python3 references/upload_aab.py promote \
--package com.example.app \
--key play-store-key.json \
--version-code 143 \
--track production \
--status inProgress \
--user-fraction 0.1
Warning: inAppUpdatePriority was left unset (defaults to 0) and cannot be changed after rollout begins. If this release needs a forced update prompt, halt now and re-create the release with --in-app-update-priority 3.
End of example.
References
references/service-account.md— Cloud project, service account, JSON key, Play Console invite, scope, verification, and permission failure modes.references/fastlane-supply.md—supply init, the exactmetadata/android/<locale>/tree, changelogs, screenshots, option table, upload ordering, and a completeFastfilewith internal, beta, production-staged, and promote lanes.references/publishing-api-v3.md— the edit lifecycle, everyedits.*endpoint with HTTP method and path, upload size caps, and a step-by-step curl walkthrough.references/upload_aab.py— working Python uploader/promoter (google-auth + requests).references/upload_aab.sh— bash + curl equivalent.references/tracks-and-rollout.md— tracks, promotion,TrackReleaseschema, staged rollout, halting and resuming,inAppUpdatePriority,countryTargeting.references/github-actions.md— completer0adkll/upload-google-playworkflow, input table, secret handling.references/mapping-and-symbols.md— R8 mapping and native debug symbol upload across all three paths.references/troubleshooting.md— error strings, causes, fixes, and the order to check them in.