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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KizzWs2WQPWN2qgE7G6r8B
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-23 12:57:12 +02:00
commit b3c2714862
7 files changed
+848

No files matched your search

+159
View File
@@ -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 `<language>.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 `<locale>.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.
Binary file not shown.
+57
View File
@@ -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 <locale>.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 }}
+372
View File
@@ -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 <locale>.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/<name>.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()
Binary file not shown.
+45
View File
@@ -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 <language>.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 }}
+215
View File
@@ -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 <language>.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()