#!/usr/bin/env python3 """Push appconnect/US metadata to App Store Connect (en-US).""" from __future__ import annotations import json import os import sys import time from pathlib import Path import jwt import requests from cryptography.hazmat.primitives import serialization REPO_ROOT = Path(__file__).resolve().parents[1] US_DIR = REPO_ROOT / "appconnect" / "US" API_BASE = "https://api.appstoreconnect.apple.com/v1" LOCALE = "en-US" def load_env() -> dict[str, str]: env_path = REPO_ROOT / ".env" values: dict[str, str] = {} if not env_path.exists(): return values for line in env_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") values[key.strip()] = value.strip() return values def read_text(name: str) -> str: path = US_DIR / name return path.read_text(encoding="utf-8").strip() def make_token(issuer_id: str, key_id: str, key_path: Path) -> str: private_key = serialization.load_pem_private_key(key_path.read_bytes(), password=None) headers = {"alg": "ES256", "kid": key_id, "typ": "JWT"} payload = { "iss": issuer_id, "iat": int(time.time()), "exp": int(time.time()) + 1200, "aud": "appstoreconnect-v1", } return jwt.encode(payload, private_key, algorithm="ES256", headers=headers) class ASCClient: def __init__(self, token: str) -> None: self.session = requests.Session() self.session.headers.update( { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } ) def get(self, path: str, params: dict | None = None) -> dict: response = self.session.get(f"{API_BASE}{path}", params=params, timeout=60) if not response.ok: raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}") return response.json() def patch(self, path: str, body: dict) -> dict: response = self.session.patch(f"{API_BASE}{path}", json=body, timeout=60) if not response.ok: raise RuntimeError(f"PATCH {path} failed ({response.status_code}): {response.text}") return response.json() def main() -> int: env = {**os.environ, **load_env()} issuer_id = env.get("APP_STORE_CONNECT_ISSUER_ID") key_id = env.get("APP_STORE_CONNECT_KEY_ID") key_path_raw = env.get("APP_STORE_CONNECT_API_KEY_PATH") bundle_id = env.get("PRODUCT_BUNDLE_IDENTIFIER", "com.hwaccount.zoom-app") if not issuer_id or not key_id or not key_path_raw: print("Missing APP_STORE_CONNECT_ISSUER_ID, KEY_ID, or API_KEY_PATH in .env", file=sys.stderr) return 1 key_path = Path(key_path_raw).expanduser() if not key_path.is_file(): print(f"API key file not found: {key_path}", file=sys.stderr) return 1 urls = json.loads((US_DIR / "urls.json").read_text(encoding="utf-8")) metadata = { "name": read_text("app-name.txt"), "subtitle": read_text("subtitle.txt"), "promotionalText": read_text("promotional-text.txt"), "description": read_text("description.txt"), "keywords": read_text("keywords.txt"), "whatsNew": read_text("whats-new.txt"), "copyright": read_text("copyright.txt"), "supportUrl": urls.get("supportUrl"), "marketingUrl": urls.get("marketingUrl"), "privacyPolicyUrl": urls.get("privacyPolicyUrl"), } token = make_token(issuer_id, key_id, key_path) client = ASCClient(token) apps = client.get("/apps", {"filter[bundleId]": bundle_id, "limit": 1}) app_list = apps.get("data", []) if not app_list: print(f"No app found for bundle id {bundle_id}", file=sys.stderr) return 1 app_id = app_list[0]["id"] print(f"App: {app_id} ({bundle_id})") # --- App Information (name, subtitle, privacy policy URL) --- app_infos = client.get(f"/apps/{app_id}/appInfos", {"limit": 10}) app_info_id = None for info in app_infos.get("data", []): if info.get("attributes", {}).get("appStoreState") in ( "PREPARE_FOR_SUBMISSION", "READY_FOR_SALE", "DEVELOPER_REJECTED", "PENDING_DEVELOPER_RELEASE", "IN_REVIEW", "WAITING_FOR_REVIEW", "ACCEPTED", ) or app_info_id is None: app_info_id = info["id"] if not app_info_id: print("No appInfo resource found", file=sys.stderr) return 1 info_locs = client.get( f"/appInfos/{app_info_id}/appInfoLocalizations", {"filter[locale]": LOCALE, "limit": 1}, ) info_loc_list = info_locs.get("data", []) if not info_loc_list: print(f"No appInfoLocalization for {LOCALE}", file=sys.stderr) return 1 info_loc_id = info_loc_list[0]["id"] info_attrs: dict[str, str] = { "name": metadata["name"], "subtitle": metadata["subtitle"], } if metadata.get("privacyPolicyUrl"): info_attrs["privacyPolicyUrl"] = metadata["privacyPolicyUrl"] client.patch( f"/appInfoLocalizations/{info_loc_id}", { "data": { "type": "appInfoLocalizations", "id": info_loc_id, "attributes": info_attrs, } }, ) print(f"Updated app info localization ({LOCALE}): name, subtitle, privacyPolicyUrl") # --- App Store version (copyright) + version localization --- versions = client.get( f"/apps/{app_id}/appStoreVersions", { "filter[platform]": "MAC_OS", "limit": 10, }, ) version_id = None preferred_states = { "PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "WAITING_FOR_REVIEW", "IN_REVIEW", "PENDING_DEVELOPER_RELEASE", } for version in versions.get("data", []): state = version.get("attributes", {}).get("appStoreState") if state in preferred_states: version_id = version["id"] break if version_id is None: version_id = version["id"] if not version_id: print("No macOS app store version found", file=sys.stderr) return 1 client.patch( f"/appStoreVersions/{version_id}", { "data": { "type": "appStoreVersions", "id": version_id, "attributes": {"copyright": metadata["copyright"]}, } }, ) print(f"Updated app store version copyright: {metadata['copyright']}") version_locs = client.get( f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", {"filter[locale]": LOCALE, "limit": 1}, ) version_loc_list = version_locs.get("data", []) if not version_loc_list: print(f"No appStoreVersionLocalization for {LOCALE}", file=sys.stderr) return 1 version_loc_id = version_loc_list[0]["id"] version_attrs: dict[str, str] = { "description": metadata["description"], "keywords": metadata["keywords"], "promotionalText": metadata["promotionalText"], } if metadata.get("supportUrl"): version_attrs["supportUrl"] = metadata["supportUrl"] if metadata.get("marketingUrl"): version_attrs["marketingUrl"] = metadata["marketingUrl"] client.patch( f"/appStoreVersionLocalizations/{version_loc_id}", { "data": { "type": "appStoreVersionLocalizations", "id": version_loc_id, "attributes": version_attrs, } }, ) print( f"Updated version localization ({LOCALE}): description, keywords, " "promotionalText, supportUrl, marketingUrl" ) try: client.patch( f"/appStoreVersionLocalizations/{version_loc_id}", { "data": { "type": "appStoreVersionLocalizations", "id": version_loc_id, "attributes": {"whatsNew": metadata["whatsNew"]}, } }, ) print(f"Updated whatsNew ({LOCALE})") except RuntimeError as error: if "whatsNew" in str(error) and "409" in str(error): print( "Skipped whatsNew: not editable for this version state " "(common for first release; set it when you submit an update)." ) else: raise print("Done. Review changes in App Store Connect.") return 0 if __name__ == "__main__": raise SystemExit(main())