From 43e3c7b69ffad2bcd36ddf8eeca658354aa37f3b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 1 Aug 2026 09:29:04 +0200 Subject: [PATCH] feat(ci/android): play-upload can attach release notes and promote a build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things it could not do, both needed now that a tag ships to production. Release notes: it never sent `releaseNotes`, so Play's "What's new" was whatever the previous release said. It now takes --release-notes-file, and refuses text over Play's 500-char-per-language cap with the actual count — that check has to happen before the upload, because the API only rejects it at commit, by which point the AAB is already on Play. Promotion: --promote assigns a versionCode that is already on Play instead of uploading, so what reaches production is the byte-identical artifact the testers ran. Rebuilding would mint a fresh versionCode from possibly-newer sources and ship something nobody tested. --promote-from asserts the code really is on that track (a typo'd versionCode now fails before it touches production) and clears that track in the SAME edit, so the build is never active on both at once. --user-fraction comes along because --status inProgress is an API error without it; it is validated as strictly between 0 and 1 rather than left to Google. Co-Authored-By: Claude Opus 5 (1M context) --- clients/android/ci/play-upload.py | 111 +++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 16 deletions(-) diff --git a/clients/android/ci/play-upload.py b/clients/android/ci/play-upload.py index db3d9bb7..b557707d 100644 --- a/clients/android/ci/play-upload.py +++ b/clients/android/ci/play-upload.py @@ -6,13 +6,23 @@ Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints G error at the stage it fails instead of a catch-all. Reuses the SERVICE_ACCOUNT_JSON secret and tolerates it being raw JSON *or* base64-encoded JSON. -Usage: +Usage (upload a new build): SERVICE_ACCOUNT_JSON='' \ python3 play-upload.py --package io.unom.punktfunk \ --aab path/to/app-release.aab --track internal --status completed [--no-commit] ---no-commit: do insert -> upload -> track-update -> validate, then delete the edit (publishes -nothing). Use it to dry-run the credentials/AAB without touching the live track. +Usage (promote a build that is already on Play, no rebuild): + python3 play-upload.py --package io.unom.punktfunk \ + --promote 10816 --promote-from alpha --track production + +Promotion assigns an EXISTING versionCode to another track, so what ships to production is the +byte-identical artifact the testers ran — rebuilding would burn a fresh versionCode and ship +something nobody has tested. --promote-from additionally asserts the code really is on that track +(catches a typo'd versionCode before it reaches production) and empties it in the SAME edit, so +the move is atomic: testers are never left pinned to a code that production also serves. + +--no-commit: do insert -> upload/assign -> track-update -> validate, then delete the edit +(publishes nothing). Use it to dry-run the credentials/AAB/notes without touching the live track. """ import argparse, base64, json, os, subprocess, sys, tempfile, time import urllib.request, urllib.parse, urllib.error @@ -104,18 +114,79 @@ def access_token(sa) -> str: return tok["access_token"] +# Play's "What's new" is capped at 500 characters per language. The cap lives in the Console +# (the REST reference does not state it) and the API rejects longer text at commit — i.e. AFTER +# the AAB has uploaded — so check it up front and print the actual count. +NOTES_MAX = 500 + + +def load_release_notes(path, language): + with open(path, encoding="utf-8") as f: + text = f.read().strip() + if not text: + sys.exit(f"ERROR: release-notes file is empty: {path}") + if len(text) > NOTES_MAX: + sys.exit(f"ERROR: release notes are {len(text)} chars, Play allows {NOTES_MAX}: {path}") + print(f"release notes: {len(text)}/{NOTES_MAX} chars ({language})") + return [{"language": language, "text": text}] + + +def put_track(app, edit, tok, track, version_codes, status, user_fraction=None, notes=None): + """PUT one track. An empty version_codes list clears the track (what promotion does to the + track it promoted OUT of).""" + release = {"status": status, "versionCodes": [str(v) for v in version_codes]} + if user_fraction is not None: + release["userFraction"] = user_fraction + if notes: + release["releaseNotes"] = notes + # Clearing a track means "no active releases", not "an empty release". + body = {"track": track, "releases": [release] if version_codes else []} + call("PUT", f"{app}/edits/{edit}/tracks/{track}", token=tok, + data=json.dumps(body).encode(), content_type="application/json") + + +def assert_on_track(app, edit, tok, track, vc): + """Fail before anything is written if --promote names a versionCode that is not actually on + the track we claim to be promoting out of.""" + got = call("GET", f"{app}/edits/{edit}/tracks/{track}", token=tok) + live = [c for r in got.get("releases", []) for c in r.get("versionCodes", [])] + if str(vc) not in live: + sys.exit(f"ERROR: versionCode {vc} is not on track '{track}' (it has: {live or 'nothing'})") + print(f"verified versionCode={vc} is live on '{track}'") + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--package", required=True) - ap.add_argument("--aab", required=True) + ap.add_argument("--aab", help="upload this bundle (mutually exclusive with --promote)") + ap.add_argument("--promote", type=int, metavar="VERSIONCODE", + help="assign an already-uploaded versionCode instead of uploading") + ap.add_argument("--promote-from", metavar="TRACK", + help="with --promote: assert the code is on TRACK, then clear TRACK") ap.add_argument("--track", default="internal") ap.add_argument("--status", default="completed") + ap.add_argument("--user-fraction", type=float, + help="staged rollout fraction, 0 track={a.track} status={a.status}") + if a.promote: + vc = a.promote + if a.promote_from: + assert_on_track(app, edit, tok, a.promote_from, vc) + else: + with open(a.aab, "rb") as f: + blob = f.read() + print(f"uploading {a.aab} ({len(blob)} bytes) ...") + vc = call("POST", f"{UPLOAD}/{a.package}/edits/{edit}/bundles?uploadType=media", + token=tok, data=blob, content_type="application/octet-stream")["versionCode"] + print(f"uploaded versionCode={vc}") + + put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes) + print(f"assigned versionCode={vc} -> track={a.track} status={a.status}" + + (f" userFraction={a.user_fraction}" if a.user_fraction is not None else "")) + # Same edit as the assignment above, so the code is never active on both tracks at once. + if a.promote_from: + put_track(app, edit, tok, a.promote_from, [], a.status) + print(f"cleared track '{a.promote_from}'") if a.no_commit: call("POST", f"{app}/edits/{edit}:validate", token=tok)