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