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:
commit
b3c2714862
7 files changed
+848
No files matched your search
Binary file not shown.
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user