DESIGN.md@docs · diff
git:20260830.118ccfb to git:20260830.118a574
3 added, 1 removed. Audit A to A.
# Design conventions — gplay
Living reference for the CLI design decisions that didn't rise to ADR-level
(reversible, or "the obvious thing once you know the constraint") but that we
still want to be consistent about across commands.
For deeper rationale on the load-bearing choices, see:
- [ADR-0001 — Credential storage](./adr/0001-credential-storage.md)
- [ADR-0002 — Safe production defaults](./adr/0002-safe-production-defaults.md)
- [ADR-0003 — `--output json` is API pass-through](./adr/0003-json-passthrough.md)
- [ADR-0004 — Cascading config](./adr/0004-cascading-config.md)
- [ADR-0005 — TTY-aware output defaults](./adr/0005-tty-aware-output.md)
- [ADR-0019 — Canonical verb vocabulary](./adr/0019-canonical-verb-vocabulary.md)
- [ADR-0023 — JSON error envelope on failure](./adr/0023-json-error-envelope.md)
- [ADR-0024 — `GPLAY_READONLY` environment policy](./adr/0024-readonly-environment-policy.md)
- [ADR-0042 — 1.0 GA and the stability-label mechanism](./adr/0042-one-zero-ga-and-stability-label-mechanism.md)
- [ADR-0044 — Structured diagnostic codes](./adr/0044-structured-diagnostic-codes.md)
---
## 0. Verb vocabulary
Every command verb belongs to one of three categories. The rationale and the
considered alternatives live in
[ADR-0019](./adr/0019-canonical-verb-vocabulary.md); this is the normative
quick-reference.
**1. CRUD grammar** — generic resource verbs, held to strict consistency:
| Gesture | Verb | Example |
|---|---|---|
| Read many | `list` | `gplay tracks list` |
| Read one addressed resource | `view` | `gplay apps view`, `gplay tracks view` |
| Session / health (no addressed resource) | `status` | `gplay auth status` |
| Bring a new object into existence | `create` | `gplay tracks create <name>` |
| Add/remove membership of an existing entity | `add` / `remove` | `gplay apps add`, `gplay team users remove` |
| Delete a resource / clear a field | `remove` | `gplay team grants remove` |
| Write/declare state with explicit values | `set` | `gplay apps details set`, `gplay testers set` |
Deciding rules:
- **`view` vs `status`:** `view` reads a resource you point at (`--package`,
`--track`, …); `status` reports session/health with nothing pointed at.
`auth status` is the only `status`.
- **`create` vs `add`:** ask "am I making the object *exist*?" Yes → `create`
(a closed track). No, it already exists and I am enrolling it in a
collection → `add` (registering an app locally, adding a user to the org).
gplay cannot create or delete an app on Play, so apps use `add`/`remove`,
never `create`/`delete`.
- **`remove`, never `revoke`:** one delete verb across the surface.
- **`set`, never `edit`:** writes are declarative/idempotent (full-replace or
deterministic patch), non-interactive, and `edit` would collide with the
`Edit` transaction concept (`gplay edits …`).
- **No verb-less reads:** a read always carries `view` (so `apps details view`
and `tracks availability view`, not the bare nouns).
**2. Domain verbs** — each names a real gesture no generic verb captures:
`upload`, `promote`, `rollout`, `halt`, `resume`, `complete`, `reply`, `pull`,
`apply`, `validate`, `download`, `enroll`, `rotate`, `login`/`logout`, and the
App Recovery trio `deploy`, `cancel`, `add-targeting`. Admission test: it must
state a domain gesture `set`/`create`/`view` could not say honestly. The
`releases` rollout state machine (`rollout`/`halt`/`resume`/`complete`) lives
flat here — these act on a release's rollout *state*, not on a "rollout"
resource, so they are not nested.
The recovery trio was admitted under this test and recorded in
[ADR-0030](./adr/0030-android-publisher-long-tail-surfaces.md): `deploy`
(activate a draft, ≠ `create`/`set`), `cancel` (terminate to status CANCELED,
≠ `remove` which deletes), and `add-targeting` (append-only audience widening,
≠ `set` which would imply replace/narrow). `add-targeting` is the lone
hyphenated domain verb — a deliberate, narrow exception for legibility (it
mirrors the API method `addTargeting`), not licence for hyphenated verbs
generally. `download` (`releases generated download`) was admitted on the same
test: it writes opaque binary bytes to a local file — a gesture `view`/`set`
cannot state — and mirrors the API method `.download`. It is gplay's only binary
download-to-file verb; its destination flag is `--dest` (`--dest -` for stdout),
never `--output`, which controls structured-data format
([ADR-0034](./adr/0034-generated-apks-binary-download-to-file.md)).
`enroll` and `rotate` (`gplay signing`) were admitted on the same test with
[PRD #476](https://github.com/PollyGlot/google-play-cli/issues/476): they act on
an app's *signing key custody*, a state no resource verb can state honestly.
`enroll` moves an app onto a key the developer hosts in their own Cloud KMS
(a one-way transition of the app itself, so not `create`, which makes an object
exist, nor `add`, which enrolls into a collection gplay can also `remove` from),
and `rotate` swaps that live key for a successor carrying an apksigner
proof-of-rotation lineage (an ordered succession, not `set`, which would imply a
field you can overwrite back). Both mirror the API methods
`appsigning.enrollApp` and `appsigning.rotateAppSigningKey`. `rotate` is
unrelated to the release `rollout` state machine above: different noun,
different resource.
**3. Reference / diagnostic / scaffold** — meta-commands outside the resource
grammar, keeping their own names: `version`, `exit-codes`, `install-skills`
- ([ADR-0028](./adr/0028-install-skills-command.md)), `auth doctor`,
+ ([ADR-0028](./adr/0028-install-skills-command.md), installer mechanism
+ superseded by [ADR-0045](./adr/0045-install-skills-pinned-git-install.md)),
+ `auth doctor`,
`team permissions` (offline catalog), `init`.
---
## 1. Authentication
### Credential resolution precedence
In order, first match wins:
1. `--service-account <path-or-json>` flag on the command
2. `--account <name>` flag (selects a stored Account)
3. `GPLAY_SERVICE_ACCOUNT` env var (path or inline JSON)
4. `GPLAY_ACCOUNT` env var (name of a stored Account)
5. The Account marked **active** in `~/.gplay/config.json` (or `$XDG_CONFIG_HOME/gplay/config.json`)
If nothing resolves: exit code `10` with a message pointing at `gplay auth login`
and the env var docs.
**Absent vs. invalid.** Resolution has two distinct failure modes, and gplay
keeps them apart ([ADR-0020](adr/0020-resolution-error-surfacing.md)):
- **Absent** — no source is configured (none of layers 1–5 yield one), or the
named/active Account has no key in the store. This is a benign state: a
command that consumes a credential exits `10` ("run `gplay auth login`"),
but `gplay auth status` reports "No active account" and exits `0`, and
read-only `apps list` still works off the local registry.
- **Invalid** — a credential *was* provided but its bytes are unusable
(malformed JSON, a missing required field, an unreadable file, a keystore
read error). This is always an error: exit `10` with the underlying cause
in the message (`could not read credential: <cause>`), on **every** command
— including `auth status`, which no longer masks a corrupt active credential
as "No active account".
### `gplay auth doctor`
Runs these checks in order, stopping on the first hard failure:
1. Service account JSON present, readable, well-formed (required fields:
`client_email`, `private_key`, `token_uri`).
2. OAuth2 access token can be minted (RSA-signed JWT exchange succeeds).
3. Token bears the `androidpublisher` scope.
4. For every package in the local registry (or the one passed via
`--package`): call `edits.insert` then `edits.delete` round-trip. Catches the
common case "SA created but not invited on the app in Play Console".
Output: one `✅`/`❌` line per check, with an action hint on failure.
---
## 2. Package targeting
### Project pinning
`gplay init --package com.example.myapp` writes `.gplay/config.json` at the
repo root. Any subsequent command run inside that tree (we walk up from cwd
looking for `.gplay/`) defaults its target to that package — so `--package`
becomes optional.
`--package` always overrides.
### Cascading layers (see [ADR-0004](./adr/0004-cascading-config.md))
The same `config.json` schema appears at three levels. Later wins:
```text
$XDG_CONFIG_HOME/gplay/config.json (global, machine-local — Accounts live here)
<repo>/.gplay/config.json (project shared, committed — package pin)
<repo>/.gplay/config.local.json (project local, gitignored — per-developer overrides)
GPLAY_* env vars (e.g. GPLAY_ACCOUNT, GPLAY_SERVICE_ACCOUNT,
GPLAY_APP_STORE_PACKAGE — `appstore` axis, ADR-0043)
CLI flags (e.g. --account, --package, --service-account)
```
The walk-up that finds `.gplay/` refuses to traverse into `$HOME` (or any
ancestor of `$HOME`) so a stray `~/.gplay/config.json` cannot masquerade
as a project pin. `gplay init` refuses to run when `cwd == $HOME` for
the same reason.
### Field rules
- **`account` is forbidden in committed `config.json`.** Account names
are machine-local; pinning one in shared state breaks teammates. The
loader rejects it with an error naming the offending file path.
- `account` may appear in `config.local.json`, as `GPLAY_ACCOUNT`, or as
`--account`.
### `.gplay/` contents
- `config.json` — package pinning (the `package` field). **Commit this.**
- `config.local.json` — per-developer overrides (`account`, rarely
`package`). **Gitignore this** — `gplay init` writes the rule for you
in `.gplay/.gitignore`.
- `edit-<package>.json` — open explicit Edit ID (see `CONTEXT.md` → Edit).
**Gitignore this** too — transient and per-developer; covered by the
same `.gplay/.gitignore`.
---
## 3. Release commands
### Status defaults (see ADR-0002)
| Target track | Default status | Default userFraction |
|---|---|---|
| `production` | `draft` | — |
| `internal` / `alpha` / `beta` / closed | `completed` | `1.0` |
Explicit overrides: `--complete`, `--staged <fraction>`, `--draft`.
### `--track` is passthrough
Any string is accepted. The four standard tracks (`internal`, `alpha`, `beta`,
`production`) are documented but not enforced — closed-test tracks with
custom names work out of the box.
### Release notes
Two flags, mutually exclusive:
- `--release-notes "<text>"` — single text applied to the app's
`defaultLanguage`.
- `--release-notes-dir <dir>` — one file per locale (`en-US.txt`, `fr-FR.txt`,
...). Optional `default.txt` is used as fallback for locales without a
dedicated file.
### Targeting a release
- `releases upload <aab>` → versionCode is read from the AAB; never a flag.
- `releases promote/rollout/halt/resume/complete --track <X>` → targets the
**latest** release on the track. Override with `--version-code N` or
`--release-name <name>`. If two releases coexist on the track (e.g.
`inProgress` + `halted`) and no flag is passed, refuse with exit code `60`
rather than guess.
### Rollout state machine
Each transition is its own verb:
- `gplay releases rollout --to <fraction>` — set userFraction (status becomes
`inProgress` if it wasn't already)
- `gplay releases halt`
- `gplay releases resume`
- `gplay releases complete` — userFraction → 1.0, status → `completed`
### Sub-surfaces under `releases`
`releases` also hosts grouping nouns for non-track surfaces (ADR-0030;
`generated` added in ADR-0034):
- **`releases sharing upload`** — Internal App Sharing: a non-track media upload
(no Edit) returning a private shareable link (CONTEXT.md "Internal App
Sharing").
- **`releases expansion-files upload/set/view`** — legacy OBB expansion files,
an Edit artifact keyed by `apkVersionCode` (CONTEXT.md "Expansion file (OBB)").
Labeled legacy (superseded by Play Asset Delivery). The expansion **`patch`
type** (`--type patch`) is unrelated to the HTTP PATCH method: the API's
`update` (PUT) and `patch` (PATCH) both write the single field
`referencesVersion`, so gplay exposes one declarative `set` (PUT primary), not
a PUT/PATCH pair.
- **`releases generated list/download`** — the APKs Play generates and signs from
an uploaded AAB (CONTEXT.md "Generated APK"). **Read-only and Edit-free**
(application-scoped, not under an Edit). `list` enumerates the artifacts for a
`--version-code`; `download <downloadId>` streams one artifact's raw bytes to
`--dest PATH` (or `--dest -` for stdout) — gplay's only binary download-to-file
command, so it carries no `--output` flag
([ADR-0034](./adr/0034-generated-apks-binary-download-to-file.md)).
---
## 4. Edit lifecycle
### Implicit edits (default)
`begin → upload/patch → commit` is wrapped inside each write command. On any
failure after `begin`, the Edit is **auto-discarded** before the error
propagates. Pass `--keep-edit-on-failure` to bypass cleanup when debugging.
### Explicit edits
`gplay edits begin / commit / discard`. The Edit ID is persisted to
`.gplay/edit-<package>.json` so subsequent write commands in the same cwd
reuse it. No auto-discard in this mode — explicit `commit` or `discard` is
required.
---
## 5. Reviews
- API hard limit: **only the last 7 days** are exposed. Surfaced in `--help`
and as a stderr `WARN:` line on **every** successful run — including an empty
result (a quiet empty result must not read as "this app has no reviews").
- Auto-pagination is on by default; `--limit N` caps the result count, default
is no cap.
- `--stars` (e.g. `1`, `1-2`, `1,3,5`) is a **client-side** filter — the API
has no server-side rating filter.
- Long-history retrieval (CSV reports in the GCS bucket) is in `BACKLOG.md`.
---
## 6. Apps registry (workaround for missing `apps.list` endpoint)
The Google Play Developer API has no `apps.list`, so `gplay apps list` reads a
**local registry**:
- Populated by `gplay init --package X` (auto-adds) or `gplay apps add X`
- Stored in `~/.gplay/config.json` alongside Accounts
- `gplay apps view --package X` still hits the live API (via `edits.details`
etc.) — only enumeration is local
Backlog: real discovery via Cloud Resource Manager + IAM (see `BACKLOG.md`).
### App icon on `apps view` (`[experimental]`, ADR-0038)
`gplay apps view` also reports the app's store **icon** for its default
language, read live via `edits.images.list` inside the same read-only Edit:
- **Durable handle is `sha256`.** The icon's content `sha256` is the stable,
content-addressed identifier — the only value safe to persist, diff, or key a
cache on. The `table`/`markdown` views show a `sha256` line only when the icon
slot is non-empty; the `--output json` envelope adds an optional `icon` key
`{"url":..,"sha256":..}`, omitted entirely when the slot is empty
(missing == empty, [ADR-0013](./adr/0013-image-slot-reconciliation.md)).
- **`url` is a preview link — never persist it.** The `Image.url` has
undocumented resolution, auth, and expiry; it is passed through verbatim but
must not be stored. To fetch the actual icon **bytes**, use
`gplay metadata images pull`.
- **Scope reality.** Reading the icon requires the full `androidpublisher` OAuth
scope gplay already uses — Google exposes **no** narrower listings/images
`.readonly` scope — so it confers no new permission beyond "the service
account is invited on the app".
- **Not gated by `GPLAY_READONLY`.** The read mutates nothing (the Edit is
always discarded), so it is exempt from the read-only policy
([ADR-0024](./adr/0024-readonly-environment-policy.md)) and keeps working under
a read-only deployment.
- **No cache.** gplay never stores the icon between runs — each `apps view` is a
faithful live read. Caching (keyed on `sha256`) is the consumer's
responsibility.
`gplay metadata images list --type <AppImageType>` (`[experimental]`) narrows
the same live per-slot summary to a single image slot (CONTEXT.md "Image slot")
across locales; an unknown `--type` is refused client-side (exit 20) before any
API call, and `--output json` keeps its exact per-slot shape — `--type` only
narrows which slots appear.
---
## 7. Output
### Formats
A command's output Format is one of `table`, `json`, or `markdown`. The
dispatcher in `internal/output` layers three sources, the most explicit
winning:
1. `--output table|json|markdown`: always wins.
2. `$GPLAY_DEFAULT_OUTPUT`: a personal default, same value set as the
flag. An unset or empty value means "not set"; any other unknown
value is CLI misuse and fails with exit `2` naming the env var and
the three valid formats.
3. `auto`, the TTY-aware detection:
- `CI=true` (non-empty) → `json`
- stdout is not a TTY → `json`
- otherwise → `table`
`GPLAY_DEFAULT_OUTPUT` outranks the auto-detection, `CI` included: it is
a value the user typed, while CI and TTY state are guesses about intent,
so `GPLAY_DEFAULT_OUTPUT=table` still means `table` inside CI.
`--output table` in a piped context (e.g. behind `tee`) is the escape
hatch when the auto-detect is wrong. See
[ADR-0005](./adr/0005-tty-aware-output.md).
### Commands without `--output`
`auth login` and `auth logout` emit free-form human text and do not
expose `--output`. There is no structured payload that would survive
three Renderers, and forcing one would invent a schema with no consumer.
Any future command in the same shape (side-effecting, no structured
result) follows the same rule.
`releases generated download` follows it for the opposite reason: its
payload is **raw binary bytes**, not a Renderable. It writes the bytes to
`--dest PATH` (or `--dest -` for stdout) and names the byte count +
destination on a `✓` stderr line (§8), sidestepping a confusing
`--output`/`--output-file` collision
([ADR-0034](./adr/0034-generated-apks-binary-download-to-file.md)).
### `--output markdown`
Markdown is a first-rank Format, not "table-in-markdown syntax". Each
command renders the shape that fits its data:
- Tabular data → a Markdown table (helper: `output.MarkdownTable`).
- Status / info → a list of `- **Field**: value` lines.
- Checklists (`auth doctor`) → GitHub-style task list
(`- [x] Check 1` / `- [ ] Check 2 — hint: ...`).
### `--output json` is API pass-through (ADR-0003)
The JSON output mirrors the Google Play Developer API's native response
shape, including its per-endpoint envelope (`{"reviews":[...]}`,
`{"tracks":[...]}`, etc.). Exception: `apps list` (no API source) uses
`{"apps":[...]}`.
**Scope.** The pass-through guarantee applies to commands that *wrap a
Developer API call* — their JSON is the API's response, unowned by gplay.
**Offline reference commands wrap no API call and synthesise their own JSON**
(`team permissions`, `schema`): the shape is gplay's, documented per command,
and free to evolve (a `schema` is additionally `[experimental]`). Pass-through
is a promise about *not reshaping the API*, not a promise that every `--output
json` is an API echo.
### `--output table`
Columns are chosen for readability — not pass-through. Each command's
default columns are documented in its `--help`. `--columns col1,col2,...`
lets the caller override.
### Control-sequence sanitization (human formats only)
API-returned strings are often user-generated (review text, store-listing
copy). The **table and markdown** renderers strip ANSI escape sequences (CSI,
OSC) and C0/C1 control characters from every cell, centrally at the render
boundary — so a hostile value cannot inject color/cursor/title sequences into a
terminal or CI log. The sanitization is rune-based: legitimate multi-byte
content (accents, CJK, emoji) is untouched. **`--output json` is never
sanitized** — machine consumers get the bytes verbatim (ADR-0003); fidelity
lives on the JSON path, safety on the human path.
### Errors (`--output json` error envelope, ADR-0023)
Errors are **never** pass-through. The human-readable line always goes to
**stderr** (DESIGN §8). Under `--output json` a failing command *additionally*
writes a single structured envelope to **stdout**, so an agent/CI consumer can
branch on the failure without scraping stderr:
```json
{
"error": {
"code": "EDIT_ALREADY_EXISTS",
"exitCode": 60,
"retryable": false,
"operation": "edits.insert",
"package": "com.example.app",
"message": "edits.insert on com.example.app: edit already exists (HTTP 409) [reason: editAlreadyExists]",
"reasons": ["editAlreadyExists"]
}
}
```
- `code` / `exitCode` / `retryable` / `message` are always present.
- `code` is the stable SCREAMING_SNAKE diagnostic code (§9.1) — the field to
branch on, because it discriminates failures that share an exit code.
- `exitCode` mirrors the process exit code (§9).
- `retryable` says whether replaying the same command unchanged can plausibly
succeed, so retry logic needs no per-cause table. Emitted even when `false`.
- `operation` / `package` name the API call that failed; omitted on a local
failure, which is itself the signal that no call was made.
- `reasons` carries the upstream `error.errors[].reason` values verbatim when an
API envelope was parsed; omitted otherwise.
- `requires` names the missing safety flag on an exit-3 refusal (extends the
ADR-0017 dry-run `requires` to failure time); omitted otherwise.
Under `table` / `markdown` a failure leaves stdout empty (error → stderr only).
Exit codes and stderr are unchanged by the envelope. The envelope shape is part
of the public contract (ADR-0010); see
[ADR-0023](./adr/0023-json-error-envelope.md) and
[ADR-0044](./adr/0044-structured-diagnostic-codes.md).
---
## 8. Verbosity and logging
- **stdout** carries data only (the requested output).
- **stderr** carries logs, progress, warnings, errors. Always.
- `-v` / `--verbose` → info level on stderr (flow steps, edit ID, deduced
versionCode, ...).
- `-vv` → debug level (HTTP method + URL, headers, truncated bodies).
- `-q` / `--quiet` → only errors on stderr.
- Progress bars (e.g. AAB upload) are active **only in TTY** and disabled by
`--quiet`.
- Color is auto in TTY, disabled in pipes, disabled if `NO_COLOR` env or
`--no-color` is set.
### Success confirmation (`✓`)
A command that **successfully mutates Google Play state** emits a single `✓`
line on **stderr** once the change is committed — the success counterpart to the
`WARN:` and progress lines above. It is emitted **in addition to** the command's
stdout payload, so a human-legible success marker survives `--output json` and
piping (where stdout is machine data and the table view is absent).
- The line leans only on canonical terms (`track`, `status`, `versionCode`,
`userFraction`); it never names the Play "release" object — the glossary's
**Release** is the CLI's own distribution (CONTEXT.md), a different thing.
- `userFraction` is rendered as a percentage and only when `status` is
`inProgress` (a partial rollout — the one case where the fraction informs).
- `--dry-run` never emits it: `✓` means *committed*. A dry-run already prints
its plan to stdout.
- It is written through a single helper (`rc.Confirmf`) so `--quiet` can suppress
every `✓` in one place once that flag lands.
- Wording is **not** part of the Public contract (§7) — it is free to evolve.
`releases upload/promote/rollout/halt/resume/complete` are the first commands to
carry it; the remaining payload-bearing mutations (`metadata apply`,
`metadata images apply`, `apps details set`, `compliance datasafety set`,
`tracks create`, `testers set`) follow the same rule. Commands that mutate only
**local** state and have no stdout payload (`auth login/logout`, `apps
add/remove/init`, `team`) already emit a `✓` as their sole output; this rule
brings the payload-bearing mutations in line.
### Request timeouts (`--timeout`)
Every API call carries a deadline so a hung connection fails the step in
seconds instead of stalling a CI job until the runner-level kill:
- **Control-plane calls** (Edits, tracks, reviews, metadata, team, …) get a
**60s default** deadline, applied once where the kernel builds the
authenticated HTTP client — every command inherits it, no per-command
plumbing.
- **Media uploads** (`releases upload`, `releases sharing upload`,
`releases expansion-files upload`, `metadata images apply`) are **exempt from
the default**: a multi-hundred-MB transfer is never killed by the short
control-plane bound.
- The global **`--timeout <duration>`** flag (e.g. `--timeout 30s`,
`--timeout 2m`) overrides both — it bounds *every* request, uploads included.
Unset (`0`) means "60s for control-plane, unbounded for uploads".
A deadline-exceeded failure is a transport-level error and maps to **exit 50**
(network), the retry-safe bucket — so the same CI wrapper that retries a DNS
blip retries a timeout.
### Opt-in retry (`--retry`)
The global **`--retry N`** flag (default `0` = no retry) layers a transport
middleware on the authed client that retries the transient classes — transport
errors, HTTP 5xx, and 429 (honoring `Retry-After`) — with exponential backoff
plus jitter. Non-transient 4xx (auth, validation) and `edits.commit` (a
duplicate could double-publish) are never retried, so it is safe to leave on.
When `--retry` is set, `--timeout` becomes a **per-attempt** bound rather than a
single per-request one; request bodies are recreated per attempt (uploads
re-send from a fresh reader). Details and CI examples: [`CI_CD.md`](CI_CD.md#4-exit-codes--retry-vs-fail).
### Read-only policy (`GPLAY_READONLY`, ADR-0024)
`--confirm` / `--grant-admin` are advisory for an AI agent that holds the
credential — it can pass them itself. `GPLAY_READONLY` is the
environment-enforced authority boundary a harness can impose independent of the
model's flag choices:
- When `GPLAY_READONLY` is **truthy** (`1`/`true`/`yes`/`on`), every command
that mutates Google Play state is **refused with exit 4** — *before*
credential resolution and any network I/O, regardless of the flags passed.
Exit 4 is distinct from exit 3 on purpose: it is **not** resolvable by adding
a flag (the message says so); the caller must change the environment.
- **Read commands and `--dry-run` of mutating commands still run**, so
dashboards and agents can observe and plan with a production credential.
- Which commands mutate is a single registration-time annotation
(`kernel.MarkMutating`), not an ad-hoc per-command check — see CONTRIBUTING.
- Scope: the policy blocks **Google Play mutations**. Local-only operations
(credential `auth login`/`logout`, the local app registry) are not Play
writes and are not gated. Fine-grained allowlists (`GPLAY_ALLOW`) are a
future follow-up, out of scope here.
Under `--output json` the refusal is emitted as the standard error envelope
(exit code 4) on stdout (§7 / ADR-0023).
### Path containment (`GPLAY_ALLOW_EXTERNAL_SYMLINKS`)
gplay walks directory trees a repo owns (a metadata or images tree, a
release-notes directory, `.gplay/`), and a name in one of them can lie: a
`fr-FR.txt` symlinked at `~/.ssh/id_rsa` would be published to a public store
listing under the operator's own credentials. Every path built from one of
those trees is therefore **contained**: it is resolved through its symlinks and
must land at or under the resolved root, or the command is **refused with exit
2** naming the offending path. Both sides are resolved, so a checkout reached
through a symlink keeps working.
Two classes of path, and only one of them can be opened up:
- **Paths the operator arranged** are the names gplay found by reading their
own directory (a locale directory, a `title.txt`, a note file).
- **Paths derived from API data** are the ones built from a string the Play API
returned or from a package name (`<dir>/<locale>/`,
`.gplay/edit-<package>.json`). These are contained **unconditionally**: no
environment variable relaxes them, because that is the input the operator
never got to audit.
`GPLAY_ALLOW_EXTERNAL_SYMLINKS` is the opt-in escape hatch for the first class,
for the monorepo layouts that legitimately share files between trees
(`metadata/en-US/images` symlinked at `shared/assets/en-US`, a `title.txt`
pointing at a shared translation):
- **Unset (the default), containment is closed.** The refusal message names the
variable, so the operator of such a layout reads what to set rather than only
what broke.
- **Truthy** (`1`/`true`/`yes`/`on`, the spelling `GPLAY_READONLY` uses), a path
that leaves the tree **through a symlink** is followed, and a `NOTE:` line on
**stderr** names the path and what it resolved to (stdout stays untouched,
ADR-0003). One line per escaping path, not per read.
- It follows a link **out** of the tree; it does not allow a `..` component
climbing **above** the root, and it does not touch the API-derived class. It
is a door, not an off switch.
- Reads are what it covers in practice: `metadata`/`images` **pull** writes into
`<dir>/<locale>/` built from the API's locale, so a pull into a symlinked
locale directory stays refused whatever the environment says.
---
## 9. Exit codes
| Code | Meaning | Retry-safe? |
|---|---|---|
| `0` | Success | — |
| `1` | Generic error (fallback when nothing more specific fits) | No |
| `2` | CLI misuse (unknown flag, bad value, wrong number of positional args) | No |
| `3` | Safety flag required — command is well-formed but a named acknowledgment flag (`--confirm` / `--grant-admin`) is missing; the message names it | Deterministic (re-run with the named flag) |
| `4` | Denied by environment policy (`GPLAY_READONLY`) — a mutating command was refused; the message names the env var | No — **not** resolvable by adding a flag; change the environment |
| `10` | Authentication failure (SA invalid, token refused, scope missing) | No |
| `11` | Authorization (`403` — SA not invited on the app, etc.) | No |
| `20` | Client-side validation (malformed AAB, unknown locale, ...) | No |
| `30` | API 4xx other than auth/perms (not found, conflict, gone, ...) | No |
| `40` | API 5xx (upstream temporarily unhealthy) | **Yes** |
| `50` | Network (timeout, DNS, refused) | **Yes** |
| `60` | State conflict (another Edit open and unrecoverable, rate-limited, ambiguous release target, ...) | Sometimes |
| `70` | Findings present: a read-only check command (`apps audit`) ran to completion and reported drift; the report on stdout is complete | No (not a failure; fix what the report names) |
Documented in `gplay help exit-codes` and `docs/CI_CD.md`.
**Exit 3 has no exceptions.** *Every* refusal for a missing safety-acknowledgment
flag exits `3` — never `2` — whatever the command and however destructive the
write. This is the one distinction an automated caller most needs: `2` means "you
typed it wrong", `3` means "you were asked to acknowledge, re-run with the flag".
Commands built the refusal by hand for a while and drifted to `2`; that is what
[#406](https://github.com/PollyGlot/google-play-cli/issues/406) and
[#408](https://github.com/PollyGlot/google-play-cli/issues/408) corrected. Build
the refusal with `exit.SafetyFlag("<flag>", …)` and never with a bespoke error
type — the helper also feeds `requires: ["<flag>"]` into the `--output json`
error envelope (§7 / ADR-0023), which is how an agent recovers without scraping
the message.
**Exit 2 owns the argument count.** A wrong number of positional arguments —
missing *or* surplus — is CLI misuse (`2`), the same bucket as an unknown flag
or an unknown subcommand. All three doors are closed centrally, never per
command: flag-parse failures by the root's `FlagErrorFunc`, unknown subcommands
by `kernel.GroupRunE`, and positional-argument rejections by
`kernel.WrapArgErrors` — one walk over the assembled tree, the last statement of
`newRootCmd`. Commands returned the generic `1` here for a while because cobra
hands an `Args` validator's error back untyped; that is what
[#426](https://github.com/PollyGlot/google-play-cli/issues/426) corrected. Like
the exit-3 harmonisation above, the fix *restores* this documented table rather
than changing the frozen contract (ADR-0010), which is why it shipped as a
`fix`. Never hand-roll an argument-count check in a command — declare the cobra
validator (`Args: cobra.ExactArgs(1)`, …) and let the kernel own the exit code.
**Exit 70 is not an error.** A check command that sweeps and reports (today only
`gplay apps audit`, PRD #449) exits `70` when its report carries at least one
finding: every call succeeded, the document on stdout is complete, and the
non-zero status exists purely so CI can gate on "clean" without parsing JSON. It
is a distinct code precisely so an automated caller can tell *found drift* (`70`)
from *could not look* (`10`/`11`/`30`/`40`/`50`); collapsing it into `1` would
make a healthy audit and a broken one indistinguishable. Build it with
`exit.Findingsf(…)`, and never for a call that actually failed: a per-app API
failure during a sweep is reported inside the document and drives the ordinary
API code.
### 9.1 Diagnostic codes
An exit code says which *bucket* a failure fell into; a **diagnostic code** says
which failure it was. Under `--output json` every failure carries one in the
error envelope's `code` field, so an agent branches on `EDIT_ALREADY_EXISTS`
rather than regexing the message for the word "already".
| Code | Exit | Retryable | Meaning |
|---|---|---|---|
| `GENERIC_ERROR` | 1 | No | Unclassified failure (no typed exit code) |
| `USAGE_ERROR` | 2 | No | CLI misuse |
| `SAFETY_FLAG_REQUIRED` | 3 | No | Re-run with the flag named in `requires` |
| `POLICY_READONLY` | 4 | No | Refused by the read-only environment policy |
| `AUTH_FAILED` | 10 | No | Authentication failed |
| `PERMISSION_DENIED` | 11 | No | Authorization failed (403) |
| `VALIDATION_FAILED` | 20 | No | Client-side validation rejected the input |
| `INVALID_ARGUMENT` | 30 | No | The API rejected the request as malformed (400) |
| `NOT_FOUND` | 30 | No | No such package, track, Edit or resource (404) |
| `API_ERROR` | 30 | No | Other API 4xx rejection |
| `UPSTREAM_UNAVAILABLE` | 40 | **Yes** | The API is temporarily unhealthy (5xx) |
| `NETWORK_ERROR` | 50 | **Yes** | Transport failure with no HTTP response |
| `STATE_CONFLICT` | 60 | No | Remote state conflicts with the request (409) |
| `EDIT_ALREADY_EXISTS` | 60 | No | An Edit is already open on this package |
| `EDIT_EXPIRED` | 60 | No | The pinned Edit expired; begin a new Edit |
| `RATE_LIMIT_EXCEEDED` | 60 | **Yes** | Rate or quota limit exceeded; back off |
| `FINDINGS_PRESENT` | 70 | No | A check command completed and reported findings; not a failure |
Classification is **total**: it is derived from the exit code an error already
carries, refined for an upstream failure by the HTTP status and Google's own
`error.errors[].reason`. A new typed error therefore inherits a code the day it
is written, with no dispatcher to remember to extend. An error that knows a
narrower code declares it by implementing `exit.Diagnoser`.
The Exit column is the *canonical* bucket, not a promise of equality: a handful
of wrapped errors keep a narrower exit code (a `400`+`editAlreadyExists` exits
`30`), and the envelope's own `exitCode` is always authoritative for a given
failure.
Codes are **append-only frozen contract** (ADR-0010/0042): a new failure mode
earns a new code, an existing one is never renamed or repurposed. The catalog is
introspectable without reading source, from `gplay help exit-codes` (human) and
`gplay schema --codes --output json` (machine). See
[ADR-0044](./adr/0044-structured-diagnostic-codes.md).
---
## 10. Tracks and testers
`gplay tracks create` and `gplay testers list/set` manage custom closed
testing tracks and their authorized audience. Both surfaces are shaped by
hard constraints of the Play Developer API — see `CONTEXT.md` (**Closed
track**, **Tester**) for the domain terms.
### Creating tracks
- `gplay tracks create <name>` creates a **closed testing** track. The
create endpoint (`edits.tracks.create`) supports exactly one type
(`CLOSED_TESTING`), so there is **no `--type` flag** — every created
track is closed. Open/internal track creation has no API path.
- The new track's form factor is `DEFAULT` (phone). `WEAR` / `AUTOMOTIVE`
closed tracks are deferred (`BACKLOG.md`) behind a future
`--form-factor` flag — additive, non-breaking when it lands.
- Creating a track that **already exists** surfaces the API error (exit
30); gplay does not fake idempotency. "Ensure exists" would be an
explicit future `--if-not-exists`.
- There is **no `tracks delete`** — the API exposes
create/get/list/patch/update but no delete. Removing a track is a Play
Console-only gesture.
### Managing testers
- The `edits.testers` resource exposes a **single** field, `googleGroups[]`,
and explicitly does not support individual tester emails ("email lists
are not supported by this resource"). So `gplay testers` manages
**Google Groups only** (`--group a@googlegroups.com,...`); there is **no
`--email`**. Adding individuals one-by-one stays Console-only.
- Testers are **declarative**: `testers set` replaces the whole group list
(maps 1:1 to `testers.update`), `testers list` reads it (`testers.get`).
No `add` / `remove` — the typical case is a single group, and a
declarative replace is idempotent (agent/CI-friendly).
- A bare `testers set` with neither `--group` nor `--clear` is a misuse
(exit 2), so a forgotten `--group` can never silently wipe the list.
Emptying the list on purpose is the explicit `--clear`.
- Testers are keyed per-track (`…/testers/{track}`), so `--track` is
**required**. gplay does not restrict which track names are valid —
`testers set --track production` is sent to the API, which rejects it;
gplay surfaces that error rather than re-implementing the rule.
### Write safety
Both commands are writes: implicit Edit (open → mutate → commit), with
`--dry-run` (validate + preview the payload, no HTTP) and
`--keep-edit-on-failure` (skip auto-discard for debugging), matching
`releases upload` / `promote`. **No `--confirm`**: a closed test track is
low-stakes and reversible, unlike a production rollout.
### Discovering the create step
`releases upload` / `promote` keep their passthrough `--track` (§3.2). An
upload/promote to a closed track that **does not exist yet** fails at the
API; gplay attaches a hint pointing at `gplay tracks create <name>` (same
pattern as the other track-not-found hints). gplay **never auto-creates** a
track as a side effect of an upload — a typo'd `--track` must fail, not
silently spawn a phantom track.
---
## 11. Stability labels and the Public contract
Since `1.0`, every command sits on one side of a line
([ADR-0010](./adr/0010-versioning-public-contract-and-ga.md) /
[ADR-0042](./adr/0042-one-zero-ga-and-stability-label-mechanism.md)):
- **Unlabelled = frozen.** Part of the Public contract — its name, flags,
semantics and exit codes cannot change without a **major** bump. Also frozen:
the config schema and its precedence, Account resolution precedence, and the
guarantee that `--output json` stays API pass-through — for the frozen,
API-backed commands (§7 / ADR-0003). An experimental command is outside the
contract in full, output shape included: `schema` in particular projects the
embedded index rather than echoing an API response, so there is no upstream
resource to pass through.
- **`[experimental]` = shipped, not frozen.** Free to change in any release.
**Not** covered either way: the `table` / `markdown` layouts, the *fields*
inside the pass-through JSON (Google owns those), and stderr wording.
### Declaring it
One registration-time annotation, exactly like `kernel.MarkMutating` (§8):
```go
root.AddCommand(kernel.Experimental(subscriptionsGroup))
```
`kernel.Experimental` labels the command **and the subtree already registered
under it**, so a whole young namespace takes one call; `kernel.IsExperimental`
walks up the parent chain, so a subcommand can never claim more stability than
its namespace. Call it after the children are added, so each carries the visible
tag rather than inheriting it silently.
### The default is a promise
Absence of a label is not "unknown", it is "frozen" — forgetting to label
over-promises. `TestStabilityRegistry_pinsPublicContract` in `cmd/gplay` walks
every runnable leaf and fails on any that is not explicitly classified, so a new
command cannot join the contract by omission.
### Where the label shows up
The `Short` carries a `[experimental]` marker (visible in the parent's command
list) and the `Long` opens with the consequence — may change in any release, pin
an exact release in CI. Both are applied by the helper; commands do not
hand-write them.
### Sub-feature labels
A few experimental *parts* of frozen commands (APK upload on `releases upload`,
the icon fields on `apps view`, `--type` on `metadata images list`) are marked
in prose in the help text. A command-level annotation cannot express those, and
three cases do not justify a flag-level mechanism.
### Graduation
Dropping the label is additive — a normal minor release. The reverse never
happens: a frozen command whose surface must change is a major bump.