commit b3c2714862226b044e3bc8989dd952f29fe803ec Author: thepra Date: Fri Oct 23 12:57:12 2026 +0200 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 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 0000000..99e88d5 Binary files /dev/null and b/apple/__pycache__/apple.cpython-314.pyc differ diff --git a/apple/action.yml b/apple/action.yml new file mode 100644 index 0000000..56f9f0c --- /dev/null +++ b/apple/action.yml @@ -0,0 +1,57 @@ +name: App Store +description: Uploads a signed iOS .ipa to App Store Connect for TestFlight, and sends a version for App Store review +inputs: + issuer-id: + description: The issuer id of the team's App Store Connect API keys, from a secret + required: true + key-id: + description: The id of the API key, from a secret + required: true + private-key: + description: The text of the API key's .p8 file, from a secret + required: true + bundle-id: + description: The app's bundle identifier, for example software.arasaka.meweb + required: true + ipa: + description: The signed .ipa to upload; its version and build number are read from it + required: false + notes: + description: A folder of .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 0000000..0d8afbf Binary files /dev/null and b/play/__pycache__/play.cpython-314.pyc differ 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()