From 1faa6c6ad49d55ae3edffb5577baca28d8384eac Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 18 Jun 2026 20:38:20 +0000 Subject: [PATCH] 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) --- .gitea/workflows/android.yml | 17 ++-- clients/android/ci/play-upload.py | 142 ++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 clients/android/ci/play-upload.py diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 631033b..baa9524 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -103,12 +103,15 @@ jobs: echo " $base/punktfunk-android-r$VERSION.aab" echo " $base/punktfunk-android-r$VERSION.apk" + # Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the + # real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no + # pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview. - name: Upload to Google Play (Internal Testing) if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: r0adkll/upload-google-play@v1 - with: - serviceAccountJsonKeyData: ${{ secrets.SERVICE_ACCOUNT_JSON }} - packageName: io.unom.punktfunk - releaseFiles: clients/android/app/build/outputs/bundle/release/app-release.aab - track: internal - status: completed + env: + SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }} + run: | + python3 clients/android/ci/play-upload.py \ + --package io.unom.punktfunk \ + --aab clients/android/app/build/outputs/bundle/release/app-release.aab \ + --track internal --status completed diff --git a/clients/android/ci/play-upload.py b/clients/android/ci/play-upload.py new file mode 100644 index 0000000..3e51046 --- /dev/null +++ b/clients/android/ci/play-upload.py @@ -0,0 +1,142 @@ +#!/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='' \ + 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()