| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- #!/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())
|