|
|
@@ -0,0 +1,265 @@
|
|
|
+#!/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())
|