update_app_store_metadata.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. #!/usr/bin/env python3
  2. """Push app-data/*.txt to App Store Connect via the official API."""
  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. API_BASE = "https://api.appstoreconnect.apple.com/v1"
  12. ROOT = Path(__file__).resolve().parents[1]
  13. APP_DATA = ROOT / "app-data"
  14. ENV_PATH = ROOT / ".env"
  15. VERSION_STRING = os.environ.get("APP_STORE_VERSION", "1.0")
  16. LOCALE = os.environ.get("APP_STORE_LOCALE", "en-US")
  17. def load_env(path: Path) -> dict[str, str]:
  18. values: dict[str, str] = {}
  19. for line in path.read_text(encoding="utf-8").splitlines():
  20. line = line.strip()
  21. if not line or line.startswith("#") or "=" not in line:
  22. continue
  23. key, value = line.split("=", 1)
  24. values[key.strip()] = value.strip()
  25. return values
  26. def read_field(name: str) -> str:
  27. path = APP_DATA / name
  28. if not path.exists():
  29. raise FileNotFoundError(path)
  30. return path.read_text(encoding="utf-8").strip()
  31. class ASCClient:
  32. def __init__(self, issuer_id: str, key_id: str, private_key_path: str):
  33. self.issuer_id = issuer_id
  34. self.key_id = key_id
  35. self.private_key = Path(private_key_path).expanduser().read_text(encoding="utf-8")
  36. self.session = requests.Session()
  37. self._token: str | None = None
  38. self._token_exp = 0.0
  39. def _ensure_token(self) -> str:
  40. now = time.time()
  41. if self._token and now < self._token_exp - 60:
  42. return self._token
  43. issued_at = int(now)
  44. payload = {
  45. "iss": self.issuer_id,
  46. "iat": issued_at,
  47. "exp": issued_at + 1200,
  48. "aud": "appstoreconnect-v1",
  49. }
  50. self._token = jwt.encode(
  51. payload,
  52. self.private_key,
  53. algorithm="ES256",
  54. headers={"kid": self.key_id, "typ": "JWT"},
  55. )
  56. self._token_exp = issued_at + 1200
  57. return self._token
  58. def request(self, method: str, path: str, **kwargs) -> requests.Response:
  59. token = self._ensure_token()
  60. headers = kwargs.pop("headers", {})
  61. headers["Authorization"] = f"Bearer {token}"
  62. headers.setdefault("Content-Type", "application/json")
  63. url = path if path.startswith("http") else f"{API_BASE}{path}"
  64. response = self.session.request(method, url, headers=headers, timeout=60, **kwargs)
  65. return response
  66. def get_json(self, path: str, params: dict | None = None) -> dict:
  67. response = self.request("GET", path, params=params)
  68. if not response.ok:
  69. raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}")
  70. return response.json()
  71. def patch(self, resource_type: str, resource_id: str, attributes: dict) -> dict:
  72. body = {
  73. "data": {
  74. "type": resource_type,
  75. "id": resource_id,
  76. "attributes": attributes,
  77. }
  78. }
  79. response = self.request("PATCH", f"/{resource_type}/{resource_id}", json=body)
  80. if not response.ok:
  81. raise RuntimeError(
  82. f"PATCH {resource_type}/{resource_id} failed ({response.status_code}): {response.text}"
  83. )
  84. return response.json()
  85. def post(self, resource_type: str, attributes: dict, relationships: dict) -> dict:
  86. body = {
  87. "data": {
  88. "type": resource_type,
  89. "attributes": attributes,
  90. "relationships": relationships,
  91. }
  92. }
  93. response = self.request("POST", f"/{resource_type}", json=body)
  94. if not response.ok:
  95. raise RuntimeError(
  96. f"POST {resource_type} failed ({response.status_code}): {response.text}"
  97. )
  98. return response.json()["data"]
  99. def find_app(client: ASCClient, bundle_id: str) -> dict:
  100. data = client.get_json("/apps", params={"filter[bundleId]": bundle_id, "limit": 1})
  101. apps = data.get("data", [])
  102. if not apps:
  103. raise RuntimeError(f"No app found for bundle id {bundle_id!r}")
  104. return apps[0]
  105. def find_mac_app_info(client: ASCClient, app_id: str) -> dict:
  106. data = client.get_json(f"/apps/{app_id}/appInfos")
  107. infos = data.get("data", [])
  108. if not infos:
  109. raise RuntimeError(f"No appInfos for app {app_id}")
  110. editable_states = {
  111. "PREPARE_FOR_SUBMISSION",
  112. "DEVELOPER_REJECTED",
  113. "REJECTED",
  114. "METADATA_REJECTED",
  115. }
  116. for info in infos:
  117. state = info.get("attributes", {}).get("appStoreState")
  118. if state in editable_states:
  119. return info
  120. for info in infos:
  121. info_id = info["id"]
  122. detail = client.get_json(f"/appInfos/{info_id}")
  123. platform = detail.get("data", {}).get("attributes", {}).get("platform")
  124. if platform == "MAC_OS":
  125. return detail["data"]
  126. if len(infos) == 1:
  127. return infos[0]
  128. return infos[0]
  129. def find_app_info_localization(client: ASCClient, app_info_id: str, locale: str) -> dict:
  130. data = client.get_json(f"/appInfos/{app_info_id}/appInfoLocalizations")
  131. for loc in data.get("data", []):
  132. if loc.get("attributes", {}).get("locale") == locale:
  133. return loc
  134. raise RuntimeError(f"No appInfoLocalization for locale {locale!r}")
  135. def find_version(client: ASCClient, app_id: str, version_string: str, *, create: bool = False) -> dict:
  136. data = client.get_json(
  137. f"/apps/{app_id}/appStoreVersions",
  138. params={"filter[platform]": "MAC_OS", "limit": 50},
  139. )
  140. matches = [
  141. v
  142. for v in data.get("data", [])
  143. if v.get("attributes", {}).get("versionString") == version_string
  144. ]
  145. if not matches:
  146. if not create:
  147. raise RuntimeError(f"No MAC_OS appStoreVersion {version_string!r}")
  148. created = client.post(
  149. "appStoreVersions",
  150. {"platform": "MAC_OS", "versionString": version_string},
  151. {"app": {"data": {"type": "apps", "id": app_id}}},
  152. )
  153. print(f"Created version {version_string}: {created['id']}")
  154. return created
  155. # Prefer editable state if multiple.
  156. for v in matches:
  157. state = v.get("attributes", {}).get("appStoreState")
  158. if state in ("PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED"):
  159. return v
  160. return matches[0]
  161. def find_version_localization(
  162. client: ASCClient, version_id: str, locale: str, *, create: bool = False
  163. ) -> dict:
  164. data = client.get_json(f"/appStoreVersions/{version_id}/appStoreVersionLocalizations")
  165. for loc in data.get("data", []):
  166. if loc.get("attributes", {}).get("locale") == locale:
  167. return loc
  168. if not create:
  169. raise RuntimeError(f"No appStoreVersionLocalization for locale {locale!r}")
  170. created = client.post(
  171. "appStoreVersionLocalizations",
  172. {"locale": locale},
  173. {"appStoreVersion": {"data": {"type": "appStoreVersions", "id": version_id}}},
  174. )
  175. print(f"Created version localization ({locale}): {created['id']}")
  176. return created
  177. def main() -> int:
  178. if not ENV_PATH.exists():
  179. print(f"Missing {ENV_PATH}", file=sys.stderr)
  180. return 1
  181. env = load_env(ENV_PATH)
  182. issuer = env.get("APP_STORE_CONNECT_ISSUER_ID", "")
  183. key_id = env.get("APP_STORE_CONNECT_KEY_ID", "")
  184. key_path = env.get("APP_STORE_CONNECT_PRIVATE_KEY_PATH", "")
  185. bundle_id = env.get("APP_STORE_CONNECT_BUNDLE_ID", "")
  186. for key, value in [
  187. ("APP_STORE_CONNECT_ISSUER_ID", issuer),
  188. ("APP_STORE_CONNECT_KEY_ID", key_id),
  189. ("APP_STORE_CONNECT_PRIVATE_KEY_PATH", key_path),
  190. ("APP_STORE_CONNECT_BUNDLE_ID", bundle_id),
  191. ]:
  192. if not value:
  193. print(f"Set {key} in {ENV_PATH}", file=sys.stderr)
  194. return 1
  195. client = ASCClient(issuer, key_id, key_path)
  196. app = find_app(client, bundle_id)
  197. app_id = app["id"]
  198. print(f"App: {app['attributes'].get('name')} ({app_id})")
  199. app_info = find_mac_app_info(client, app_id)
  200. app_info_id = app_info["id"]
  201. print(f"App Information (MAC_OS): {app_info_id}")
  202. info_loc = find_app_info_localization(client, app_info_id, LOCALE)
  203. info_loc_id = info_loc["id"]
  204. app_name = read_field("app-name.txt")
  205. subtitle = read_field("subtitle.txt")
  206. privacy_url = read_field("link-privacy.txt")
  207. info_patch = {
  208. "name": app_name,
  209. "subtitle": subtitle,
  210. "privacyPolicyUrl": privacy_url,
  211. }
  212. client.patch("appInfoLocalizations", info_loc_id, info_patch)
  213. print(f"Updated App Information localization ({LOCALE}): name, subtitle, privacyPolicyUrl")
  214. create_version = os.environ.get("APP_STORE_CREATE_VERSION", "").lower() in ("1", "true", "yes")
  215. version = find_version(client, app_id, VERSION_STRING, create=create_version)
  216. version_id = version["id"]
  217. version_state = version.get("attributes", {}).get("appStoreState")
  218. print(f"Version {VERSION_STRING}: {version_id} (state: {version_state})")
  219. copyright_text = read_field("copyright.txt")
  220. client.patch("appStoreVersions", version_id, {"copyright": copyright_text})
  221. print(f"Updated version copyright")
  222. version_loc = find_version_localization(client, version_id, LOCALE, create=create_version)
  223. version_loc_id = version_loc["id"]
  224. version_patch = {
  225. "description": read_field("description.txt"),
  226. "keywords": read_field("keywords.txt"),
  227. "promotionalText": read_field("promotional-text.txt"),
  228. "marketingUrl": read_field("link-marketing.txt"),
  229. "supportUrl": read_field("link-support.txt"),
  230. }
  231. whats_new_path = APP_DATA / "whats-new.txt"
  232. if whats_new_path.exists():
  233. version_patch["whatsNew"] = whats_new_path.read_text(encoding="utf-8").strip()
  234. client.patch("appStoreVersionLocalizations", version_loc_id, version_patch)
  235. fields = "description, keywords, promotionalText, marketingUrl, supportUrl"
  236. if "whatsNew" in version_patch:
  237. fields += ", whatsNew"
  238. print(f"Updated version {VERSION_STRING} localization ({LOCALE}): {fields}")
  239. # Custom EULA URL (Terms) — attach to app if not using standard Apple EULA.
  240. terms_url = read_field("link-terms.txt")
  241. try:
  242. eula_data = client.get_json(f"/apps/{app_id}/endUserLicenseAgreement")
  243. eula = eula_data.get("data")
  244. if eula:
  245. # Custom EULA resource uses agreementText, not URL in all cases; try app-level license.
  246. print("App has custom EULA resource; update Terms URL in App Store Connect if needed:", terms_url)
  247. else:
  248. print("Using standard Apple EULA; Terms URL for reference:", terms_url)
  249. except RuntimeError:
  250. print("EULA lookup skipped; Terms URL:", terms_url)
  251. print("Done.")
  252. return 0
  253. if __name__ == "__main__":
  254. raise SystemExit(main())