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