diff --git a/ci/main.go b/ci/main.go index e3089fc..9b3f462 100644 --- a/ci/main.go +++ b/ci/main.go @@ -739,7 +739,7 @@ func (m *Ci) UploadToPlayStore( From("python:3.12-alpine"). WithExec([]string{"apk", "add", "--no-cache", "curl"}). WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")). - WithExec([]string{"pip", "install", "requests", "google-auth"}). + WithExec([]string{"pip", "install", "google-api-python-client", "google-auth-httplib2", "httplib2"}). WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab). WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")). WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig). diff --git a/flake.nix b/flake.nix index 6c5c993..fe21e94 100644 --- a/flake.nix +++ b/flake.nix @@ -94,8 +94,9 @@ sqlite # python3 base + Google Play API client (for scripts/deploy_playstore.py) (python3.withPackages (ps: with ps; [ - google-auth - requests + google-api-python-client + google-auth-httplib2 + httplib2 ])) # used by stalwart-dev/start and deploy_playstore.py fgj # Codeberg/Forgejo CLI (like gh for GitHub) ]); diff --git a/scripts/deploy_playstore.py b/scripts/deploy_playstore.py index 409618e..0d00f0c 100755 --- a/scripts/deploy_playstore.py +++ b/scripts/deploy_playstore.py @@ -4,78 +4,17 @@ import json import os import sys -import time -import requests -from google.auth.transport.requests import AuthorizedSession +import google_auth_httplib2 +import httplib2 from google.oauth2 import service_account +from googleapiclient.discovery import build +from googleapiclient.http import MediaFileUpload PACKAGE_NAME = "de.sharedinbox.mua" AAB_PATH = "build/app/outputs/bundle/release/app-release.aab" TRACK = "internal" _TIMEOUT = 300 # seconds — AAB uploads can be large -_MAX_UPLOAD_ATTEMPTS = 3 -_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications" -_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications" - - -def _make_session(config_json: str) -> AuthorizedSession: - creds = service_account.Credentials.from_service_account_info( - json.loads(config_json), - scopes=["https://www.googleapis.com/auth/androidpublisher"], - ) - 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() - - last_exc = None - for attempt in range(_MAX_UPLOAD_ATTEMPTS): - try: - # 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"] - - upload_resp = session.put( - upload_url, - data=data, - headers={ - "Content-Type": "application/octet-stream", - "Content-Length": str(file_size), - }, - timeout=_TIMEOUT, - ) - 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() - return upload_resp.json()["versionCode"] - except requests.RequestException as exc: - last_exc = exc - if attempt < _MAX_UPLOAD_ATTEMPTS - 1: - delay = 10 * (2 ** attempt) - print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…") - time.sleep(delay) - - raise RuntimeError( - f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts" - ) from last_exc def main(): @@ -88,31 +27,37 @@ def main(): 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, + creds = service_account.Credentials.from_service_account_info( + json.loads(config_json), + scopes=["https://www.googleapis.com/auth/androidpublisher"], ) - edit_resp.raise_for_status() - edit_id = edit_resp.json()["id"] - version_code = _upload_aab(session, edit_id) + authorized_http = google_auth_httplib2.AuthorizedHttp( + creds, http=httplib2.Http(timeout=_TIMEOUT) + ) + service = build("androidpublisher", "v3", http=authorized_http) + + edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3) + edit_id = edit["id"] + + media = MediaFileUpload(AAB_PATH, mimetype="application/octet-stream", resumable=True) + bundle = ( + service.edits() + .bundles() + .upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media) + .execute(num_retries=3) + ) + version_code = bundle["versionCode"] print(f"Uploaded AAB, version code: {version_code}") - 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() + service.edits().tracks().update( + packageName=PACKAGE_NAME, + editId=edit_id, + track=TRACK, + body={"releases": [{"versionCodes": [version_code], "status": "completed"}]}, + ).execute(num_retries=3) - commit_resp = session.post( - f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit", - timeout=30, - ) - commit_resp.raise_for_status() + service.edits().commit(packageName=PACKAGE_NAME, editId=edit_id).execute(num_retries=3) print(f"Deployed version {version_code} to {TRACK} track") diff --git a/scripts/test_deploy_playstore.py b/scripts/test_deploy_playstore.py new file mode 100644 index 0000000..af583a6 --- /dev/null +++ b/scripts/test_deploy_playstore.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Tests for deploy_playstore.py.""" +import io +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +sys.path.insert(0, str(Path(__file__).parent)) + +import deploy_playstore + + +class TestMainEnvChecks(unittest.TestCase): + def test_missing_env_exits(self): + with patch.dict(os.environ, {}, clear=True): + with self.assertRaises(SystemExit) as ctx: + deploy_playstore.main() + self.assertEqual(ctx.exception.code, 1) + + def test_missing_aab_exits(self): + fake_config = '{"type": "service_account"}' + with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}): + with patch("deploy_playstore.os.path.exists", return_value=False): + with self.assertRaises(SystemExit) as ctx: + deploy_playstore.main() + self.assertEqual(ctx.exception.code, 1) + + +class TestMainHappyPath(unittest.TestCase): + def _run_main(self, fake_config): + mock_service = MagicMock() + mock_edits = mock_service.edits.return_value + mock_edits.insert.return_value.execute.return_value = {"id": "edit-42"} + mock_edits.bundles.return_value.upload.return_value.execute.return_value = { + "versionCode": 7 + } + mock_edits.tracks.return_value.update.return_value.execute.return_value = {} + mock_edits.commit.return_value.execute.return_value = {} + + with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}): + with patch("deploy_playstore.os.path.exists", return_value=True): + with patch("deploy_playstore.service_account.Credentials.from_service_account_info"): + with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp"): + with patch("deploy_playstore.build", return_value=mock_service): + with patch("deploy_playstore.MediaFileUpload"): + deploy_playstore.main() + + return mock_edits + + def test_insert_called_with_num_retries(self): + edits = self._run_main('{"type":"service_account"}') + edits.insert.return_value.execute.assert_called_once_with(num_retries=3) + + def test_bundle_upload_called_with_num_retries(self): + edits = self._run_main('{"type":"service_account"}') + edits.bundles.return_value.upload.return_value.execute.assert_called_once_with(num_retries=3) + + def test_tracks_update_called_with_num_retries(self): + edits = self._run_main('{"type":"service_account"}') + edits.tracks.return_value.update.return_value.execute.assert_called_once_with(num_retries=3) + + def test_commit_called_with_num_retries(self): + edits = self._run_main('{"type":"service_account"}') + edits.commit.return_value.execute.assert_called_once_with(num_retries=3) + + def test_authorized_http_uses_timeout(self): + fake_config = '{"type":"service_account"}' + with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": fake_config}): + with patch("deploy_playstore.os.path.exists", return_value=True): + with patch("deploy_playstore.service_account.Credentials.from_service_account_info"): + with patch("deploy_playstore.httplib2.Http") as mock_http_cls: + with patch("deploy_playstore.google_auth_httplib2.AuthorizedHttp") as mock_auth: + mock_service = MagicMock() + mock_edits = mock_service.edits.return_value + mock_edits.insert.return_value.execute.return_value = {"id": "e1"} + mock_edits.bundles.return_value.upload.return_value.execute.return_value = { + "versionCode": 1 + } + mock_edits.tracks.return_value.update.return_value.execute.return_value = {} + mock_edits.commit.return_value.execute.return_value = {} + with patch("deploy_playstore.build", return_value=mock_service): + with patch("deploy_playstore.MediaFileUpload"): + deploy_playstore.main() + + mock_http_cls.assert_called_once_with(timeout=deploy_playstore._TIMEOUT) + + +if __name__ == "__main__": + unittest.main()