ci(android): replace r0adkll with a direct Play Publishing-API upload
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 / 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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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='<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()
|
||||
Reference in New Issue
Block a user