@daemux/store-automator 0.10.69 → 0.10.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "App Store & Google Play automation for Flutter apps",
|
|
8
|
-
"version": "0.10.
|
|
8
|
+
"version": "0.10.71"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "store-automator",
|
|
13
13
|
"source": "./plugins/store-automator",
|
|
14
14
|
"description": "3 agents for app store publishing: reviewer, meta-creator, media-designer",
|
|
15
|
-
"version": "0.10.
|
|
15
|
+
"version": "0.10.71",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Resolve the App Store Connect version slot for this run.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
5
|
+
Apple permits at most ONE editable appStoreVersion per app at a time. If one
|
|
6
|
+
already exists under a different versionString, creating another returns 409
|
|
7
|
+
ENTITY_ERROR.RELATIONSHIP.INVALID. We therefore PATCH the existing editable
|
|
8
|
+
slot to the desired versionString rather than attempt a second CREATE.
|
|
9
|
+
|
|
10
|
+
Reads PROJECT_MARKETING_VERSION from env (pinned in Xcode) and decides whether
|
|
11
|
+
to REUSE an editable draft (directly or via PATCH) or CREATE a new one. Never
|
|
12
|
+
auto-bumps the version.
|
|
13
|
+
|
|
14
|
+
Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID,
|
|
15
|
+
PROJECT_MARKETING_VERSION (e.g. "1.2.3" or "1.2").
|
|
16
|
+
Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
|
|
17
|
+
Stderr: human log + ::error:: lines for GitHub Actions.
|
|
17
18
|
"""
|
|
18
19
|
|
|
19
20
|
from __future__ import annotations
|
|
@@ -34,6 +35,19 @@ from asc_common import (
|
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
|
|
38
|
+
MAX_RECURSIONS = 2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _log(msg: str) -> None:
|
|
42
|
+
print(f"[decision] {msg}", file=sys.stderr)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _result(decision: str, version: str, vid: str) -> dict:
|
|
46
|
+
return {
|
|
47
|
+
"decision": decision,
|
|
48
|
+
"versionString": version,
|
|
49
|
+
"appStoreVersionId": vid,
|
|
50
|
+
}
|
|
37
51
|
|
|
38
52
|
|
|
39
53
|
def env(name: str) -> str:
|
|
@@ -53,26 +67,27 @@ def normalize(v: str) -> str:
|
|
|
53
67
|
file=sys.stderr,
|
|
54
68
|
)
|
|
55
69
|
raise SystemExit(1)
|
|
56
|
-
|
|
57
|
-
return f"{major}.{minor}.{patch}"
|
|
70
|
+
return f"{m.group(1)}.{m.group(2)}.{m.group(3) or '0'}"
|
|
58
71
|
|
|
59
72
|
|
|
60
73
|
def to_tuple(v: str) -> tuple[int, int, int]:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return int(
|
|
74
|
+
m = SEM_RE.match(v or "")
|
|
75
|
+
if not m:
|
|
76
|
+
return (0, 0, 0)
|
|
77
|
+
return int(m.group(1)), int(m.group(2)), int(m.group(3) or "0")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def same_version(a: str, b: str) -> bool:
|
|
81
|
+
"""1.2 == 1.2.0 via normalized tuple comparison."""
|
|
82
|
+
return to_tuple(a) == to_tuple(b)
|
|
65
83
|
|
|
66
84
|
|
|
67
85
|
def fetch_versions(app_id: str, token: str) -> list[dict]:
|
|
68
|
-
"""Return list of {'versionString','state','id'} for IOS
|
|
86
|
+
"""Return list of {'versionString','state','id','createdDate'} for IOS."""
|
|
69
87
|
data = get_json(
|
|
70
88
|
f"/apps/{app_id}/appStoreVersions",
|
|
71
89
|
token,
|
|
72
|
-
params={
|
|
73
|
-
"limit": 200,
|
|
74
|
-
"filter[platform]": "IOS",
|
|
75
|
-
},
|
|
90
|
+
params={"limit": 200, "filter[platform]": "IOS"},
|
|
76
91
|
)
|
|
77
92
|
out: list[dict] = []
|
|
78
93
|
for item in data.get("data", []):
|
|
@@ -82,18 +97,39 @@ def fetch_versions(app_id: str, token: str) -> list[dict]:
|
|
|
82
97
|
"versionString": attrs.get("versionString") or "",
|
|
83
98
|
"state": attrs.get("appStoreState") or "",
|
|
84
99
|
"id": item.get("id") or "",
|
|
100
|
+
"createdDate": attrs.get("createdDate") or "",
|
|
85
101
|
}
|
|
86
102
|
)
|
|
87
103
|
return out
|
|
88
104
|
|
|
89
105
|
|
|
90
|
-
def
|
|
91
|
-
|
|
106
|
+
def partition(versions: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
|
|
107
|
+
"""Split versions into (editable, blocking, terminal) buckets."""
|
|
108
|
+
ed, bl, te = [], [], []
|
|
92
109
|
for v in versions:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
st = v["state"]
|
|
111
|
+
if st in EDITABLE_STATES:
|
|
112
|
+
ed.append(v)
|
|
113
|
+
elif st in BLOCKING_STATES:
|
|
114
|
+
bl.append(v)
|
|
115
|
+
elif st in TERMINAL_STATES:
|
|
116
|
+
te.append(v)
|
|
117
|
+
return ed, bl, te
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def pick_latest_editable(editable: list[dict]) -> dict:
|
|
121
|
+
"""Latest by createdDate; ties broken by id desc."""
|
|
122
|
+
return sorted(
|
|
123
|
+
editable,
|
|
124
|
+
key=lambda v: (v.get("createdDate", ""), v.get("id", "")),
|
|
125
|
+
reverse=True,
|
|
126
|
+
)[0]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def highest_terminal_tuple(te: list[dict]) -> tuple[int, int, int] | None:
|
|
130
|
+
best: tuple[int, int, int] | None = None
|
|
131
|
+
for v in te:
|
|
132
|
+
if not SEM_RE.match(v["versionString"] or ""):
|
|
97
133
|
continue
|
|
98
134
|
t = to_tuple(v["versionString"])
|
|
99
135
|
if best is None or t > best:
|
|
@@ -132,7 +168,27 @@ def fail_not_higher(v: str, published: tuple[int, int, int]) -> None:
|
|
|
132
168
|
raise SystemExit(1)
|
|
133
169
|
|
|
134
170
|
|
|
135
|
-
def
|
|
171
|
+
def guard_not_downgrade(v: str, te: list[dict]) -> None:
|
|
172
|
+
top = highest_terminal_tuple(te)
|
|
173
|
+
if top is not None and to_tuple(v) <= top:
|
|
174
|
+
fail_not_higher(v, top)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def patch_version_string(slot_id: str, version: str, token: str):
|
|
178
|
+
body = {
|
|
179
|
+
"data": {
|
|
180
|
+
"type": "appStoreVersions",
|
|
181
|
+
"id": slot_id,
|
|
182
|
+
"attributes": {"versionString": version},
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return request(
|
|
186
|
+
"PATCH", f"/appStoreVersions/{slot_id}", token,
|
|
187
|
+
json_body=body, allow_status={409},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def create_version(app_id: str, version: str, token: str):
|
|
136
192
|
body = {
|
|
137
193
|
"data": {
|
|
138
194
|
"type": "appStoreVersions",
|
|
@@ -146,40 +202,74 @@ def create_version(app_id: str, version: str, token: str) -> str:
|
|
|
146
202
|
},
|
|
147
203
|
}
|
|
148
204
|
}
|
|
149
|
-
|
|
150
|
-
"POST",
|
|
151
|
-
|
|
152
|
-
token,
|
|
153
|
-
json_body=body,
|
|
154
|
-
allow_status={409},
|
|
205
|
+
return request(
|
|
206
|
+
"POST", "/appStoreVersions", token,
|
|
207
|
+
json_body=body, allow_status={409},
|
|
155
208
|
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if match and match["state"] in EDITABLE_STATES:
|
|
163
|
-
print(
|
|
164
|
-
f"[decision] REUSE {version} (state={match['state']}, "
|
|
165
|
-
f"id={match['id']}) — recovered from 409 on create",
|
|
166
|
-
file=sys.stderr,
|
|
167
|
-
)
|
|
168
|
-
return match["id"]
|
|
169
|
-
if match and match["state"] in BLOCKING_STATES:
|
|
170
|
-
fail_blocking(version)
|
|
171
|
-
if match:
|
|
172
|
-
fail_terminal(version, match["state"])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _reenter_on_409(
|
|
212
|
+
app_id: str, version: str, token: str, depth: int, label: str,
|
|
213
|
+
) -> dict:
|
|
214
|
+
if depth >= MAX_RECURSIONS:
|
|
173
215
|
raise SystemExit(
|
|
174
|
-
f"
|
|
175
|
-
f"{version} was found on re-query:\n{resp.text[:500]}"
|
|
216
|
+
f"{label} returned 409 after {depth} recursion(s); giving up."
|
|
176
217
|
)
|
|
177
|
-
new_id = resp.json()["data"]["id"]
|
|
178
218
|
print(
|
|
179
|
-
f"
|
|
219
|
+
f"{label} returned 409; re-fetching and redeciding "
|
|
220
|
+
f"(depth={depth + 1})",
|
|
180
221
|
file=sys.stderr,
|
|
181
222
|
)
|
|
182
|
-
return
|
|
223
|
+
return decide(app_id, version, token, depth + 1)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def decide(
|
|
227
|
+
app_id: str, project_version: str, token: str, depth: int = 0,
|
|
228
|
+
) -> dict:
|
|
229
|
+
"""Decision tree; re-enters up to MAX_RECURSIONS on 409 races."""
|
|
230
|
+
versions = fetch_versions(app_id, token)
|
|
231
|
+
editable, blocking, terminal = partition(versions)
|
|
232
|
+
|
|
233
|
+
# 1. Blocking match on the target versionString (awaiting dev release).
|
|
234
|
+
for v in blocking:
|
|
235
|
+
if same_version(v["versionString"], project_version):
|
|
236
|
+
fail_blocking(project_version)
|
|
237
|
+
|
|
238
|
+
# 2. Editable slot exists — reuse (directly or via PATCH rename).
|
|
239
|
+
if editable:
|
|
240
|
+
latest = pick_latest_editable(editable)
|
|
241
|
+
if same_version(latest["versionString"], project_version):
|
|
242
|
+
_log(f"REUSE {project_version} (state={latest['state']}, "
|
|
243
|
+
f"id={latest['id']})")
|
|
244
|
+
return _result("REUSE", project_version, latest["id"])
|
|
245
|
+
|
|
246
|
+
guard_not_downgrade(project_version, terminal)
|
|
247
|
+
old_v, prior = latest["versionString"], latest["state"]
|
|
248
|
+
resp = patch_version_string(latest["id"], project_version, token)
|
|
249
|
+
if resp.status_code == 409:
|
|
250
|
+
return _reenter_on_409(
|
|
251
|
+
app_id, project_version, token, depth,
|
|
252
|
+
f"PATCH /appStoreVersions/{latest['id']}",
|
|
253
|
+
)
|
|
254
|
+
new_id = resp.json()["data"]["id"]
|
|
255
|
+
_log(f"REUSE {project_version} (patched from {old_v}, id={new_id}, "
|
|
256
|
+
f"prior_state={prior})")
|
|
257
|
+
return _result("REUSE", project_version, new_id)
|
|
258
|
+
|
|
259
|
+
# 3. No editable slots — check terminal collision, then CREATE.
|
|
260
|
+
for v in terminal:
|
|
261
|
+
if same_version(v["versionString"], project_version):
|
|
262
|
+
fail_terminal(project_version, v["state"])
|
|
263
|
+
guard_not_downgrade(project_version, terminal)
|
|
264
|
+
|
|
265
|
+
resp = create_version(app_id, project_version, token)
|
|
266
|
+
if resp.status_code == 409:
|
|
267
|
+
return _reenter_on_409(
|
|
268
|
+
app_id, project_version, token, depth, "POST /appStoreVersions",
|
|
269
|
+
)
|
|
270
|
+
new_id = resp.json()["data"]["id"]
|
|
271
|
+
_log(f"CREATE {project_version} -> appStoreVersion id={new_id}")
|
|
272
|
+
return _result("CREATE", project_version, new_id)
|
|
183
273
|
|
|
184
274
|
|
|
185
275
|
def main() -> None:
|
|
@@ -190,39 +280,7 @@ def main() -> None:
|
|
|
190
280
|
project_version = normalize(env("PROJECT_MARKETING_VERSION"))
|
|
191
281
|
|
|
192
282
|
token = make_jwt(key_id, issuer_id, key_path)
|
|
193
|
-
|
|
194
|
-
match = next(
|
|
195
|
-
(v for v in versions if v["versionString"] == project_version), None
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
if match and match["state"] in EDITABLE_STATES:
|
|
199
|
-
print(
|
|
200
|
-
f"[decision] REUSE {project_version} (state={match['state']}, "
|
|
201
|
-
f"id={match['id']})",
|
|
202
|
-
file=sys.stderr,
|
|
203
|
-
)
|
|
204
|
-
result = {
|
|
205
|
-
"decision": "REUSE",
|
|
206
|
-
"versionString": project_version,
|
|
207
|
-
"appStoreVersionId": match["id"],
|
|
208
|
-
}
|
|
209
|
-
elif match and match["state"] in BLOCKING_STATES:
|
|
210
|
-
fail_blocking(project_version)
|
|
211
|
-
return # unreachable
|
|
212
|
-
elif match and match["state"] in TERMINAL_STATES:
|
|
213
|
-
fail_terminal(project_version, match["state"])
|
|
214
|
-
return # unreachable
|
|
215
|
-
else:
|
|
216
|
-
top = highest_terminal(versions)
|
|
217
|
-
if top is not None and to_tuple(project_version) <= top:
|
|
218
|
-
fail_not_higher(project_version, top)
|
|
219
|
-
new_id = create_version(app_id, project_version, token)
|
|
220
|
-
result = {
|
|
221
|
-
"decision": "CREATE",
|
|
222
|
-
"versionString": project_version,
|
|
223
|
-
"appStoreVersionId": new_id,
|
|
224
|
-
}
|
|
225
|
-
|
|
283
|
+
result = decide(app_id, project_version, token)
|
|
226
284
|
print(json.dumps(result))
|
|
227
285
|
|
|
228
286
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Set TestFlight "What to Test" (betaBuildLocalization.whatsNew) for the build
|
|
4
|
+
just uploaded by this workflow.
|
|
5
|
+
|
|
6
|
+
Runs after the altool upload step. Polls ASC until the build record appears
|
|
7
|
+
(altool upload returns before Apple has fully ingested the build), then either
|
|
8
|
+
PATCHes an existing betaBuildLocalization for the requested locale or POSTs a
|
|
9
|
+
new one.
|
|
10
|
+
|
|
11
|
+
Environment:
|
|
12
|
+
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
|
|
13
|
+
APP_STORE_APPLE_ID -- numeric ASC app id
|
|
14
|
+
MARKETING_VERSION -- e.g. "1.2.3"
|
|
15
|
+
BUILD_NUMBER -- e.g. "42"
|
|
16
|
+
TESTFLIGHT_WHATS_NEW -- release notes text (empty = skip)
|
|
17
|
+
TESTFLIGHT_LOCALE -- locale code, default "en-US"
|
|
18
|
+
|
|
19
|
+
Non-fatal by design: if the build can't be found within the polling window we
|
|
20
|
+
emit a ::warning:: and exit 0, since the TestFlight upload itself may still
|
|
21
|
+
have succeeded.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
|
|
30
|
+
from asc_common import get_json, make_jwt, request
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
POLL_ATTEMPTS = 20
|
|
34
|
+
POLL_FIRST_DELAY = 10
|
|
35
|
+
POLL_DELAY = 30
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _require_env(name: str) -> str:
|
|
39
|
+
value = os.environ.get(name, "").strip()
|
|
40
|
+
if not value:
|
|
41
|
+
print(f"::error::{name} env var is required", file=sys.stderr)
|
|
42
|
+
raise SystemExit(1)
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _find_build(token: str, app_id: str, marketing_version: str, build_number: str) -> str | None:
|
|
47
|
+
"""Return the build id matching (marketing_version, build_number), or None."""
|
|
48
|
+
params = {
|
|
49
|
+
"filter[app]": app_id,
|
|
50
|
+
"filter[preReleaseVersion.version]": marketing_version,
|
|
51
|
+
"filter[version]": build_number,
|
|
52
|
+
"limit": "5",
|
|
53
|
+
}
|
|
54
|
+
data = get_json("/builds", token, params=params)
|
|
55
|
+
items = data.get("data") or []
|
|
56
|
+
return items[0].get("id") if items else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _poll_for_build(token: str, app_id: str, marketing_version: str, build_number: str) -> str | None:
|
|
60
|
+
"""Poll ASC for the uploaded build. Returns build id or None on timeout."""
|
|
61
|
+
for attempt in range(POLL_ATTEMPTS):
|
|
62
|
+
delay = POLL_FIRST_DELAY if attempt == 0 else POLL_DELAY
|
|
63
|
+
print(
|
|
64
|
+
f"Waiting {delay}s for build {marketing_version} ({build_number}) to appear "
|
|
65
|
+
f"(attempt {attempt + 1}/{POLL_ATTEMPTS})",
|
|
66
|
+
file=sys.stderr,
|
|
67
|
+
)
|
|
68
|
+
time.sleep(delay)
|
|
69
|
+
build_id = _find_build(token, app_id, marketing_version, build_number)
|
|
70
|
+
if build_id:
|
|
71
|
+
print(f"Found build id {build_id}", file=sys.stderr)
|
|
72
|
+
return build_id
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _existing_localization(token: str, build_id: str, locale: str) -> str | None:
|
|
77
|
+
"""Return the betaBuildLocalization id for `locale` on this build, or None."""
|
|
78
|
+
data = get_json(f"/builds/{build_id}/betaBuildLocalizations", token)
|
|
79
|
+
for item in data.get("data") or []:
|
|
80
|
+
if (item.get("attributes") or {}).get("locale") == locale:
|
|
81
|
+
return item.get("id")
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _patch_localization(token: str, localization_id: str, whats_new: str) -> None:
|
|
86
|
+
body = {
|
|
87
|
+
"data": {
|
|
88
|
+
"type": "betaBuildLocalizations",
|
|
89
|
+
"id": localization_id,
|
|
90
|
+
"attributes": {"whatsNew": whats_new},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
request("PATCH", f"/betaBuildLocalizations/{localization_id}", token, json_body=body)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _create_localization(token: str, build_id: str, locale: str, whats_new: str) -> None:
|
|
97
|
+
body = {
|
|
98
|
+
"data": {
|
|
99
|
+
"type": "betaBuildLocalizations",
|
|
100
|
+
"attributes": {"locale": locale, "whatsNew": whats_new},
|
|
101
|
+
"relationships": {
|
|
102
|
+
"build": {"data": {"type": "builds", "id": build_id}},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
request("POST", "/betaBuildLocalizations", token, json_body=body)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> int:
|
|
110
|
+
whats_new = os.environ.get("TESTFLIGHT_WHATS_NEW", "")
|
|
111
|
+
if not whats_new.strip():
|
|
112
|
+
print("empty whatsNew; skipping", file=sys.stderr)
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
key_id = _require_env("ASC_KEY_ID")
|
|
116
|
+
issuer_id = _require_env("ASC_ISSUER_ID")
|
|
117
|
+
key_path = _require_env("ASC_KEY_PATH")
|
|
118
|
+
app_id = _require_env("APP_STORE_APPLE_ID")
|
|
119
|
+
marketing_version = _require_env("MARKETING_VERSION")
|
|
120
|
+
build_number = _require_env("BUILD_NUMBER")
|
|
121
|
+
locale = os.environ.get("TESTFLIGHT_LOCALE", "").strip() or "en-US"
|
|
122
|
+
|
|
123
|
+
token = make_jwt(key_id, issuer_id, key_path)
|
|
124
|
+
|
|
125
|
+
build_id = _poll_for_build(token, app_id, marketing_version, build_number)
|
|
126
|
+
if not build_id:
|
|
127
|
+
print(
|
|
128
|
+
f"::warning::build {marketing_version} ({build_number}) not found in ASC "
|
|
129
|
+
f"after {POLL_ATTEMPTS} polls; skipping whatsNew update",
|
|
130
|
+
file=sys.stderr,
|
|
131
|
+
)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
existing = _existing_localization(token, build_id, locale)
|
|
135
|
+
if existing:
|
|
136
|
+
_patch_localization(token, existing, whats_new)
|
|
137
|
+
action = "updated"
|
|
138
|
+
else:
|
|
139
|
+
_create_localization(token, build_id, locale, whats_new)
|
|
140
|
+
action = "created"
|
|
141
|
+
print(f"Set betaBuildLocalization whatsNew for build {build_id} locale {locale} ({action})")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
sys.exit(main())
|