From b3c2714862226b044e3bc8989dd952f29fe803ec Mon Sep 17 00:00:00 2001 From: thepra Date: Fri, 23 Oct 2026 12:57:12 +0200 Subject: [PATCH] Google Play and App Store publishing as two Gitea actions play uploads an Android App Bundle to a track or moves a release between tracks; apple uploads an iOS build for TestFlight and submits a version for review. Each is one Python file over the store's own API, with openssl for the signed tokens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KizzWs2WQPWN2qgE7G6r8B --- README.md | 159 ++++++++++ apple/__pycache__/apple.cpython-314.pyc | Bin 0 -> 27656 bytes apple/action.yml | 57 ++++ apple/apple.py | 372 ++++++++++++++++++++++++ play/__pycache__/play.cpython-314.pyc | Bin 0 -> 15941 bytes play/action.yml | 45 +++ play/play.py | 215 ++++++++++++++ 7 files changed, 848 insertions(+) create mode 100644 README.md create mode 100644 apple/__pycache__/apple.cpython-314.pyc create mode 100644 apple/action.yml create mode 100644 apple/apple.py create mode 100644 play/__pycache__/play.cpython-314.pyc create mode 100644 play/action.yml create mode 100644 play/play.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef4e333 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# app-stores + +Two Gitea Actions that publish an app from any project's release workflow: `play` for Google Play and `apple` for +the App Store. Each is one Python file talking to the store's own web API: Google's Play Developer API and Apple's +App Store Connect API. Nothing else is needed on the runner beyond Python 3.9 or newer and `openssl`, which every +Linux and Mac runner has. No fastlane, no Ruby, no service in between. + +```yaml +- uses: https://git.thepra.dev/Arasaka/app-stores/play@v1 +- uses: https://git.thepra.dev/Arasaka/app-stores/apple@v1 +``` + +> Status: checked against fakes of both stores built from their published API definitions (Google's discovery +> document of 22 October 2026, Apple's OpenAPI 4.5.1), with every request body, query and signature verified. Not +> yet run against a real store account; the first project to use one is the first real run. + +## Google Play: `play` + +Two things it does, one per run: + +- **Upload**: given `aab`, it uploads the bundle and puts it on `track`. +- **Move**: given `from-track`, it takes the newest release already on that track and puts it on `track`, with + its notes unless `notes` is given. This promotes internal testing to production, raises a staged rollout + (`from-track: production`, `track: production`, a bigger `fraction`), completes it (`status: completed`) or halts + it (`status: halted`). + +| Input | | | +|---|---|---| +| `service-account` | required | the JSON key of the service account, from a secret | +| `package` | required | the application id, `software.arasaka.meweb` | +| `aab` | upload | the signed `.aab` | +| `from-track` | move | the track to take the release from | +| `track` | `internal` | `internal`, `alpha` (closed testing), `beta` (open testing), `production` or a custom closed track | +| `status` | `completed` | `completed`, `draft`, `inProgress` (staged, needs `fraction`) or `halted` | +| `fraction` | | the share of users of a staged rollout, between 0 and 1 | +| `notes` | | a folder of `.txt` files, `en-US.txt`, `it-IT.txt`, 500 characters each | +| `name` | | the release name in Play Console; Google uses the version name when empty | + +Every tag on the internal testing track: + +```yaml + - name: Google Play, internal testing + if: ${{ github.ref_type == 'tag' }} + uses: https://git.thepra.dev/Arasaka/app-stores/play@v1 + with: + service-account: ${{ secrets.PLAY_SERVICE_ACCOUNT }} + package: software.arasaka.meweb + aab: dist/android/software.arasaka.meweb-Signed.aab + notes: store/notes +``` + +Production by hand, from the Actions tab: + +```yaml +name: Google Play production + +on: + workflow_dispatch: + inputs: + fraction: + description: "Share of users, 0.1 for 10%; empty for everyone" + default: "0.1" + +jobs: + promote: + runs-on: nuvola + steps: + - uses: https://git.thepra.dev/Arasaka/app-stores/play@v1 + with: + service-account: ${{ secrets.PLAY_SERVICE_ACCOUNT }} + package: software.arasaka.meweb + from-track: internal + track: production + status: ${{ inputs.fraction && 'inProgress' || 'completed' }} + fraction: ${{ inputs.fraction }} +``` + +## App Store: `apple` + +- **Upload**: given `ipa`, it reads the version and build number from the app's `Info.plist`, uploads it, waits + until Apple has processed it (up to an hour and a half), writes what to test from `notes`, and gives it to the + TestFlight `groups`. A group outside the team (external testers) also sends the build to TestFlight review. +- **Submit**: with `submit: true`, it sends the build for App Store review: the uploaded one, or the newest + processed build of `version` (or exactly `build`) when no `ipa` is given. It reuses the version waiting to be + submitted, or creates it, links the build, writes what is new from `notes`, and submits. With `release: + after-approval` the version goes on sale when Apple approves it; with `manual` it waits for you in App Store + Connect. `phased: true` spreads the update over seven days. + +| Input | | | +|---|---|---| +| `issuer-id`, `key-id`, `private-key` | required | the team API key: two ids and the text of the `.p8` file, from secrets | +| `bundle-id` | required | the app's bundle identifier | +| `ipa` | upload | the signed `.ipa` | +| `notes` | | a folder of `.txt` files, `en-US.txt`, `it.txt`, 4,000 characters each | +| `groups` | | TestFlight groups by name, separated by commas or lines | +| `submit` | `false` | `true` sends the build for App Store review | +| `version`, `build` | submit | which build to submit when no `ipa` is given | +| `release` | `after-approval` | or `manual` | +| `phased` | `false` | `true` for a seven-day phased release | + +The build itself needs a Mac with Xcode; the upload does not. A Mac job can build, sign and run this step itself, +or hand the `.ipa` to a Linux job that runs it: + +```yaml + - name: TestFlight + uses: https://git.thepra.dev/Arasaka/app-stores/apple@v1 + with: + issuer-id: ${{ secrets.ASC_ISSUER_ID }} + key-id: ${{ secrets.ASC_KEY_ID }} + private-key: ${{ secrets.ASC_PRIVATE_KEY }} + bundle-id: software.arasaka.meweb + ipa: dist/ios/MeWeb.ipa + notes: store/notes + groups: Team +``` + +## Once per app, by hand + +Neither store lets an API create an app, so the first steps happen in their consoles. + +**Google Play** + +1. A Play Console developer account ($25 once). A personal account opened after 13 November 2023 must run a closed + test with at least 12 testers for 14 days before it can publish to production; an organisation account needs a + D-U-N-S number. +2. Create the app and fill in what Play Console asks before a first release: the store listing, the content rating + questionnaire, the target audience, data safety and a privacy policy address. +3. Upload the first `.aab` by hand, on internal testing. Play asks then which key signs the app for users (Play + App Signing). To keep a copy installed from a website and the Play copy updating each other, give Play the key + the website APK is already signed with, rather than letting Google generate a new one. The choice is permanent. +4. In Google Cloud, create a project, enable the **Google Play Android Developer API**, create a service account, + and make a JSON key for it. Store the whole file as a secret (`PLAY_SERVICE_ACCOUNT`). +5. In Play Console, Users and permissions, invite the service account's email address with release rights for + this app (testing tracks, and production if a workflow promotes to it). +6. Until the app's first release is published by hand in Play Console, Google accepts only `status: draft`. + +**App Store** + +1. The Apple Developer Program ($99 a year). +2. Register the bundle identifier (Certificates, Identifiers and Profiles) and create the app in App Store Connect. +3. Fill in the App Privacy answers, the age rating, the screenshots, the description and the support address for + the first version. +4. Users and Access, Integrations, App Store Connect API: generate a **team key** with the App Manager role. Store + the issuer id, the key id and the text of the `.p8` file (downloadable once) as three secrets. +5. Put `ITSAppUsesNonExemptEncryption` in the app's `Info.plist`. Without it every build waits for an export + compliance answer in App Store Connect, so the action refuses the upload. +6. Create the TestFlight groups. An external group needs the Beta App Review information (contact and notes) + filled in once. + +## Notes + +One file per language, the file name being the code: Google uses language and region (`en-US`, `it-IT`), Apple +its own list (`en-US`, `it`, `de-DE`). Empty files are skipped. Apple takes no release notes for an app's first +version; the action leaves them out and says so. + +## Versions + +`v1` moves forward with every change that keeps the inputs working; a change that breaks them becomes `v2`. Pin a +commit instead of `v1` to never move. diff --git a/apple/__pycache__/apple.cpython-314.pyc b/apple/__pycache__/apple.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99e88d523235d577cd55651cc5e42fec05004f5c GIT binary patch literal 27656 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.txt files, such as en-US.txt and it.txt; what to test on TestFlight, and what is new when submitting + required: false + groups: + description: TestFlight groups to give the uploaded build to, by name, separated by commas or lines + required: false + submit: + description: true sends the build for App Store review, creating or reusing the version + default: "false" + version: + description: The version to submit when no ipa is given, for example 1.4.0 + required: false + build: + description: The build number to submit when no ipa is given; the newest processed build of version when empty + required: false + release: + description: after-approval puts the version on sale as soon as Apple approves it; manual waits for you + default: after-approval + phased: + description: true releases the update to automatic updaters over seven days + default: "false" +runs: + using: composite + steps: + - shell: bash + run: python3 "$GITHUB_ACTION_PATH/apple.py" + env: + ASC_ISSUER_ID: ${{ inputs.issuer-id }} + ASC_KEY_ID: ${{ inputs.key-id }} + ASC_PRIVATE_KEY: ${{ inputs.private-key }} + APPLE_BUNDLE_ID: ${{ inputs.bundle-id }} + APPLE_IPA: ${{ inputs.ipa }} + APPLE_NOTES: ${{ inputs.notes }} + APPLE_GROUPS: ${{ inputs.groups }} + APPLE_SUBMIT: ${{ inputs.submit }} + APPLE_VERSION: ${{ inputs.version }} + APPLE_BUILD: ${{ inputs.build }} + APPLE_RELEASE: ${{ inputs.release }} + APPLE_PHASED: ${{ inputs.phased }} diff --git a/apple/apple.py b/apple/apple.py new file mode 100644 index 0000000..a26819a --- /dev/null +++ b/apple/apple.py @@ -0,0 +1,372 @@ +import base64 +import json +import os +import pathlib +import plistlib +import re +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile + +ROOT = "https://api.appstoreconnect.apple.com/v1/" +POLL = 30 +PROCESSING_LIMIT = 5400 +RETRY_AFTER = 5 +NOTES_LIMIT = 4000 +EDITABLE = "PREPARE_FOR_SUBMISSION,DEVELOPER_REJECTED,REJECTED,METADATA_REJECTED,INVALID_BINARY" +RELEASES = {"after-approval": "AFTER_APPROVAL", "manual": "MANUAL"} + + +def fail(message): + print(f"::error::{message}", flush=True) + sys.exit(1) + + +def setting(name, default=""): + return os.environ.get(name, "").strip() or default + + +def b64url(data): + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def explain(body): + text = body.decode(errors="replace") + try: + errors = json.loads(text)["errors"] + return "; ".join(f"{error.get('title', '')}: {error.get('detail', '')}".strip(": ") for error in errors) + except (ValueError, KeyError, TypeError, AttributeError): + return text[:500] + + +def send(method, url, body=None, headers=None): + where = f"{method} {urllib.parse.urlsplit(url).path}" + for attempt in (1, 2, 3): + try: + call = urllib.request.Request(url, data=body, method=method, headers=headers or {}) + with urllib.request.urlopen(call, timeout=900) as response: + return response.read() + except urllib.error.HTTPError as error: + if attempt < 3 and error.code in (429, 500, 502, 503, 504): + time.sleep(RETRY_AFTER * attempt) + continue + fail(f"{where} answered {error.code}: {explain(error.read())}") + except (urllib.error.URLError, OSError) as error: + if attempt < 3: + time.sleep(RETRY_AFTER * attempt) + continue + fail(f"{where} failed: {error}") + + +def es256(key, data): + with tempfile.TemporaryDirectory() as folder: + path = pathlib.Path(folder, "key.p8") + path.touch(0o600) + path.write_text(key) + result = subprocess.run(["openssl", "dgst", "-sha256", "-sign", str(path)], input=data, capture_output=True) + if result.returncode != 0: + fail("openssl could not sign with the API key: " + result.stderr.decode(errors="replace").strip()) + der = result.stdout + offset = 2 + halves = [] + while offset < len(der): + length = der[offset + 1] + halves.append(int.from_bytes(der[offset + 2:offset + 2 + length], "big").to_bytes(32, "big")) + offset += 2 + length + return b"".join(halves) + + +class Connect: + def __init__(self, issuer, key_id, key): + self.issuer = issuer + self.key_id = key_id + self.key = key + self.minted = 0 + self.token = "" + + def authorization(self): + now = int(time.time()) + if now - self.minted > 900: + header = b64url(json.dumps({"alg": "ES256", "kid": self.key_id, "typ": "JWT"}).encode()) + claims = b64url(json.dumps({"iss": self.issuer, "iat": now, "exp": now + 1140, "aud": "appstoreconnect-v1"}).encode()) + unsigned = f"{header}.{claims}" + self.token = f"{unsigned}.{b64url(es256(self.key, unsigned.encode()))}" + self.minted = now + return {"Authorization": f"Bearer {self.token}"} + + def call(self, method, path, payload=None, query=None): + headers = self.authorization() + body = None + if payload is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(payload).encode() + url = f"{ROOT}{path}" + (f"?{urllib.parse.urlencode(query)}" if query else "") + answer = send(method, url, body, headers) + return json.loads(answer) if answer else {} + + +def ref(kind, identifier): + return {"data": {"type": kind, "id": identifier}} + + +def notes(folder): + if not folder: + return [] + texts = [] + for file in sorted(pathlib.Path(folder).glob("*.txt")): + text = file.read_text(encoding="utf-8").strip() + if not text: + continue + if len(text) > NOTES_LIMIT: + fail(f"{file} has {len(text)} characters; App Store Connect takes at most {NOTES_LIMIT} per language") + texts.append((file.stem, text)) + if not texts: + fail(f"{folder} holds no .txt file with text in it") + return texts + + +def bundle_info(ipa, bundle_id): + with zipfile.ZipFile(ipa) as archive: + plists = [name for name in archive.namelist() if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", name)] + if len(plists) != 1: + fail(f"{ipa} does not look like an iOS app: no single Payload/.app/Info.plist in it") + info = plistlib.loads(archive.read(plists[0])) + if info.get("CFBundleIdentifier") != bundle_id: + fail(f"{ipa} is {info.get('CFBundleIdentifier')}, not {bundle_id}") + if not info.get("CFBundleShortVersionString") or not info.get("CFBundleVersion"): + fail(f"{ipa} carries no CFBundleShortVersionString or CFBundleVersion") + if "ITSAppUsesNonExemptEncryption" not in info: + fail("the app's Info.plist has no ITSAppUsesNonExemptEncryption key, so every build would wait for an export compliance answer in App Store Connect; add the key to the app's Info.plist") + return info["CFBundleShortVersionString"], info["CFBundleVersion"] + + +def app_id(connect, bundle_id): + apps = connect.call("GET", "apps", query={"filter[bundleId]": bundle_id, "limit": "200"})["data"] + for app in apps: + if app["attributes"]["bundleId"] == bundle_id: + return app["id"] + fail(f"App Store Connect has no app {bundle_id}; create its record there first") + + +def upload(connect, app, ipa, version, build): + created = connect.call("POST", "buildUploads", {"data": { + "type": "buildUploads", + "attributes": {"cfBundleShortVersionString": version, "cfBundleVersion": build, "platform": "IOS"}, + "relationships": {"app": ref("apps", app)}, + }})["data"]["id"] + reserved = connect.call("POST", "buildUploadFiles", {"data": { + "type": "buildUploadFiles", + "attributes": {"assetType": "ASSET", "fileName": ipa.name, "fileSize": ipa.stat().st_size, "uti": "com.apple.ipa"}, + "relationships": {"buildUpload": ref("buildUploads", created)}, + }})["data"] + operations = reserved["attributes"].get("uploadOperations") or [] + if not operations: + fail("App Store Connect gave no upload operations for the file") + with ipa.open("rb") as content: + for operation in operations: + content.seek(operation["offset"]) + part = content.read(operation["length"]) + headers = {header["name"]: header["value"] for header in operation.get("requestHeaders") or []} + send(operation.get("method") or "PUT", operation["url"], part, headers) + connect.call("PATCH", f"buildUploadFiles/{reserved['id']}", {"data": { + "type": "buildUploadFiles", + "id": reserved["id"], + "attributes": {"uploaded": True}, + }}) + print(f"{ipa.name} uploaded in {len(operations)} parts; App Store Connect is processing it") + return created + + +def processed(connect, upload_id): + deadline = time.time() + PROCESSING_LIMIT + build = None + seen = None + while time.time() < deadline: + if not build: + upload_state = connect.call("GET", f"buildUploads/{upload_id}", query={"include": "build"})["data"] + state = upload_state["attributes"].get("state") or {} + if state.get("state") == "FAILED": + problems = "; ".join(f"{error.get('code')}: {error.get('description')}" for error in state.get("errors") or []) + fail(f"App Store Connect refused the build: {problems}") + build = ((upload_state.get("relationships") or {}).get("build") or {}).get("data") + current = state.get("state") + if build: + current = connect.call("GET", f"builds/{build['id']}")["data"]["attributes"]["processingState"] + if current == "VALID": + print("the build is processed") + return build["id"] + if current in ("FAILED", "INVALID"): + fail(f"App Store Connect could not process the build: {current}") + if current != seen: + print(f"waiting: {current}", flush=True) + seen = current + time.sleep(POLL) + fail(f"the build was not processed within {PROCESSING_LIMIT // 60} minutes; it may still appear in App Store Connect") + + +def existing_build(connect, app, version, build): + query = { + "filter[app]": app, + "filter[preReleaseVersion.version]": version, + "filter[preReleaseVersion.platform]": "IOS", + "filter[processingState]": "VALID", + "sort": "-uploadedDate", + "limit": "1", + } + if build: + query["filter[version]"] = build + builds = connect.call("GET", "builds", query=query)["data"] + if not builds: + fail(f"App Store Connect has no processed build of version {version}" + (f" ({build})" if build else "")) + return builds[0]["id"] + + +def tester_notes(connect, build, texts): + current = connect.call("GET", f"builds/{build}/betaBuildLocalizations", query={"limit": "50"})["data"] + known = {localization["attributes"]["locale"]: localization["id"] for localization in current} + for locale, text in texts: + if locale in known: + connect.call("PATCH", f"betaBuildLocalizations/{known[locale]}", {"data": { + "type": "betaBuildLocalizations", "id": known[locale], "attributes": {"whatsNew": text}, + }}) + else: + connect.call("POST", "betaBuildLocalizations", {"data": { + "type": "betaBuildLocalizations", + "attributes": {"locale": locale, "whatsNew": text}, + "relationships": {"build": ref("builds", build)}, + }}) + print(f"what to test: {', '.join(locale for locale, _ in texts)}") + + +def beta_groups(connect, app, names): + groups = {group["attributes"]["name"]: group for group in connect.call("GET", f"apps/{app}/betaGroups", query={"limit": "200"})["data"]} + missing = [name for name in names if name not in groups] + if missing: + fail(f"no TestFlight group named {', '.join(missing)}; the app has {', '.join(groups) or 'none'}") + return [groups[name] for name in names] + + +def testers(connect, build, groups): + external = False + for group in groups: + attributes = group["attributes"] + if attributes.get("isInternalGroup") and attributes.get("hasAccessToAllBuilds"): + print(f"{attributes['name']} already gets every build") + continue + connect.call("POST", f"betaGroups/{group['id']}/relationships/builds", {"data": [{"type": "builds", "id": build}]}) + external = external or not attributes.get("isInternalGroup") + print(f"added to {attributes['name']}") + if external: + connect.call("POST", "betaAppReviewSubmissions", {"data": { + "type": "betaAppReviewSubmissions", "relationships": {"build": ref("builds", build)}, + }}) + print("sent to TestFlight review, which external testers wait for") + + +def submit(connect, app, build, version, texts, release, phased): + attributes = {"versionString": version, "releaseType": release} + editable = connect.call("GET", f"apps/{app}/appStoreVersions", query={ + "filter[platform]": "IOS", "filter[appVersionState]": EDITABLE, "limit": "1", + })["data"] + if editable: + version_id = editable[0]["id"] + connect.call("PATCH", f"appStoreVersions/{version_id}", {"data": { + "type": "appStoreVersions", "id": version_id, "attributes": attributes, + "relationships": {"build": ref("builds", build)}, + }}) + else: + version_id = connect.call("POST", "appStoreVersions", {"data": { + "type": "appStoreVersions", + "attributes": dict(attributes, platform="IOS"), + "relationships": {"app": ref("apps", app), "build": ref("builds", build)}, + }})["data"]["id"] + if texts: + earlier = connect.call("GET", f"apps/{app}/appStoreVersions", query={"filter[platform]": "IOS", "limit": "2"})["data"] + if len(earlier) < 2: + print("::notice::the first version of an app takes no release notes; they were left out") + else: + current = connect.call("GET", f"appStoreVersions/{version_id}/appStoreVersionLocalizations", query={"limit": "50"})["data"] + known = {localization["attributes"]["locale"]: localization["id"] for localization in current} + for locale, text in texts: + if locale not in known: + fail(f"version {version} has no {locale} page; add the language in App Store Connect, or remove {locale}.txt") + connect.call("PATCH", f"appStoreVersionLocalizations/{known[locale]}", {"data": { + "type": "appStoreVersionLocalizations", "id": known[locale], "attributes": {"whatsNew": text}, + }}) + if phased: + linked = connect.call("GET", f"appStoreVersions/{version_id}", query={"include": "appStoreVersionPhasedRelease"})["data"] + if not ((linked.get("relationships") or {}).get("appStoreVersionPhasedRelease") or {}).get("data"): + connect.call("POST", "appStoreVersionPhasedReleases", {"data": { + "type": "appStoreVersionPhasedReleases", + "attributes": {"phasedReleaseState": "INACTIVE"}, + "relationships": {"appStoreVersion": ref("appStoreVersions", version_id)}, + }}) + waiting = connect.call("GET", f"apps/{app}/reviewSubmissions", query={ + "filter[platform]": "IOS", "filter[state]": "READY_FOR_REVIEW", "limit": "1", + }) + if waiting["data"]: + submission = waiting["data"][0]["id"] + items = connect.call("GET", f"reviewSubmissions/{submission}/items", query={"include": "appStoreVersion", "limit": "50"})["data"] + listed = any((((item.get("relationships") or {}).get("appStoreVersion") or {}).get("data") or {}).get("id") == version_id for item in items) + else: + submission = connect.call("POST", "reviewSubmissions", {"data": { + "type": "reviewSubmissions", "attributes": {"platform": "IOS"}, "relationships": {"app": ref("apps", app)}, + }})["data"]["id"] + listed = False + if not listed: + connect.call("POST", "reviewSubmissionItems", {"data": { + "type": "reviewSubmissionItems", + "relationships": {"reviewSubmission": ref("reviewSubmissions", submission), "appStoreVersion": ref("appStoreVersions", version_id)}, + }}) + connect.call("PATCH", f"reviewSubmissions/{submission}", {"data": { + "type": "reviewSubmissions", "id": submission, "attributes": {"submitted": True}, + }}) + after = "goes live when Apple approves it" if release == "AFTER_APPROVAL" else "waits for you to release it once Apple approves it" + print(f"version {version} is submitted for review and {after}" + (", over seven days" if phased else "")) + + +def main(): + issuer = setting("ASC_ISSUER_ID") or fail("issuer-id is required (App Store Connect, Users and Access, Integrations)") + key_id = setting("ASC_KEY_ID") or fail("key-id is required") + key = setting("ASC_PRIVATE_KEY") or fail("private-key is required: the text of the .p8 file") + bundle_id = setting("APPLE_BUNDLE_ID") or fail("bundle-id is required") + ipa = setting("APPLE_IPA") + version = setting("APPLE_VERSION") + build = setting("APPLE_BUILD") + group_names = [name.strip() for name in re.split(r"[,\n]", setting("APPLE_GROUPS")) if name.strip()] + wants_submit = setting("APPLE_SUBMIT", "false") == "true" + release = RELEASES.get(setting("APPLE_RELEASE", "after-approval")) or fail("release must be after-approval or manual") + phased = setting("APPLE_PHASED", "false") == "true" + texts = notes(setting("APPLE_NOTES")) + if not ipa and not wants_submit: + fail("give ipa to upload a build, or submit: true with version to send a build for review") + if not ipa and not version: + fail("submitting without ipa needs version, and build if not the newest of that version") + + if ipa: + path = pathlib.Path(ipa) + if not path.is_file(): + fail(f"{ipa} is not a file") + version, build = bundle_info(path, bundle_id) + connect = Connect(issuer, key_id, key) + app = app_id(connect, bundle_id) + groups = beta_groups(connect, app, group_names) if group_names else [] + if ipa: + build_id = processed(connect, upload(connect, app, path, version, build)) + if texts: + tester_notes(connect, build_id, texts) + if groups: + testers(connect, build_id, groups) + else: + build_id = existing_build(connect, app, version, build) + if wants_submit: + submit(connect, app, build_id, version, texts, release, phased) + + +if __name__ == "__main__": + main() diff --git a/play/__pycache__/play.cpython-314.pyc b/play/__pycache__/play.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d8afbf2a76238ad650f6518fd409116381768e4 GIT binary patch literal 15941 zcmb_@dvF`~o!H2SEr$SzA-&XC(I-_QQAz;iQ9{At~Xg4RKTp+I&-;c>;Gg+Q~8q4 zwV&T_u?vu9>|Uo!;pYKE)cV^kL=)p9j>QpeSz+``qNtmn3%Y~boqHa^qD zH5|CeFcGZ3o_VIRnc0(vNE0B?LVy0|k4W8c`jII~N{T&QUEz2{NJJya)L1MkPVqwL zWFj#cr73;=LUeM9OeP^<4gdxLSy?y+NJa=u`8Zj7B;Chf z<%R3o^y54qK~o><$gF(hi3snTLQg#DB^<|?T3c+#7?0lhbp2FVU6bA`nQ>A`T;|86 z^izC1Jf;G|cbwvgx&?hI0{M<%0ZvNH&SRxhC`W^?u=!Y3s zCj9PmC}x>a{Tasu85gtL$Qd}}{ya@IF{Ak_h8bb?KJ<%b=8|Dgz6Vf&sfF>gX?sr( zF9->trzhYyDCUV+N}L+@n-p_Wh{h$wBwiO4mcJI2@YFiZiz2W{B%QP=-Ca|OX};@f z_pV467me`3hzcFC3)|8mN&utSl>}zeqBD72VJE`TnBc_QTksPbQOq)HtbJ}MGnsi( zb~G(WciEk5to5rWUOqAR#GjtJj~2|hzAD_1by0{2xBx^@s>euiE3J;eZ^IE}I7Z_l zBi0QMajWr(UPF8S;UvbZXPTG*_{KX11yECr2~n}~KyD!sS4@+B z)4%NWXPrB8&V90T-{OqS?h{;Sybe1E94IWNXg8k^i)LVqw)-QSzy(mrhq@9)poCkDo)t-%bwX4Ee!^2YMyy-lfGIcU3s81~|7cK4#RWRg7vn1T#udkRGEY+SQ}1 zg^6GWDur<7^=oWkPD@dy*VMp74Jl(2lfulBrYPgLDOQ08a~S7AQWnaBM5TeiNcbl#; zv*sp-0E+;LGqx~$O;Lm2ls5VvQ4Id!cNkEwv9nuYr@;AC!u6@BYCkl|$N6hX;fPX) zQ!E^fcOHr*#=|l3NT>d+8-t5AC_cjYa_zw%?p@q7e{^1&pIGSo&dwk0{YR0^^JQ

7n@X1Y9)5KNno9C_xHUzk7;i|{3J z5ydRC>h#>~z0o@#o$JjyADKON&&ZnV)=DdGIbL(j@5z?>=FF>}iksJOT$gKiW<4D_ z&q>*Ha;b7@<}VxHwcp)!a*lmqVYY35`_!AK-a515I{N0BS1)HGOMxGb-VDqI-aIoq zu|*q|NbS}dFTOSFxRbmyqRO) zNWa3~GvV%k{myk%Y{6t-?>O4b{M=YZ%I%JRr{(7dEhvA{+}dw5y=OI`953d41e+<( zcpoL<89Ci4g+S4F{yODQV_xrOe=$fsht(9lgbfPGfo%GeX4UhU3ZdCrw5C z1+yj8)lgG2wBL9HtsFR%rakl;LZHx;DWr3Tq%CM0(doSKHfq6XHXa(k zdK7)6j2uh;_7Lde0NsQ3TU5eh33u7S=+YJZte%PDc>~&!zf=E`$#V>FA+-aPBZNLS z93vGQDsT&KG)H<&+5@aMM&xl)6R=z|9Y8M;U^wpVBSZ{uI5;~&{|NBEo*5;yOplQw zF0R!zGoyrt=@GE(EW#^|qmmVIY|{Ri{nYA-} zm|43nj5%O9)x+e+j~c!Jj$5@63+GY7H|X)G5ikvW2Bu(lMKA?x`Tq$q%>LV-ftfX6 zC)Bj*T9*oI)#Q!ep%{FAlhA-wD3)nnno2|z>lADSVhH>T;3m;&rRX%DNJ+zK2kZcG z28K<1U@XLi!{|?8i8Z5gZTpEpU_=!G zELE#mGF#}HF`iE;*1Wa1 zj=Xv#=k+gp{fjN{R^^_!B;$YdlI-OJ ztmpRBYIRetx_!C2eX-^T4evC3uL%P@G%@A9czWN;R5U!a;0CSbvYtQugQxQ53pZZK zdA2Qkwmo22`)R|6o{IVEg8}4$TC?UV z%?xDr&u?Ei|MpXFKDBUq$@t^_xA*>4&rf^){h{omC*<8ja@on8>r~ct>JMwBRkvKP zx^ku4mP@z2H6wev$Y>qU__@;`%+SiIk3}<89{m{C$Rj9p?Yoy$9ug+R=upzb-eB z(%XH!+5AgKDJh%Hs99%Q5y?YP`u^Y3tt{dh1oJl>1&_)mlEXNfmrLN=o5)1iBF2Ih zmNW%TBSgX5aB#X@ZDP=36S-KhXkh#5y6I*z3;rX>I>;pRs4lyLIw$Eg6K$EZ?kB6q zk!0bu!e2ptU1T?tKbM#TGmsX&l=tbnHfImAo5_}uTDAA$l6b~6i)4ft#TyPysupYK z1^$O|bQx55@r&f^=}A=|_q)>8EBy7&BtIRU#f~dh!h}RI23u`Xl+xA?aVorf@4mFT zLyS(w15l66(ReZ?DJA3Kq?8i)5Ev)!htvD>Z^E~dibbd|h`gdYZ#MURVB{X3;KQ_q zW)yypVk7)#0xVZ458x^xgoW$Jq5}La39u;lgH3~6Q4&o<51c3pr*~@bF*7xnLIeXiRy=}2=%~N&rg;!pf zA6yv9dOGLWRh#Rk`)>%lv*B2Wrt?&x@T5w*H)cnHxJ)9EVDHaJ?ID%{Jlu1=3n~5H?ePL8wq`39)wRruAl|uMvhm zg7Kd*0t+Y5Iztsom(D&oE1uel%~%k$fpg&JEGEM)UFrJ2nx83XgmUK!ny5j*23c}f zG-m>9dSG;S@#rA(O<144{yh9OIH=}Ek!xWywOMCX+L%zDN;nT!8Z;N6p0tcsYtOl| zjW7vXK5tfBIp^&%?8yrO>aw_2wC}oNt@Y0^5D7MI4O~U>`r&e_R7~O6q+;gIfgLF( z>3UKzoxBiGOz<-+uJKruk4qtb8tNK!qjB&ev`uT|dZN5E(F6T0JT3Mlh-bftw&)-&dM?jM9bWx-y?zx%Fi+4S3t2WQ;mFn&DcRelSN~A?5@Qw66r^RQv&sZw0 z4_)AYFJ1l0wfk;MN!je+y%NS-o2i*Eo8LG8i0o;ZJ+bQDlG#4r_r}CqfyMTvj=S3* zfA^7JcKy67d-Pm(TOiwbUTz4=r59$0;A^~j=*FR(vw7Lsyl_a~bxD5qiadQ)-a3KOxDFr?MqiUphY9w+8n}&nrEdNY=S! ze)z6)hs^F!y^{cterQbc{ytQN!AhTDB)Fy!cN>#TRZIdb4^b-?I*5rzzswwqdkbfl zz>FjtaaQ@QkbH2akLPt1+BadUxVPP(#lL}U|X0sTD#`LJimZ33A z%)cHOaRy>AX%3qB?eyLhgh!_u*t}I+aSJ?H1Z#vZp^Xa2bZHS%bP?~U@FE_9 zjj@sFxFlpqOTrR`mq_t4DK4N;SnllE0L2NF@7aXc$TMR0swy0i<2v!PQqxy8g1<&y zw5UeENIY?xeM=EXAU2m`r#M3_;z`_!WHM0k%BwO`uG+s`?a!8PpR=x3)h}39s@iUd zi^rhfIm_R7HmsUUs#td8=>ryw)>6nf12iydBHlj;yx}W^F~yeAQdW7WOT+ z{=ol^e{suFU};cp?!6tzR-C$FpEG>8ZO7N8o9S25^KDsAvut~GsT_sMTL^}02l4FT z-gf`{6yFf1(5YN&Qiy78hJi^_K@Gv)>*`tI?MDspHBz-~RJYR#)>D`9sDXveRKO?B zRE$p?)UC}iii6!nJYKtDdj;8bcooDtbW5!;6WC<=)oo&RT8n0251Khgu^ItyrxS{$ zYx5P5+=TskZbsFWDX2sED_DS|x+1li*Rh$osL$*R?XR}H{Z*Q-yx4J`#@IX7sSP#7 zRFD{#cnRTDxP_+E)t`CcG#{UorYH@it!lu&OEGk%8@kkBz4(|~N%WD!#EzLJk!y-Z zicuI-Oe5z5itRLl8AM5;rAow;!RZk_q{#&6k#s~|U+6Y0ASXhgq-h;jRVm@m(3h%= zstF9J#+heP) zZEuN-d;a|TJ+rCPK_ARnAJ~|xy7{)Z0taK-~7;tTJ))!X+J;G-fuF$XL9y? z?S7*QbpmEw6XRhRToS^Lgr6bo9HG#3B9e-cYgZ`rTq+#PKkB04Dxg`hKvBH_d1d@L3U!F%d1s>ExAG#^*F=Ql{F zq!`0bB>wYHzluHi#Aq~+8XnlJ<~&(cNEC8CxM_Km}wc zghwYLd<6kpWR>bd2=C{QbpddzR$Dk10^k~pLCEGMID<&&S7&=g?MdoIo*8|fEkxW~ znuyE?Y(Wg8Zmp*RBP|3~4`5FsTd6qJnIN?vO`E+37Rnah#hrP#MOy=0XNJwf%bW$P zs?g?8ID-)vS>6c7d_|3y1`FvBKsJc^!n{b>%(h!s4#|iR>-MyHd#7|wN}E&CM8^Rj zi;bEu$bU$ik+>xK((S(SDVSK`Z-UsXCBqO}_DSI@NGS>zv7KOr2 z#u8(Sop|1Gd$mf74p-5R=7-hFe9^yM*x-# zNvB1Fy~KJ_)lhdng)2nc3I74_5L-(85sF#nLuc8lr)MEHXfcyCMnOiTs_QJwo zwt7ddx_i00d#O5Geel<%Jy0(^-kUGpcyVE7?!~O9E9cp}?Ae?3?4M&l^mu>cs#tTD z%t>E6`1dCv#D}|ghy?LqzzF73SJKc(V$w9z%6G;A~bkf&;wOg zrDEiC-;<$p16**Ze<0M?-+%V}a9~&vu^b_dBK<@@d#pptpPNpBGLS|{ysI-H0N!`< z+}U9tycsImCnkJhji%HDvwRO)2A*)0^xL9kG*U0e?u|)}s#n6U>fB&k49#5FoTL`8f&uLlqfNhJ{nGM(}nP|zLz1oc!l&;bGE+kY+XG5gH!LEx?PgndHnsI$M4Mi z>iNHUUVeHicQ*R|*(i03KD%NIud>b@TeZwqWuBK?hgR5=P|#Rf^6d8|MZj-Eo5L}G0K;q+c3 z4LHNNs7LZ0xt@e+RnMF;AYef#h92tz9pIh78l7SaSaS)o4p`HnV~tTDtKlw-U!LsBEVX>#SRoiu%tzZRZO0%^KCcadzr3D`yw-B!AR zaP`}?ZWZ%Yl9RG7A;yfz0Nh`9Fg{hz`f#D{TlrdPWoGJIo;foV9G818_07YX=e~KE z?#9^_+X&?zpFXhXfnUB_TK0;W0KZ&Sa$a`sV7}+>HT^q7?|QiF{DtI;&8~ww#~?M> zpd~Uu_88ag7%^ywpa1?-8{Q=Efs)B?Za4}%Qj~Uqou|}m>@<8feF~$lpD)3aNAbIV z0EM2D1e3T}V=1_3^4Uqi5SpC?-a)gI48x$GS@ZxU4@n7(a0*57RtRtoFv^ZNKZB5R zQBqPYJUtc(BRlCcX-a=VdLSNHnx@npTRwi}w)A7~Qs5`GOSReKSMq5}HFXBXl~0-N zM+%>)7`lNLrs?oCnyN%%=Xse%X8 zp9^P?kL8YEk?}uyS)L#l+3wg%M~n&^z#)ovaQYQJkq9+SiWZB9x2S^(D`@i;ej@Qf zkfg$rxogV}u@&dE%uat+c#-fnCF-Lh-3gdR>u&eO>59_o2XxQ>fJ~(4-}3DhQr~U{HBwvcPhXlfRi#TX;(o zcEoq6T|=muxDJvbQ?RsQx91rkjfJX0voMZRdy01p`8aATU1%5jfX~ZS>`4gfL6)Ks zb(m0Z*~SswMI)LuVXo%<6qMvY;+xV(+e~HFa6#*H5~=|dVSTMZlGjsG46RW;&{DKZ z9&LWw8M#~VoTLtv(X)NYtWhmk0{X}4Z79`%d|CP{*81MO~tuk7Y=bgbYDz~z}|Z1!fCFV)E8mtR;?#(&Jh*c3*1I; zfjMlUy%s#dYOgPF^|XF-JZ{nVlK7Qbt`#{UH9;3Jz*z9Q{SiDU&A+=1dz4p|*6|mr zaB*$$n76wb?vdRl@D+cuvi!D*LPI$x?;!lEa3J+afZ2E*y6C!_dgh{s1hcsU#%+lj8U4|l>o z)kV0I3^XAmraL64x>snE=>%R2>mLT;J095ga2e#k1tQ(?=@L>!K6wgLn(zY%-8?j5sl3Nx-`qE84T3PT& zXkTt&Rb3Y zqp;lw9@EW6N+kgWU5fDcxbl0c1*e*ZB&ej=&QaVPI42OHNz&01F={Oc1)qyGD#8&7 zZwc{*Q14&C2`N}e9f6g=lY&~7E4cEPQEO887ODMjr1%{v{+bkjfr9vUQUVOppOG6j z31^Z(^gz`gZBu>3a4^fHj_eyE;Zq4DGE(Id8m)@))DDF2X8oaha#-XU#D$OX?jnAo z4E_TJv!o$g(lmQ~t)wy&&z5YxZ(p`;|Gu(cz-w_aGWworrY@`~-shZth=R_wLd!oFEW}Wr(fxFJFv&Yb>p7xaWw7_eH zAXx9Kz4KDmyM4~KW^-pMZq>e8n{mop_AX7`{?grjXXL%ZatSpNz0Gg+FWQzG?mqHZ zw)JtjrB5zDdc*o#baC+2gYc_|7ukiYix-zBmM+SzN3!0>a^C)BZ~wa|v);2g?|B*j z=g!aBKJ-)*57?f0OXix~bVM$DY{m6BvPtJ{*|O$2%Wt|aWXrECcU@RC<(}r3pXTL> zXzuCDE8dpOqx0f-obP*E@Nmv5mtVnr<-J2^vaM;&S~B;1&bsA&>z1{;=7k;Ey3RRk z#&W~+q1CmzwVk$EWo=&_dTD5m&pbC@F>jflT1YKS%9Wi<)wj33TX$#j&XaQYg%$Rq z)^1|nFyA^~H9sR)wXd*`e7Ngi#+;d&7c-X@4lb8;+!law_^j^CeCv1p-}WOAF?_eR zH(Pf|u04D^@NV0?HFs)c?t=WpMfu_-IW!?(nv@+=zhUjEMQA()>8NvXbH% zQk+HsK1MU8JCSFX5NL>BuTpcTOY#l!Dbs3b=_};@k4W)TQVf$~Hz^3~QJE0YPiZ!^ z8j*^TTjFk2*$ibk?~&U(q#%5S(zEbAazzv#H6lT*LdAASjiVe99^#gmI^y4=fXl*Q z_<(VLz&Jl(>>n^CA24MfFpdux53TWjz~H~~x7J;=NciiSHLp4k&e~Ryui{)S@yt4Z z%XZ#lm|Z7~t8BGgJHE;`;hK^6#Y=+uTs!9wyA8xHPZ2g!)@kx){Q1*$>X7GMeWyMv6!4StQI}Dc% b_u#BzU32LbQ|)h9+n)}8Y+~3ty4e2%1~@gL literal 0 HcmV?d00001 diff --git a/play/action.yml b/play/action.yml new file mode 100644 index 0000000..fc5de36 --- /dev/null +++ b/play/action.yml @@ -0,0 +1,45 @@ +name: Google Play +description: Uploads an Android App Bundle to a Google Play track, or moves a release from one track to another +inputs: + service-account: + description: The JSON key file of the Google Cloud service account invited in Play Console, from a secret + required: true + package: + description: The application id, for example software.arasaka.meweb + required: true + aab: + description: The signed .aab to upload; leave empty when from-track is given + required: false + from-track: + description: Take the newest release on this track instead of uploading, to promote it to track + required: false + track: + description: internal, alpha (closed testing), beta (open testing), production, or a custom closed track + default: internal + status: + description: completed, draft, inProgress (a staged rollout, with fraction) or halted + default: completed + fraction: + description: The share of users a staged rollout reaches, between 0 and 1 + required: false + notes: + description: A folder of .txt release notes, such as en-US.txt and it-IT.txt, 500 characters each at most + required: false + name: + description: The release name shown in Play Console; Google uses the version name when empty + required: false +runs: + using: composite + steps: + - shell: bash + run: python3 "$GITHUB_ACTION_PATH/play.py" + env: + PLAY_SERVICE_ACCOUNT: ${{ inputs.service-account }} + PLAY_PACKAGE: ${{ inputs.package }} + PLAY_AAB: ${{ inputs.aab }} + PLAY_FROM_TRACK: ${{ inputs.from-track }} + PLAY_TRACK: ${{ inputs.track }} + PLAY_STATUS: ${{ inputs.status }} + PLAY_FRACTION: ${{ inputs.fraction }} + PLAY_NOTES: ${{ inputs.notes }} + PLAY_NAME: ${{ inputs.name }} diff --git a/play/play.py b/play/play.py new file mode 100644 index 0000000..e453113 --- /dev/null +++ b/play/play.py @@ -0,0 +1,215 @@ +import base64 +import json +import os +import pathlib +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + +ROOT = "https://androidpublisher.googleapis.com/" +SCOPE = "https://www.googleapis.com/auth/androidpublisher" +STATUSES = ("draft", "inProgress", "halted", "completed") +NOTES_LIMIT = 500 +RETRY_AFTER = 5 +HINTS = { + "status draft may be created on draft app": "until the app's first release is published by hand in Play Console, use status: draft", + "Package not found": "create the app in Play Console and upload its first bundle there by hand", + "does not have permission": "invite the service account in Play Console, Users and permissions, with release rights for this app", + "already been used": "every upload needs a version code higher than any uploaded before", + "has not been used in project": "enable the Google Play Android Developer API in the service account's Google Cloud project", +} + + +def fail(message): + print(f"::error::{message}", flush=True) + sys.exit(1) + + +def setting(name, default=""): + return os.environ.get(name, "").strip() or default + + +def b64url(data): + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def explain(body): + text = body.decode(errors="replace") + try: + error = json.loads(text) + text = error.get("error_description") or error["error"]["message"] + except (ValueError, KeyError, TypeError, AttributeError): + text = text[:500] + hints = [hint for clue, hint in HINTS.items() if clue in text] + return text + "".join(f" ({hint})" for hint in hints) + + +def send(method, url, body=None, headers=None, retry=True): + where = f"{method} {urllib.parse.urlsplit(url).path}" + for attempt in (1, 2, 3): + try: + call = urllib.request.Request(url, data=body, method=method, headers=headers or {}) + with urllib.request.urlopen(call, timeout=900) as response: + return response.headers, response.read() + except urllib.error.HTTPError as error: + if retry and attempt < 3 and error.code in (429, 500, 502, 503, 504): + time.sleep(RETRY_AFTER * attempt) + continue + fail(f"{where} answered {error.code}: {explain(error.read())}") + except (urllib.error.URLError, OSError) as error: + if retry and attempt < 3: + time.sleep(RETRY_AFTER * attempt) + continue + fail(f"{where} failed: {error}") + + +def sign(key, data): + with tempfile.TemporaryDirectory() as folder: + path = pathlib.Path(folder, "key.pem") + path.touch(0o600) + path.write_text(key) + result = subprocess.run(["openssl", "dgst", "-sha256", "-sign", str(path)], input=data, capture_output=True) + if result.returncode != 0: + fail("openssl could not sign with the service account's key: " + result.stderr.decode(errors="replace").strip()) + return result.stdout + + +def access_token(account): + now = int(time.time()) + header = b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode()) + claims = b64url(json.dumps({ + "iss": account["client_email"], + "scope": SCOPE, + "aud": account["token_uri"], + "iat": now, + "exp": now + 3600, + }).encode()) + unsigned = f"{header}.{claims}" + assertion = f"{unsigned}.{b64url(sign(account['private_key'], unsigned.encode()))}" + form = urllib.parse.urlencode({"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion}) + _, body = send("POST", account["token_uri"], form.encode(), {"Content-Type": "application/x-www-form-urlencoded"}) + return json.loads(body)["access_token"] + + +class Play: + def __init__(self, account, package): + self.authorization = {"Authorization": f"Bearer {access_token(account)}"} + self.path = f"androidpublisher/v3/applications/{urllib.parse.quote(package)}/edits" + + def call(self, method, path="", payload=None): + headers = dict(self.authorization) + body = b"" if method == "POST" else None + if payload is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(payload).encode() + _, answer = send(method, f"{ROOT}{self.path}{path}", body, headers) + return json.loads(answer) if answer else {} + + def upload(self, edit, bundle): + size = bundle.stat().st_size + headers = dict(self.authorization, **{ + "X-Upload-Content-Type": "application/octet-stream", + "X-Upload-Content-Length": str(size), + }) + started, _ = send("POST", f"{ROOT}upload/{self.path}/{edit}/bundles?uploadType=resumable", b"", headers) + with bundle.open("rb") as content: + _, answer = send("PUT", started["Location"], content, { + "Content-Type": "application/octet-stream", + "Content-Length": str(size), + }, retry=False) + return json.loads(answer) + + +def release_notes(folder): + if not folder: + return [] + notes = [] + for file in sorted(pathlib.Path(folder).glob("*.txt")): + text = file.read_text(encoding="utf-8").strip() + if not text: + continue + if len(text) > NOTES_LIMIT: + fail(f"{file} has {len(text)} characters; Google Play takes at most {NOTES_LIMIT} per language") + notes.append({"language": file.stem, "text": text}) + if not notes: + fail(f"{folder} holds no .txt file with text in it") + return notes + + +def service_account(): + try: + account = json.loads(setting("PLAY_SERVICE_ACCOUNT")) + except ValueError: + account = None + if not isinstance(account, dict) or not account.get("client_email") or not account.get("private_key"): + fail("service-account must be the whole JSON key file of a Google Cloud service account") + account.setdefault("token_uri", "https://oauth2.googleapis.com/token") + return account + + +def share(fraction): + try: + value = float(fraction) + except ValueError: + value = 0 + if not 0 < value < 1: + fail("fraction is the share of users, between 0 and 1, for example 0.1") + return value + + +def newest(releases): + return max(releases, key=lambda release: max(int(code) for code in release.get("versionCodes") or ["0"])) + + +def main(): + account = service_account() + package = setting("PLAY_PACKAGE") or fail("package is required, for example software.arasaka.meweb") + track = setting("PLAY_TRACK", "internal") + status = setting("PLAY_STATUS", "completed") + fraction = setting("PLAY_FRACTION") + bundle = setting("PLAY_AAB") + source = setting("PLAY_FROM_TRACK") + if status not in STATUSES: + fail(f"status must be one of {', '.join(STATUSES)}") + if (status in ("inProgress", "halted")) != bool(fraction): + fail("a staged rollout takes status inProgress or halted together with fraction; other statuses take no fraction") + fraction = share(fraction) if fraction else None + if bool(bundle) == bool(source): + fail("give either aab, to upload a bundle, or from-track, to move a release that is already there") + if bundle and not pathlib.Path(bundle).is_file(): + fail(f"{bundle} is not a file") + notes = release_notes(setting("PLAY_NOTES")) + + play = Play(account, package) + edit = play.call("POST")["id"] + if bundle: + path = pathlib.Path(bundle) + uploaded = play.upload(edit, path) + print(f"{path.name} uploaded as version code {uploaded['versionCode']}") + release = {"versionCodes": [str(uploaded["versionCode"])]} + else: + releases = play.call("GET", f"/{edit}/tracks/{source}").get("releases") or [] + if not releases: + fail(f"the {source} track holds no release to move") + chosen = newest(releases) + release = {key: chosen[key] for key in ("versionCodes", "name", "releaseNotes") if chosen.get(key)} + print(f"taking version code {', '.join(release['versionCodes'])} from the {source} track") + release["status"] = status + if fraction: + release["userFraction"] = fraction + if setting("PLAY_NAME"): + release["name"] = setting("PLAY_NAME") + if notes: + release["releaseNotes"] = notes + play.call("PUT", f"/{edit}/tracks/{track}", {"track": track, "releases": [release]}) + play.call("POST", f"/{edit}:commit") + rollout = f" for {fraction:.0%} of users" if fraction else "" + print(f"version code {', '.join(release['versionCodes'])} is {status} on the {track} track{rollout}") + + +if __name__ == "__main__": + main()