suede-play-release · diff
git:20260906.317f6d5 to git:20260906.48f5da5
1 added, 1 removed. Audit A to A.
---
name: suede-play-release
- description: "Suede Labs Google Play delivery skill: set up publishing credentials, upload an AAB, promote between tracks, stage a rollout, and prove against the Play Developer API what is actually live. Use when uploading or promoting an Android build, wiring a Play service account and fastlane supply lanes for the first time, writing or fixing per-versionCode changelogs, raising or halting a staged rollout, or answering what version and rollout fraction production is really serving. NOT FOR: planning, building, or policy-checking the app itself (use android-app-factory); live Play listing and keyword audits on a shipped app (use suede-aso); wiring CI required checks around the release (use suede-ci-gate); iOS archive, upload, or App Store review submission (a private Suede Labs companion, not in this pack: ios-app-store-release)."
+ description: "Suede Labs Google Play delivery skill: ship an Android release end to end from the agent interface, without opening the Play Console. Set up credentials, upload an AAB, promote between tracks, stage or complete a rollout, push per-locale release notes, and prove against the Play Developer API what is actually live. One step stays a Console action by design and the skill hands it to you: granting the service account release access the first time. Use when uploading or promoting an Android build, wiring a Play service account and fastlane supply lanes, fixing per-versionCode changelogs, raising or halting a staged rollout, or answering what version and fraction production is really serving. NOT FOR: planning or building the app itself (use android-app-factory); live listing and keyword audits (use suede-aso); CI required checks (use suede-ci-gate); iOS release (a private Suede Labs companion, not in this pack: ios-app-store-release)."
---
# Suede Play Release
```text
Iron Law: the Play API decides what shipped, not a green fastlane summary.
Every claim about a live release is a readback or it is a guess.
```
## Gate policy — advisory, not blocking
Run every check and report results honestly. Verdicts are advice attached to the
work, never a control that changes it. A failed check changes what you report,
never what you do. The one exception is the production halt in Step 7: a public
release is irreversible for the users who get it, so present the options and let
the user pick.
## Step 0 — Preflight the credential before anything else
Never assume publishing works. Prove it with a real API call that changes
nothing: open an edit and delete it.
```bash
python3 scripts/play-preflight.py
```
Three outcomes, three different actions:
| Result | Meaning | Do |
| --- | --- | --- |
| token fails | key is wrong or revoked | rotate, see Step 1 |
| HTTP 401/403 on the edit | Play Console has not granted the account | Step 1's grant |
| edit opens and deletes | publishing is wired | continue |
A 403 here is the normal state of a brand-new service account, not a bug.
## Step 1 — Credentials
Use a **dedicated publishing service account**, separate from any billing or
purchase-verification account. A credential that can push releases should not
also be able to void purchases. Give it no cloud IAM roles: all of its authority
comes from the Play Console grant, so before that grant the key is inert.
Store the JSON in the login Keychain **base64-encoded**:
```bash
security add-generic-password -U -s GOOGLE_PLAY_SERVICE_ACCOUNT_JSON -a <account> -w
```
Run it with `-w` last and no value so it prompts and the key never enters shell
history. Paste the base64, not the raw JSON.
Base64 is load-bearing. `security` hex-encodes any multi-line secret on read, so
raw JSON comes back in one of two shapes depending on content. Base64 gives one
shape and one decode path.
Granting access is a **Play Console UI action** and cannot be done from the API:
invite the service-account email under Users and permissions with release
permissions on the app. Report this to the user as a step only they can take,
with the exact email and permissions, then re-run Step 0.
## Step 2 — Pull before every command
`fastlane` reads the working tree, not the remote. A stale checkout produces a
run that succeeds and does nothing, because a missing changelog is a skip rather
than an error.
```bash
git -C <repo> pull --ff-only && git -C <repo> log --oneline -1
```
If fastlane offers to set itself up, the Fastfile is not on disk. Answer no and
pull; accepting scaffolds an empty config over the real one.
## Step 3 — Write the changelog first
Release notes live at
`fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt`, one file per
locale per versionCode.
A missing file is **not** an error. Play silently serves the previous version's
notes, so users read stale text against a new build and nothing tells you. Write
one for every locale that has listing copy, before uploading.
```bash
for l in $(ls fastlane/metadata/android); do
[ -f "fastlane/metadata/android/$l/changelogs/$VC.txt" ] || echo "MISSING: $l"
done
```
Match each locale's existing register rather than translating the English word
for word. Check the limit: 500 characters.
## Step 4 — Verify the artifact, never the source tree
A source read is not evidence that a change reached the binary.
```bash
jarsigner -verify app/build/outputs/bundle/release/app-release.aab | head -1
grep -oE 'android:version(Code|Name)="[^"]*"' \
app/build/intermediates/merged_manifests/release/*/AndroidManifest.xml
```
When the release changes an asset, hash it on both sides and require a match:
```bash
unzip -p <aab> 'base/res/*xxxhdpi*ic_launcher.png' | shasum -a 256
shasum -a 256 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
```
`jarsigner` reporting a self-signed certificate is correct under Play App
Signing, which re-signs with the real release key.
## Step 5 — Upload to a testing track first
```bash
fastlane android internal
```
Build uploads carry the binary and its changelogs only. Listing text, icon,
feature graphic and screenshots move through a separate lane, so a routine
upload cannot rewrite what the store says about the app.
## Step 6 — Promote, never re-upload
Play rejects a second bundle carrying a versionCode it already has. Re-uploading
is not a slower path to the same place, it is an error.
Promote the versionCode already on the testing track, which is also the only
thing that makes testing-first mean anything: the artifact reaching production is
the one that was validated, bit for bit.
```ruby
upload_to_play_store(
version_code: <code>, track: "internal", track_promote_to: "production",
skip_upload_aab: true, skip_upload_apk: true,
release_status: "inProgress", rollout: "0.1"
)
```
## Step 7 — Production halt
A production rollout is irreversible for the users who receive it. Stop and
present:
```text
Ready to promote versionCode <n> (<name>) to production at <pct>% of users.
Currently live: versionCode <m>. <one line on what users will notice>.
1. Promote at <pct>%
2. Promote at a different fraction
3. Upload to a testing track only
4. Hold
```
Then wait. Proceed on an explicit answer, and treat a standing instruction to
ship as that answer.
## Step 8 — Read back what is actually live
The fastlane summary reports that the request succeeded, not what the release
became. Parameters like `track_promote_release_status` can differ from the
`release_status` you set, so read the track:
```
GET /androidpublisher/v3/applications/<pkg>/edits/<id>/tracks/production
```
Check three fields and say them plainly:
- `versionCodes` — the build users get.
- `status` — `inProgress` is a staged rollout; `completed` is everyone.
- `userFraction` — absent when `completed`.
A 100% rollout should land as `status: completed` with **no** `userFraction`. A
release sitting at `inProgress` with `userFraction: 1.0` is a different state
that reads the same in a success message. Confirm which one you got.
Also confirm every locale you wrote appears in that release's `releaseNotes`.
## Done means
Every line proven by a command in this run:
- Preflight opened and deleted a real Play edit.
- The AAB verified: signature, versionCode in the merged manifest, and a hash
match for any changed asset.
- The track readback shows the expected versionCode, status, and fraction.
- Every locale with listing copy appears in the live release's notes.
## Boundaries
1. Do not promote to production, raise a rollout, or halt one without an
explicit instruction covering that action.
2. Do not create the Play Console grant yourself; it is a UI action. Name the
email and permissions and hand it to the user.
3. Do not print, commit, or copy the service-account JSON, the upload keystore,
or `keystore.properties` into a repo.
4. Do not push listing text, images, or screenshots as part of a build upload.
Store copy moves in its own deliberate step.
5. Do not report a release state from a fastlane summary. Read the track.
6. Do not reuse a billing or purchase-verification service account for
publishing.
7. Do not edit a changelog for a versionCode already serving users to fix a
typo silently; that changes live store copy and belongs in the same
confirmation as a rollout change.
## Routing
- Need to plan, build, test, or policy-check the app itself -> use
`android-app-factory`, then return here for delivery.
- Need a live Play listing or keyword audit on a shipped app -> use
`suede-aso`.
- Need CI wiring, required checks, or merge gates around the release -> use
`suede-ci-gate`.
- Need branch, worktree, or stale-mirror handling for the release branch -> a
private Suede Labs companion, not in this pack: suede-git-hygiene.
- Need the iOS half of the same release -> a private Suede Labs companion, not
in this pack: ios-app-store-release.
- Need constants to match across the Android, iOS and web surfaces -> use
`suede-parity-contract`.
- From `android-app-factory`: route Play credential setup, upload,
track promotion, staged rollout, and live-state verification back here.