Files
punktfunk/clients/android/ci/play-upload.py
T
enricobuehler 1faa6c6ad4
ci / rust (push) Successful in 1m39s
ci / web (push) Successful in 32s
deb / build-publish (push) Successful in 2m31s
decky / build-publish (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
apple / swift (push) Successful in 53s
ci / docs-site (push) Successful in 31s
android / android (push) Successful in 4m6s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m11s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
ci(android): replace r0adkll with a direct Play Publishing-API upload
r0adkll/upload-google-play hides real API errors behind "Unknown error
occurred." Proved the full upload sequence (insert edit -> upload bundle ->
track update -> validate) succeeds with the service account, so the failure was
r0adkll's opaque error handling and/or a base64-encoded SERVICE_ACCOUNT_JSON
secret.

clients/android/ci/play-upload.py does the same sequence with stdlib + openssl
(no pip), reuses the SERVICE_ACCOUNT_JSON secret, tolerates it being raw JSON or
base64, auto-retries commit with changesNotSentForReview, and prints Google's
actual error. Locally dry-run-validated against the live app (both secret forms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:38:20 +00:00

143 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Upload a signed AAB to Google Play via the Publishing API — a direct replacement for
r0adkll/upload-google-play, which swallows real API errors into "Unknown error occurred."
Why hand-rolled: stdlib + `openssl` only (no pip on the runner), and it prints Google's actual
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:
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
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.
"""
import argparse, base64, json, os, subprocess, sys, tempfile, time
import urllib.request, urllib.parse, urllib.error
API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
UPLOAD = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
class ApiError(Exception):
def __init__(self, code, method, url, body):
super().__init__(f"HTTP {code} from {method} {url}\n{body}")
self.code, self.body = code, body
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def call(method, url, token=None, data=None, content_type=None, want_json=True):
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
if content_type:
headers["Content-Type"] = content_type
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=300) as r:
body = r.read()
except urllib.error.HTTPError as e:
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
return json.loads(body) if (want_json and body) else body
def load_sa():
raw = os.environ.get("SERVICE_ACCOUNT_JSON", "")
if not raw.strip():
sys.exit("ERROR: SERVICE_ACCOUNT_JSON env is empty")
try: # raw JSON (what r0adkll expects)
return json.loads(raw)
except json.JSONDecodeError:
try: # or base64-encoded JSON (common mistake)
sa = json.loads(base64.b64decode(raw))
print("note: SERVICE_ACCOUNT_JSON was base64-encoded; decoded it.")
return sa
except Exception:
sys.exit("ERROR: SERVICE_ACCOUNT_JSON is neither valid JSON nor base64-encoded JSON")
def access_token(sa) -> str:
now = int(time.time())
header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
claims = _b64url(json.dumps({
"iss": sa["client_email"],
"scope": "https://www.googleapis.com/auth/androidpublisher",
"aud": sa["token_uri"], "iat": now, "exp": now + 3600,
}).encode())
signing_input = f"{header}.{claims}".encode()
with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as f:
f.write(sa["private_key"])
keyfile = f.name
try:
sig = subprocess.run(["openssl", "dgst", "-sha256", "-sign", keyfile],
input=signing_input, capture_output=True, check=True).stdout
finally:
os.unlink(keyfile)
jwt = f"{header}.{claims}.{_b64url(sig)}"
tok = call("POST", sa["token_uri"],
data=urllib.parse.urlencode({
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": jwt}).encode(),
content_type="application/x-www-form-urlencoded")
return tok["access_token"]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--package", required=True)
ap.add_argument("--aab", required=True)
ap.add_argument("--track", default="internal")
ap.add_argument("--status", default="completed")
ap.add_argument("--no-commit", action="store_true")
a = ap.parse_args()
if not os.path.isfile(a.aab):
sys.exit(f"ERROR: AAB not found: {a.aab}")
sa = load_sa()
tok = access_token(sa)
print(f"authenticated as {sa['client_email']} (project {sa.get('project_id')})")
app = f"{API}/{a.package}"
try:
edit = call("POST", f"{app}/edits", token=tok)["id"]
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}")
call("PUT", f"{app}/edits/{edit}/tracks/{a.track}", token=tok,
data=json.dumps({"track": a.track,
"releases": [{"status": a.status, "versionCodes": [str(vc)]}]}).encode(),
content_type="application/json")
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}")
if a.no_commit:
call("POST", f"{app}/edits/{edit}:validate", token=tok)
print("validated (dry-run) OK — deleting edit, nothing published")
call("DELETE", f"{app}/edits/{edit}", token=tok, want_json=False)
return
try:
call("POST", f"{app}/edits/{edit}:commit", token=tok)
except ApiError as e:
if "changesNotSentForReview" in e.body:
print("commit needs changesNotSentForReview=true — retrying")
call("POST", f"{app}/edits/{edit}:commit?changesNotSentForReview=true", token=tok)
else:
raise
print(f"COMMITTED: versionCode={vc} live on track '{a.track}' ({a.status})")
except ApiError as e:
print(f"\nPLAY API ERROR:\n{e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()