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

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()