update_appstore_connect_us.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. #!/usr/bin/env python3
  2. """Push appconnect/US metadata to App Store Connect (en-US)."""
  3. from __future__ import annotations
  4. import json
  5. import os
  6. import sys
  7. import time
  8. from pathlib import Path
  9. import jwt
  10. import requests
  11. from cryptography.hazmat.primitives import serialization
  12. REPO_ROOT = Path(__file__).resolve().parents[1]
  13. US_DIR = REPO_ROOT / "appconnect" / "US"
  14. API_BASE = "https://api.appstoreconnect.apple.com/v1"
  15. LOCALE = "en-US"
  16. def load_env() -> dict[str, str]:
  17. env_path = REPO_ROOT / ".env"
  18. values: dict[str, str] = {}
  19. if not env_path.exists():
  20. return values
  21. for line in env_path.read_text(encoding="utf-8").splitlines():
  22. line = line.strip()
  23. if not line or line.startswith("#") or "=" not in line:
  24. continue
  25. key, _, value = line.partition("=")
  26. values[key.strip()] = value.strip()
  27. return values
  28. def read_text(name: str) -> str:
  29. path = US_DIR / name
  30. return path.read_text(encoding="utf-8").strip()
  31. def make_token(issuer_id: str, key_id: str, key_path: Path) -> str:
  32. private_key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
  33. headers = {"alg": "ES256", "kid": key_id, "typ": "JWT"}
  34. payload = {
  35. "iss": issuer_id,
  36. "iat": int(time.time()),
  37. "exp": int(time.time()) + 1200,
  38. "aud": "appstoreconnect-v1",
  39. }
  40. return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
  41. class ASCClient:
  42. def __init__(self, token: str) -> None:
  43. self.session = requests.Session()
  44. self.session.headers.update(
  45. {
  46. "Authorization": f"Bearer {token}",
  47. "Content-Type": "application/json",
  48. }
  49. )
  50. def get(self, path: str, params: dict | None = None) -> dict:
  51. response = self.session.get(f"{API_BASE}{path}", params=params, timeout=60)
  52. if not response.ok:
  53. raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}")
  54. return response.json()
  55. def patch(self, path: str, body: dict) -> dict:
  56. response = self.session.patch(f"{API_BASE}{path}", json=body, timeout=60)
  57. if not response.ok:
  58. raise RuntimeError(f"PATCH {path} failed ({response.status_code}): {response.text}")
  59. return response.json()
  60. def main() -> int:
  61. env = {**os.environ, **load_env()}
  62. issuer_id = env.get("APP_STORE_CONNECT_ISSUER_ID")
  63. key_id = env.get("APP_STORE_CONNECT_KEY_ID")
  64. key_path_raw = env.get("APP_STORE_CONNECT_API_KEY_PATH")
  65. bundle_id = env.get("PRODUCT_BUNDLE_IDENTIFIER", "com.hwaccount.zoom-app")
  66. if not issuer_id or not key_id or not key_path_raw:
  67. print("Missing APP_STORE_CONNECT_ISSUER_ID, KEY_ID, or API_KEY_PATH in .env", file=sys.stderr)
  68. return 1
  69. key_path = Path(key_path_raw).expanduser()
  70. if not key_path.is_file():
  71. print(f"API key file not found: {key_path}", file=sys.stderr)
  72. return 1
  73. urls = json.loads((US_DIR / "urls.json").read_text(encoding="utf-8"))
  74. metadata = {
  75. "name": read_text("app-name.txt"),
  76. "subtitle": read_text("subtitle.txt"),
  77. "promotionalText": read_text("promotional-text.txt"),
  78. "description": read_text("description.txt"),
  79. "keywords": read_text("keywords.txt"),
  80. "whatsNew": read_text("whats-new.txt"),
  81. "copyright": read_text("copyright.txt"),
  82. "supportUrl": urls.get("supportUrl"),
  83. "marketingUrl": urls.get("marketingUrl"),
  84. "privacyPolicyUrl": urls.get("privacyPolicyUrl"),
  85. }
  86. token = make_token(issuer_id, key_id, key_path)
  87. client = ASCClient(token)
  88. apps = client.get("/apps", {"filter[bundleId]": bundle_id, "limit": 1})
  89. app_list = apps.get("data", [])
  90. if not app_list:
  91. print(f"No app found for bundle id {bundle_id}", file=sys.stderr)
  92. return 1
  93. app_id = app_list[0]["id"]
  94. print(f"App: {app_id} ({bundle_id})")
  95. # --- App Information (name, subtitle, privacy policy URL) ---
  96. app_infos = client.get(f"/apps/{app_id}/appInfos", {"limit": 10})
  97. app_info_id = None
  98. for info in app_infos.get("data", []):
  99. if info.get("attributes", {}).get("appStoreState") in (
  100. "PREPARE_FOR_SUBMISSION",
  101. "READY_FOR_SALE",
  102. "DEVELOPER_REJECTED",
  103. "PENDING_DEVELOPER_RELEASE",
  104. "IN_REVIEW",
  105. "WAITING_FOR_REVIEW",
  106. "ACCEPTED",
  107. ) or app_info_id is None:
  108. app_info_id = info["id"]
  109. if not app_info_id:
  110. print("No appInfo resource found", file=sys.stderr)
  111. return 1
  112. info_locs = client.get(
  113. f"/appInfos/{app_info_id}/appInfoLocalizations",
  114. {"filter[locale]": LOCALE, "limit": 1},
  115. )
  116. info_loc_list = info_locs.get("data", [])
  117. if not info_loc_list:
  118. print(f"No appInfoLocalization for {LOCALE}", file=sys.stderr)
  119. return 1
  120. info_loc_id = info_loc_list[0]["id"]
  121. info_attrs: dict[str, str] = {
  122. "name": metadata["name"],
  123. "subtitle": metadata["subtitle"],
  124. }
  125. if metadata.get("privacyPolicyUrl"):
  126. info_attrs["privacyPolicyUrl"] = metadata["privacyPolicyUrl"]
  127. client.patch(
  128. f"/appInfoLocalizations/{info_loc_id}",
  129. {
  130. "data": {
  131. "type": "appInfoLocalizations",
  132. "id": info_loc_id,
  133. "attributes": info_attrs,
  134. }
  135. },
  136. )
  137. print(f"Updated app info localization ({LOCALE}): name, subtitle, privacyPolicyUrl")
  138. # --- App Store version (copyright) + version localization ---
  139. versions = client.get(
  140. f"/apps/{app_id}/appStoreVersions",
  141. {
  142. "filter[platform]": "MAC_OS",
  143. "limit": 10,
  144. },
  145. )
  146. version_id = None
  147. preferred_states = {
  148. "PREPARE_FOR_SUBMISSION",
  149. "DEVELOPER_REJECTED",
  150. "WAITING_FOR_REVIEW",
  151. "IN_REVIEW",
  152. "PENDING_DEVELOPER_RELEASE",
  153. }
  154. for version in versions.get("data", []):
  155. state = version.get("attributes", {}).get("appStoreState")
  156. if state in preferred_states:
  157. version_id = version["id"]
  158. break
  159. if version_id is None:
  160. version_id = version["id"]
  161. if not version_id:
  162. print("No macOS app store version found", file=sys.stderr)
  163. return 1
  164. client.patch(
  165. f"/appStoreVersions/{version_id}",
  166. {
  167. "data": {
  168. "type": "appStoreVersions",
  169. "id": version_id,
  170. "attributes": {"copyright": metadata["copyright"]},
  171. }
  172. },
  173. )
  174. print(f"Updated app store version copyright: {metadata['copyright']}")
  175. version_locs = client.get(
  176. f"/appStoreVersions/{version_id}/appStoreVersionLocalizations",
  177. {"filter[locale]": LOCALE, "limit": 1},
  178. )
  179. version_loc_list = version_locs.get("data", [])
  180. if not version_loc_list:
  181. print(f"No appStoreVersionLocalization for {LOCALE}", file=sys.stderr)
  182. return 1
  183. version_loc_id = version_loc_list[0]["id"]
  184. version_attrs: dict[str, str] = {
  185. "description": metadata["description"],
  186. "keywords": metadata["keywords"],
  187. "promotionalText": metadata["promotionalText"],
  188. }
  189. if metadata.get("supportUrl"):
  190. version_attrs["supportUrl"] = metadata["supportUrl"]
  191. if metadata.get("marketingUrl"):
  192. version_attrs["marketingUrl"] = metadata["marketingUrl"]
  193. client.patch(
  194. f"/appStoreVersionLocalizations/{version_loc_id}",
  195. {
  196. "data": {
  197. "type": "appStoreVersionLocalizations",
  198. "id": version_loc_id,
  199. "attributes": version_attrs,
  200. }
  201. },
  202. )
  203. print(
  204. f"Updated version localization ({LOCALE}): description, keywords, "
  205. "promotionalText, supportUrl, marketingUrl"
  206. )
  207. try:
  208. client.patch(
  209. f"/appStoreVersionLocalizations/{version_loc_id}",
  210. {
  211. "data": {
  212. "type": "appStoreVersionLocalizations",
  213. "id": version_loc_id,
  214. "attributes": {"whatsNew": metadata["whatsNew"]},
  215. }
  216. },
  217. )
  218. print(f"Updated whatsNew ({LOCALE})")
  219. except RuntimeError as error:
  220. if "whatsNew" in str(error) and "409" in str(error):
  221. print(
  222. "Skipped whatsNew: not editable for this version state "
  223. "(common for first release; set it when you submit an update)."
  224. )
  225. else:
  226. raise
  227. print("Done. Review changes in App Store Connect.")
  228. return 0
  229. if __name__ == "__main__":
  230. raise SystemExit(main())