2026-05-13 17:13:38 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Upload an Android App Bundle to the Google Play Store internal track."""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
2026-05-14 10:20:25 +02:00
|
|
|
import time
|
2026-05-13 17:13:38 +02:00
|
|
|
|
2026-05-14 12:12:56 +02:00
|
|
|
import requests
|
|
|
|
|
from google.auth.transport.requests import AuthorizedSession
|
2026-05-13 17:13:38 +02:00
|
|
|
from google.oauth2 import service_account
|
|
|
|
|
|
|
|
|
|
PACKAGE_NAME = "de.sharedinbox.mua"
|
|
|
|
|
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
|
|
|
|
TRACK = "internal"
|
2026-05-14 09:46:59 +02:00
|
|
|
_TIMEOUT = 300 # seconds — AAB uploads can be large
|
2026-05-14 10:20:25 +02:00
|
|
|
_MAX_UPLOAD_ATTEMPTS = 3
|
2026-05-14 12:12:56 +02:00
|
|
|
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
|
|
|
|
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
2026-05-14 10:20:25 +02:00
|
|
|
|
|
|
|
|
|
2026-05-14 12:12:56 +02:00
|
|
|
def _make_session(config_json: str) -> AuthorizedSession:
|
2026-05-13 17:13:38 +02:00
|
|
|
creds = service_account.Credentials.from_service_account_info(
|
|
|
|
|
json.loads(config_json),
|
|
|
|
|
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
|
|
|
|
)
|
2026-05-14 12:12:56 +02:00
|
|
|
return AuthorizedSession(creds)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
|
|
|
|
|
"""Resumable upload of the AAB. Returns the version code."""
|
|
|
|
|
file_size = os.path.getsize(AAB_PATH)
|
|
|
|
|
|
|
|
|
|
with open(AAB_PATH, "rb") as f:
|
|
|
|
|
data = f.read()
|
2026-05-13 17:13:38 +02:00
|
|
|
|
2026-05-14 10:20:25 +02:00
|
|
|
last_exc = None
|
|
|
|
|
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
|
|
|
|
try:
|
2026-05-18 05:49:55 +02:00
|
|
|
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
|
|
|
|
|
init_resp = session.post(
|
|
|
|
|
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
|
|
|
|
|
params={"uploadType": "resumable"},
|
|
|
|
|
headers={
|
|
|
|
|
"X-Upload-Content-Type": "application/octet-stream",
|
|
|
|
|
"X-Upload-Content-Length": str(file_size),
|
|
|
|
|
},
|
|
|
|
|
json={},
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
if not init_resp.ok:
|
|
|
|
|
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
|
|
|
|
|
init_resp.raise_for_status()
|
|
|
|
|
upload_url = init_resp.headers["Location"]
|
|
|
|
|
|
2026-05-14 12:12:56 +02:00
|
|
|
upload_resp = session.put(
|
|
|
|
|
upload_url,
|
|
|
|
|
data=data,
|
|
|
|
|
headers={
|
|
|
|
|
"Content-Type": "application/octet-stream",
|
|
|
|
|
"Content-Length": str(file_size),
|
|
|
|
|
},
|
|
|
|
|
timeout=_TIMEOUT,
|
2026-05-14 10:20:25 +02:00
|
|
|
)
|
2026-05-18 05:49:55 +02:00
|
|
|
if not upload_resp.ok:
|
|
|
|
|
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
|
|
|
|
|
upload_resp.raise_for_status()
|
2026-05-14 12:12:56 +02:00
|
|
|
return upload_resp.json()["versionCode"]
|
2026-05-18 05:06:42 +02:00
|
|
|
except requests.RequestException as exc:
|
2026-05-14 10:20:25 +02:00
|
|
|
last_exc = exc
|
|
|
|
|
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
|
|
|
|
delay = 10 * (2 ** attempt)
|
2026-05-18 05:49:55 +02:00
|
|
|
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
|
2026-05-14 10:20:25 +02:00
|
|
|
time.sleep(delay)
|
|
|
|
|
|
2026-05-14 12:12:56 +02:00
|
|
|
raise RuntimeError(
|
|
|
|
|
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
|
|
|
|
|
) from last_exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
config_json = os.environ.get("PLAY_STORE_CONFIG_JSON")
|
|
|
|
|
if not config_json:
|
|
|
|
|
print("Error: PLAY_STORE_CONFIG_JSON environment variable not set", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
if not os.path.exists(AAB_PATH):
|
|
|
|
|
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
session = _make_session(config_json)
|
|
|
|
|
|
|
|
|
|
edit_resp = session.post(
|
|
|
|
|
f"{_BASE}/{PACKAGE_NAME}/edits",
|
|
|
|
|
json={},
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
edit_resp.raise_for_status()
|
|
|
|
|
edit_id = edit_resp.json()["id"]
|
|
|
|
|
|
|
|
|
|
version_code = _upload_aab(session, edit_id)
|
2026-05-13 17:13:38 +02:00
|
|
|
print(f"Uploaded AAB, version code: {version_code}")
|
|
|
|
|
|
2026-05-14 12:12:56 +02:00
|
|
|
tracks_resp = session.put(
|
|
|
|
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
|
|
|
|
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
tracks_resp.raise_for_status()
|
2026-05-13 17:13:38 +02:00
|
|
|
|
2026-05-14 12:12:56 +02:00
|
|
|
commit_resp = session.post(
|
|
|
|
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
commit_resp.raise_for_status()
|
2026-05-13 17:13:38 +02:00
|
|
|
print(f"Deployed version {version_code} to {TRACK} track")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|