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
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>
143 lines
5.9 KiB
Python
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()
|