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
@@ -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.
@@ -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
@@ -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.
@@ -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