#!/usr/bin/env python3 from __future__ import annotations import argparse import hashlib import json import os import sys import time import urllib.error import urllib.parse import urllib.request from pathlib import Path from typing import Any API_ROOT = "https://androidpublisher.googleapis.com/androidpublisher/v3" UPLOAD_ROOT = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3" RETRYABLE_STATUS = {429, 500, 502, 503, 504} class PlayApiError(RuntimeError): def __init__(self, status: int | None, message: str) -> None: super().__init__(message) self.status = status class PlayClient: def __init__(self, token: str) -> None: if not token: raise ValueError("Google Play access token is required") self.token = token def request( self, method: str, url: str, *, body: bytes | None = None, content_type: str | None = None, timeout: int = 180, attempts: int = 5, ) -> bytes: headers = { "Authorization": f"Bearer {self.token}", "Accept": "application/json", } if content_type is not None: headers["Content-Type"] = content_type for attempt in range(1, attempts + 1): request = urllib.request.Request( url, data=body, headers=headers, method=method, ) try: with urllib.request.urlopen(request, timeout=timeout) as response: return response.read() except urllib.error.HTTPError as error: error_body = error.read().decode("utf-8", errors="replace") if error.code not in RETRYABLE_STATUS or attempt == attempts: raise PlayApiError( error.code, f"Google Play API returned HTTP {error.code}: {error_body}", ) from error except urllib.error.URLError as error: if attempt == attempts: raise PlayApiError( None, f"Google Play API request failed: {error.reason}", ) from error time.sleep(2 ** (attempt - 1)) raise AssertionError("request retry loop exited unexpectedly") def request_json( self, method: str, url: str, *, value: Any | None = None, timeout: int = 180, ) -> dict[str, Any]: body = None content_type = None if value is not None: body = json.dumps(value, separators=(",", ":")).encode() content_type = "application/json" response = self.request( method, url, body=body, content_type=content_type, timeout=timeout, ) return json.loads(response) if response else {} def normalize_fingerprint(value: str) -> str: return "".join(character for character in value.lower() if character.isalnum()) def validate_closed_track(track: str) -> None: normalized = track.strip().casefold() if not normalized: raise ValueError("Play track is required") if normalized == "production" or normalized.endswith(":production"): raise ValueError( "production tracks are forbidden by this closed-testing pipeline" ) def find_universal_apk( response: dict[str, Any], expected_fingerprint: str, ) -> tuple[str, str] | None: expected = normalize_fingerprint(expected_fingerprint) for signing_key in response.get("generatedApks", []): fingerprint = normalize_fingerprint( str(signing_key.get("certificateSha256Hash", "")) ) if fingerprint != expected: continue universal = signing_key.get("generatedUniversalApk") or {} download_id = universal.get("downloadId") if download_id: return fingerprint, str(download_id) return None def build_track_payload( track: dict[str, Any], version_code: int, release_name: str, ) -> dict[str, Any]: releases = list(track.get("releases") or []) expected_code = str(version_code) if any( expected_code in [str(code) for code in release.get("versionCodes", [])] for release in releases ): raise ValueError( f"version code {version_code} is already present in track " f"{track.get('track', '')}" ) releases.append( { "name": release_name, "versionCodes": [expected_code], "status": "draft", } ) return {"track": track["track"], "releases": releases} def find_track_release( track: dict[str, Any], version_code: int, ) -> dict[str, Any] | None: expected_code = str(version_code) return next( ( release for release in track.get("releases") or [] if expected_code in [str(code) for code in release.get("versionCodes", [])] ), None, ) def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as source: for chunk in iter(lambda: source.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def generated_apks_url(package_name: str, version_code: int) -> str: package = urllib.parse.quote(package_name, safe="") return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}" def generated_apk_download_url( package_name: str, version_code: int, download_id: str, ) -> str: package = urllib.parse.quote(package_name, safe="") download = urllib.parse.quote(download_id, safe="") return ( f"{API_ROOT}/applications/{package}/generatedApks/" f"{version_code}/downloads/{download}:download?alt=media" ) def get_generated_apks( client: PlayClient, package_name: str, version_code: int, ) -> dict[str, Any] | None: try: return client.request_json( "GET", generated_apks_url(package_name, version_code), ) except PlayApiError as error: if error.status == 404: return None raise def download_universal_apk( client: PlayClient, package_name: str, version_code: int, expected_fingerprint: str, output: Path, *, attempts: int, interval_seconds: int, ) -> str: for attempt in range(1, attempts + 1): response = get_generated_apks(client, package_name, version_code) if response is not None: selected = find_universal_apk(response, expected_fingerprint) if selected is not None: fingerprint, download_id = selected apk = client.request( "GET", generated_apk_download_url( package_name, version_code, download_id, ), ) if apk: output.parent.mkdir(parents=True, exist_ok=True) output.write_bytes(apk) return fingerprint if attempt < attempts: time.sleep(interval_seconds) raise RuntimeError( "Google Play did not provide a non-empty universal APK signed with the " f"expected certificate after {attempts} attempts" ) def publish_bundle(args: argparse.Namespace) -> dict[str, Any]: validate_closed_track(args.track) if args.version_code < 1: raise ValueError("version code must be positive") if not args.bundle.is_file() or args.bundle.stat().st_size == 0: raise ValueError(f"AAB is missing or empty: {args.bundle}") client = PlayClient(args.access_token) existing = get_generated_apks(client, args.package_name, args.version_code) if existing is not None: selected = find_universal_apk(existing, args.expected_app_certificate) if selected is None: raise RuntimeError( "version code already exists in Play, but no universal APK matches " "the expected app-signing certificate" ) package = urllib.parse.quote(args.package_name, safe="") edit = client.request_json( "POST", f"{API_ROOT}/applications/{package}/edits", value={}, ) edit_id = str(edit["id"]) edit_base = f"{API_ROOT}/applications/{package}/edits/{edit_id}" try: track_id = urllib.parse.quote(args.track, safe="") track = client.request_json( "GET", f"{edit_base}/tracks/{track_id}", ) release = find_track_release(track, args.version_code) if release is None or release.get("status") != "draft": raise RuntimeError( "version code already exists in Play but is not a draft on " f"the configured track {args.track}" ) finally: try: client.request("DELETE", edit_base, attempts=1) except PlayApiError: pass source = "existing" else: package = urllib.parse.quote(args.package_name, safe="") edit = client.request_json( "POST", f"{API_ROOT}/applications/{package}/edits", value={}, ) edit_id = str(edit["id"]) committed = False edit_base = f"{API_ROOT}/applications/{package}/edits/{edit_id}" try: upload_url = ( f"{UPLOAD_ROOT}/applications/{package}/edits/{edit_id}/bundles" "?uploadType=media" ) try: uploaded = json.loads( client.request( "POST", upload_url, body=args.bundle.read_bytes(), content_type="application/octet-stream", attempts=1, ) ) except PlayApiError: bundles = client.request_json("GET", f"{edit_base}/bundles") matches = [ bundle for bundle in bundles.get("bundles", []) if int(bundle.get("versionCode", 0)) == args.version_code ] if len(matches) != 1: raise uploaded = matches[0] uploaded_code = int(uploaded["versionCode"]) if uploaded_code != args.version_code: raise RuntimeError( f"Play accepted version code {uploaded_code}, expected " f"{args.version_code}" ) track_id = urllib.parse.quote(args.track, safe="") track_url = f"{edit_base}/tracks/{track_id}" track = client.request_json("GET", track_url) payload = build_track_payload(track, args.version_code, args.release_name) client.request_json("PUT", track_url, value=payload) commit_url = ( f"{edit_base}:commit" "?changesInReviewBehavior=ERROR_IF_IN_REVIEW" ) client.request("POST", commit_url, body=b"") committed = True source = "uploaded" finally: if not committed: try: client.request("DELETE", edit_base, attempts=1) except PlayApiError: pass fingerprint = download_universal_apk( client, args.package_name, args.version_code, args.expected_app_certificate, args.apk_output, attempts=args.poll_attempts, interval_seconds=args.poll_interval, ) return { "packageName": args.package_name, "track": args.track, "releaseStatus": "draft", "releaseName": args.release_name, "versionCode": args.version_code, "bundleSha256": sha256_file(args.bundle), "universalApk": args.apk_output.name, "universalApkSha256": sha256_file(args.apk_output), "appSigningCertificateSha256": fingerprint, "source": source, } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Stage a signed AAB on a closed Play track and download Play's " "app-signed universal APK" ) ) parser.add_argument( "--access-token", default=os.environ.get("GOOGLE_PLAY_ACCESS_TOKEN"), ) parser.add_argument("--bundle", type=Path, required=True) parser.add_argument("--package-name", required=True) parser.add_argument("--track", required=True) parser.add_argument("--version-code", type=int, required=True) parser.add_argument("--release-name", required=True) parser.add_argument("--expected-app-certificate", required=True) parser.add_argument("--apk-output", type=Path, required=True) parser.add_argument("--metadata-output", type=Path, required=True) parser.add_argument("--poll-attempts", type=int, default=18) parser.add_argument("--poll-interval", type=int, default=10) return parser.parse_args() def main() -> int: args = parse_args() try: metadata = publish_bundle(args) except (KeyError, ValueError, RuntimeError, PlayApiError) as error: print(f"Play closed-testing publication failed: {error}", file=sys.stderr) return 1 args.metadata_output.parent.mkdir(parents=True, exist_ok=True) args.metadata_output.write_text( json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) print( f"Staged {args.release_name} ({args.version_code}) as a draft on " f"{args.track}" ) print(f"Downloaded Play-signed APK: {args.apk_output}") return 0 if __name__ == "__main__": raise SystemExit(main())