# Gplay Release Flow

> Ship Android releases through Google Play with `gplay releases`. Use when uploading an AAB or APK to a track, promoting a build up the internal → alpha → beta → production ladder, steering a staged rollout (halt / resume / complete), inspecting or downloading what sits on a track, attaching ProGuard/R8 mappings for vitals symbolication, sharing a private Internal App Sharing build, or managing legacy OBB expansion files.

- Skill: `pollyglot/gplay-release-flow` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add pollyglot/gplay-release-flow`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pollyglot/gplay-release-flow/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: PollyGlot (https://skillmd.com/u/pollyglot)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/pollyglot/gplay-release-flow

---


# 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

```bash
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)

```bash
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`.

```bash
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
  to `inProgress`.
- `halt` sets `status=halted` while **preserving** the current `userFraction`,
  so a later `resume` picks up exactly where it left off.
- `resume` returns the release to `inProgress` at the halted fraction.
- `complete` ramps to `userFraction=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

```bash
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.

```bash
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 `generatedapks` endpoints are application-scoped
  (not under an Edit), so gplay issues a direct GET; don't pattern-match
  `releases list` and expect an Edit. Only requires the service account to be
  invited on the app.
- **`--version-code N` is required on both**; it addresses the uploaded
  bundle. `list` flattens the API's signing-key groups into one row per
  artifact (*type · module · split/variant/slice id · downloadId · cert*);
  `--output json` stays the verbatim `GeneratedApksListResponse` (ADR-0003).
- The **Download ID** from `list` is the positional handle `download` takes.
  It is **not a URL** and **not stable** across re-generation; read a fresh
  one from `list`, never cache it.
- `download` writes to **`--dest PATH`** (required; `-` streams to stdout);
  the payload is opaque bytes, so there is no `--output` here (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`/`50` retry-safe; `download` adds `20`
  when `--dest` can't be written. Missing required args are usage (exit `2`).

## 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:

```bash
# 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)

```bash
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](obb.md) for the `expansion-files upload/set/view`
commands.

## 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.

