#!/usr/bin/env python3 """Push app-data/*.txt to App Store Connect via the official API.""" from __future__ import annotations import json import os import sys import time from pathlib import Path import jwt import requests API_BASE = "https://api.appstoreconnect.apple.com/v1" ROOT = Path(__file__).resolve().parents[1] APP_DATA = ROOT / "app-data" ENV_PATH = ROOT / ".env" VERSION_STRING = os.environ.get("APP_STORE_VERSION", "1.0") LOCALE = os.environ.get("APP_STORE_LOCALE", "en-US") def load_env(path: Path) -> dict[str, str]: values: dict[str, str] = {} for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) values[key.strip()] = value.strip() return values def read_field(name: str) -> str: path = APP_DATA / name if not path.exists(): raise FileNotFoundError(path) return path.read_text(encoding="utf-8").strip() class ASCClient: def __init__(self, issuer_id: str, key_id: str, private_key_path: str): self.issuer_id = issuer_id self.key_id = key_id self.private_key = Path(private_key_path).expanduser().read_text(encoding="utf-8") self.session = requests.Session() self._token: str | None = None self._token_exp = 0.0 def _ensure_token(self) -> str: now = time.time() if self._token and now < self._token_exp - 60: return self._token issued_at = int(now) payload = { "iss": self.issuer_id, "iat": issued_at, "exp": issued_at + 1200, "aud": "appstoreconnect-v1", } self._token = jwt.encode( payload, self.private_key, algorithm="ES256", headers={"kid": self.key_id, "typ": "JWT"}, ) self._token_exp = issued_at + 1200 return self._token def request(self, method: str, path: str, **kwargs) -> requests.Response: token = self._ensure_token() headers = kwargs.pop("headers", {}) headers["Authorization"] = f"Bearer {token}" headers.setdefault("Content-Type", "application/json") url = path if path.startswith("http") else f"{API_BASE}{path}" response = self.session.request(method, url, headers=headers, timeout=60, **kwargs) return response def get_json(self, path: str, params: dict | None = None) -> dict: response = self.request("GET", path, params=params) if not response.ok: raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}") return response.json() def patch(self, resource_type: str, resource_id: str, attributes: dict) -> dict: body = { "data": { "type": resource_type, "id": resource_id, "attributes": attributes, } } response = self.request("PATCH", f"/{resource_type}/{resource_id}", json=body) if not response.ok: raise RuntimeError( f"PATCH {resource_type}/{resource_id} failed ({response.status_code}): {response.text}" ) return response.json() def find_app(client: ASCClient, bundle_id: str) -> dict: data = client.get_json("/apps", params={"filter[bundleId]": bundle_id, "limit": 1}) apps = data.get("data", []) if not apps: raise RuntimeError(f"No app found for bundle id {bundle_id!r}") return apps[0] def find_mac_app_info(client: ASCClient, app_id: str) -> dict: data = client.get_json(f"/apps/{app_id}/appInfos") for info in data.get("data", []): attrs = info.get("attributes", {}) if attrs.get("appStoreState") is not None or attrs.get("brazilAgeRating") is not None: # Prefer MAC_OS if platform is listed in included relationships; filter by bundle platform. pass # App infos are per platform — pick MAC_OS via appStoreAgeRating or secondary query. for info in data.get("data", []): info_id = info["id"] detail = client.get_json(f"/appInfos/{info_id}") platform = detail.get("data", {}).get("attributes", {}).get("platform") if platform == "MAC_OS": return detail["data"] # Fallback: single app info entry. infos = data.get("data", []) if len(infos) == 1: return infos[0] raise RuntimeError("Could not determine MAC_OS appInfo; inspect /apps/{id}/appInfos") def find_app_info_localization(client: ASCClient, app_info_id: str, locale: str) -> dict: data = client.get_json(f"/appInfos/{app_info_id}/appInfoLocalizations") for loc in data.get("data", []): if loc.get("attributes", {}).get("locale") == locale: return loc raise RuntimeError(f"No appInfoLocalization for locale {locale!r}") def find_version(client: ASCClient, app_id: str, version_string: str) -> dict: data = client.get_json( f"/apps/{app_id}/appStoreVersions", params={"filter[platform]": "MAC_OS", "limit": 50}, ) matches = [ v for v in data.get("data", []) if v.get("attributes", {}).get("versionString") == version_string ] if not matches: raise RuntimeError(f"No MAC_OS appStoreVersion {version_string!r}") # Prefer editable state if multiple. for v in matches: state = v.get("attributes", {}).get("appStoreState") if state in ("PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED"): return v return matches[0] def find_version_localization(client: ASCClient, version_id: str, locale: str) -> dict: data = client.get_json(f"/appStoreVersions/{version_id}/appStoreVersionLocalizations") for loc in data.get("data", []): if loc.get("attributes", {}).get("locale") == locale: return loc raise RuntimeError(f"No appStoreVersionLocalization for locale {locale!r}") def main() -> int: if not ENV_PATH.exists(): print(f"Missing {ENV_PATH}", file=sys.stderr) return 1 env = load_env(ENV_PATH) issuer = env.get("APP_STORE_CONNECT_ISSUER_ID", "") key_id = env.get("APP_STORE_CONNECT_KEY_ID", "") key_path = env.get("APP_STORE_CONNECT_PRIVATE_KEY_PATH", "") bundle_id = env.get("APP_STORE_CONNECT_BUNDLE_ID", "") for key, value in [ ("APP_STORE_CONNECT_ISSUER_ID", issuer), ("APP_STORE_CONNECT_KEY_ID", key_id), ("APP_STORE_CONNECT_PRIVATE_KEY_PATH", key_path), ("APP_STORE_CONNECT_BUNDLE_ID", bundle_id), ]: if not value: print(f"Set {key} in {ENV_PATH}", file=sys.stderr) return 1 client = ASCClient(issuer, key_id, key_path) app = find_app(client, bundle_id) app_id = app["id"] print(f"App: {app['attributes'].get('name')} ({app_id})") app_info = find_mac_app_info(client, app_id) app_info_id = app_info["id"] print(f"App Information (MAC_OS): {app_info_id}") info_loc = find_app_info_localization(client, app_info_id, LOCALE) info_loc_id = info_loc["id"] app_name = read_field("app-name.txt") subtitle = read_field("subtitle.txt") privacy_url = read_field("link-privacy.txt") info_patch = { "name": app_name, "subtitle": subtitle, "privacyPolicyUrl": privacy_url, } client.patch("appInfoLocalizations", info_loc_id, info_patch) print(f"Updated App Information localization ({LOCALE}): name, subtitle, privacyPolicyUrl") version = find_version(client, app_id, VERSION_STRING) version_id = version["id"] version_state = version.get("attributes", {}).get("appStoreState") print(f"Version {VERSION_STRING}: {version_id} (state: {version_state})") copyright_text = read_field("copyright.txt") client.patch("appStoreVersions", version_id, {"copyright": copyright_text}) print(f"Updated version copyright") version_loc = find_version_localization(client, version_id, LOCALE) version_loc_id = version_loc["id"] version_patch = { "description": read_field("description.txt"), "keywords": read_field("keywords.txt"), "promotionalText": read_field("promotional-text.txt"), "marketingUrl": read_field("link-marketing.txt"), "supportUrl": read_field("link-support.txt"), } client.patch("appStoreVersionLocalizations", version_loc_id, version_patch) print(f"Updated version {VERSION_STRING} localization ({LOCALE}): description, keywords, promotionalText, marketingUrl, supportUrl") # Custom EULA URL (Terms) — attach to app if not using standard Apple EULA. terms_url = read_field("link-terms.txt") try: eula_data = client.get_json(f"/apps/{app_id}/endUserLicenseAgreement") eula = eula_data.get("data") if eula: # Custom EULA resource uses agreementText, not URL in all cases; try app-level license. print("App has custom EULA resource; update Terms URL in App Store Connect if needed:", terms_url) else: print("Using standard Apple EULA; Terms URL for reference:", terms_url) except RuntimeError: print("EULA lookup skipped; Terms URL:", terms_url) print("Done.") return 0 if __name__ == "__main__": raise SystemExit(main())