gplay release flow
Drive the Google Play release lifecycle from the command line with gplay:
upload a build to a track, promote it up the ladder, run a staged
rollout on production (and halt / resume / complete it), and list
what is currently on a track. gplay hides Google's three-step Edit transaction
(edits.insert → change → edits.commit) behind a single call per command.
Shared conventions (auth setup with gplay auth doctor, --package pinning
via gplay init, --output json for machines, and the semantic exit-code
table) are in gplay-cli-usage; onboarding auth is gplay-setup. The codes
that matter most here: 3 a required --confirm is missing (re-run with it),
30 an API 4xx such as a missing track, 60 an ambiguous target (two releases
coexist), 40/50 retry-safe (5xx / network).
Mental model: the track ladder + the rollout state machine
A build is uploaded to one track (internal, alpha, beta,
production, or any custom closed-track name) and then promoted up the
ladder: the same versionCode, no AAB re-upload. On a track, the latest
release moves through a small state machine:
draft ──► inProgress (userFraction f) ──► completed (f = 1.0)
│ ▲
halt│ │resume
▼ │
halted (fraction preserved)
rollout sets the fraction, halt freezes it, resume un-freezes it, and
complete ramps to 100%.
Upload a build to a track
gplay releases upload ./app.aab --track internal
gplay releases upload ./app.aab --track production --staged 0.1 --confirm
gplay releases upload ./app.aab --track production --complete --confirm
One call runs the full Edit lifecycle (edits.insert → bundles.upload → tracks.update → edits.commit). Any string is a valid --track, so custom
closed tracks "just work", as long as the track already exists (see
Track must exist first below). Attach notes with --release-notes or a
--release-notes-dir of <locale>.txt files, and a ProGuard/R8
--mapping mapping.txt so vitals can symbolicate this build's crash stacks
(see Deobfuscation mappings below). Run gplay releases upload --help for
the full set.
Large-artifact uploads are resumable: gplay transfers the
AAB/APK over Google's resumable upload protocol, so a transient interruption
during a big upload resumes instead of restarting from zero. It is automatic;
there is no flag to set (and no --timeout cap applies to the upload leg).
upload also accepts a legacy .apk ([experimental], via
edits.apks.upload): the extension picks the API call (AAB vs APK), and
--format apk|bundle overrides when the extension is ambiguous. The rest of
the pipeline (track, notes, --mapping, draft-by-default on production,
--dry-run/--confirm) is identical. Google has required the AAB for new
apps since August 2021, so APK uploads only serve existing apps still
distributed as APKs; if the app requires an App Bundle, Google's rejection
passes through verbatim.
Local preflight: the file is inspected before any byte leaves
Before the upload, gplay opens the artifact locally and checks two things by
structure, never by extension: the container really is the format the call
promised (an AAB is a zip carrying BundleConfig.pb, an APK carries
AndroidManifest.xml at its root), and the package name its manifest declares
matches the package being released. A mismatch fails offline in milliseconds
with exit 20, naming what was expected and what was found; no Edit is
opened and no upload session is reserved. A renamed file (app.apk that is
really an AAB, or a build for another package) is caught here, not by Google
minutes later. When the manifest cannot be read, the preflight degrades to a
stderr NOTE and the upload proceeds. --dry-run reports the preflight
result; --skip-preflight uploads the file as-is. The same check guards
releases sharing upload, customapps create and appstore upload apk;
releases expansion-files upload checks only that the file is not an
AAB/APK. --mapping is not preflighted (a mapping is not an Android
container).
Release-notes files must be named in BCP 47 (en-US.txt, pt-BR.txt).
An underscore form such as en_US.txt is refused before the Edit opens, every
offending file named in one error, so fix them all at once.
Promote a build up the ladder (no re-upload)
gplay releases promote --from internal --to alpha
gplay releases promote --from beta --to production --staged 0.1 --confirm
promote copies the latest release on --from to --to, keeping the same
versionCode. Release notes carry over from the source unless you override
with --release-notes / --release-notes-dir. If the source track holds more
than one release (e.g. an inProgress plus a halted one), disambiguate with
--version-code N or --release-name <name>, otherwise the command refuses
rather than guess (exit 60).
Staged rollout: rollout / halt / resume / complete
These four act on the latest release of --track. On production each one
reaches real users, so each requires --confirm.
gplay releases rollout --track production --to 0.25 --confirm # set fraction → inProgress
gplay releases halt --track production --confirm # freeze at current fraction
gplay releases resume --track production --confirm # un-freeze, continue
gplay releases complete --track production --confirm # ramp to 1.0 → completed
rollout --to <f>sets the staged fraction (0 < f ≤ 1.0) and flips status toinProgress.haltsetsstatus=haltedwhile preserving the currentuserFraction, so a laterresumepicks up exactly where it left off.resumereturns the release toinProgressat the halted fraction.completeramps touserFraction=1.0,status=completed, ending the rollout.
When two releases coexist on the track, pin one with --version-code or
--release-name (same rule as promote).
Inspect what is on a track
gplay releases list --track production
gplay releases list --track production --output json
gplay releases list --track production --columns name,status,userFraction
releases list reads the track inside a read-only Edit (nothing is committed)
and shows every release on it: draft, inProgress, halted, completed. For a
cross-track or whole-track view use the gplay-tracks skill (gplay tracks list / gplay tracks view).
Generated APKs: list + download what Play signs from your AAB
After an upload, Play generates and signs the APKs it actually serves to
devices from your AAB: split, standalone, and universal APKs, plus asset-pack
and recovery-module slices. The generated sub-surface ([experimental])
lists their download metadata and fetches the raw signed bytes, to verify the
signing identity, sideload, or archive the exact artifacts Play serves.
gplay releases generated list --version-code 42
gplay releases generated download <downloadId> --version-code 42 --dest ./universal.apk
gplay releases generated download <downloadId> --version-code 42 --dest - # stream to stdout
Points to know:
- Edit-free reads. The
generatedapksendpoints are application-scoped (not under an Edit), so gplay issues a direct GET; don't pattern-matchreleases listand expect an Edit. Only requires the service account to be invited on the app. --version-code Nis required on both; it addresses the uploaded bundle.listflattens the API's signing-key groups into one row per artifact (type · module · split/variant/slice id · downloadId · cert);--output jsonstays the verbatimGeneratedApksListResponse(ADR-0003).- The Download ID from
listis the positional handledownloadtakes. It is not a URL and not stable across re-generation; read a fresh one fromlist, never cache it. downloadwrites to--dest PATH(required;-streams to stdout); the payload is opaque bytes, so there is no--outputhere (ADR-0034). Bytes are streamed, a✓line on stderr reports count and destination, and a failed transfer leaves no partial file behind.- Exit codes:
11(403, not invited),30(404, unknown package/version/Download ID),40/50retry-safe;downloadadds20when--destcan't be written. Missing required args are usage (exit2).
Deobfuscation mappings (symbolicate vitals crash stacks)
A ProGuard/R8 mapping.txt lets Play vitals de-obfuscate a release's crash
stacks. There are two ways to attach one:
# The common case, with the artifact, in the same Edit:
gplay releases upload ./app.aab --track production --mapping ./mapping.txt --confirm
# After the fact, attach to an already-published versionCode:
gplay releases mappings upload ./mapping.txt --version-code 42
gplay releases mappings upload ./native.txt --version-code 42 --type nativeCode
Prefer --mapping on upload when the mapping exists at build time.
releases mappings upload covers the case where the version is already live and
you only later need symbolication; it runs its own Edit lifecycle
(edits.insert → deobfuscationfiles.upload → edits.commit). --version-code is
required; --type is proguard (default) or nativeCode; --dry-run previews
without a call. See the gplay-vitals skill for reading the symbolicated stacks.
Internal App Sharing (private shareable build links)
gplay releases sharing upload ./app.aab # prints a private downloadUrl
gplay releases sharing upload ./app.apk --output json
gplay releases sharing upload ./app.aab --dry-run
releases sharing upload ([experimental]) pushes an APK or AAB to Google Play
Internal App Sharing and prints the private, shareable downloadUrl an
authorized tester follows to install it. It bypasses tracks and the Edit
lifecycle entirely, a QA/preview gesture, not a release: no track, no
rollout, no versionCode promotion. The extension picks APK vs AAB
(--format apk|bundle overrides), and the same local preflight as releases upload verifies container and package name before any byte is sent
(--skip-preflight to bypass). No --confirm is needed (the link is private
and creates no release), but GPLAY_READONLY=1 still refuses it (exit 4).
--output json passes the InternalAppSharingArtifact through verbatim
(downloadUrl, certificateFingerprint, sha256).
Legacy OBB expansion files
Only APK-based apps carry .obb expansion files (the pre-AAB mechanism for
150 MB assets; AAB apps use Play Asset Delivery). When the task touches OBB files, read obb.md for the
expansion-files upload/set/viewcommands.
Production safety is built in
gplay defaults to the cautious choice on production (ADR-0002): an upload or
promote that targets production becomes a draft release unless you ask for
a live one with --complete or --staged, and those, plus every
rollout/halt/resume/complete on production, require an explicit
--confirm. If you omit it, the command fails with exit 3 and names the
flag it wants; re-run with that flag added. Treat exit 3 as "safe to retry
verbatim once --confirm is appended", never as a hard failure.
Every write command also takes --dry-run: it validates inputs and prints
the payload it would send without making any HTTP call. Use it to preview a
production change before committing to it.
Track must exist first (the trackhint behavior)
gplay never auto-creates a track as a side effect of an upload or promote;
a typo'd --track must fail loudly, not silently spawn a phantom track. When
upload or promote targets a custom closed track that has not been created
yet, the command fails with exit 30 and a hint naming the fix:
track "qa-team" does not exist — create it first with
`gplay tracks create qa-team`, then re-run …
Recovery: create the track once (gplay tracks create <name>, see the
gplay-tracks skill), then re-run the upload/promote. The standard tracks
(internal, alpha, beta, production) always exist and never need this.