@daemux/store-automator 0.10.71 → 0.10.73
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/scripts/ci/ios-native/manage_marketing_version.py +107 -199
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +167 -0
- package/templates/scripts/ci/ios-native/set_testflight_whats_new.py +0 -146
|
@@ -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.73"
|
|
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.73",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
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
|
-
|
|
5
|
+
Faithful port of gowalk-step/manage_version.py. The CI -- not the Xcode
|
|
6
|
+
project -- decides the marketing version. The archive step overrides the
|
|
7
|
+
pbxproj MARKETING_VERSION at build time via `MARKETING_VERSION=...` on the
|
|
8
|
+
xcodebuild command line.
|
|
9
|
+
|
|
10
|
+
Algorithm:
|
|
11
|
+
1. Fetch all appStoreVersions for the app (no sort, no filter).
|
|
12
|
+
2. No versions at all -> first release: CREATE "1.0.0".
|
|
13
|
+
3. Pick latest by createdDate.
|
|
14
|
+
- state == PENDING_DEVELOPER_RELEASE -> fail (must be released first).
|
|
15
|
+
- state == READY_FOR_SALE -> auto-bump and CREATE the next
|
|
16
|
+
version with rollover (patch<10 -> +1; patch>=10 -> reset, minor+1;
|
|
17
|
+
minor>=10 -> reset, major+1).
|
|
18
|
+
- anything else (PREPARE_FOR_SUBMISSION, WAITING_FOR_REVIEW, IN_REVIEW,
|
|
19
|
+
REJECTED, ...) -> REUSE it.
|
|
20
|
+
4. 409 on CREATE (race) -> re-fetch, match by versionString, REUSE that id.
|
|
21
|
+
|
|
22
|
+
Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID.
|
|
16
23
|
Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
|
|
17
|
-
Stderr:
|
|
24
|
+
Stderr: [decision] log lines + ::error:: on fatal conditions.
|
|
18
25
|
"""
|
|
19
26
|
|
|
20
27
|
from __future__ import annotations
|
|
@@ -24,18 +31,10 @@ import os
|
|
|
24
31
|
import re
|
|
25
32
|
import sys
|
|
26
33
|
|
|
27
|
-
from asc_common import
|
|
28
|
-
BLOCKING_STATES,
|
|
29
|
-
EDITABLE_STATES,
|
|
30
|
-
TERMINAL_STATES,
|
|
31
|
-
get_json,
|
|
32
|
-
make_jwt,
|
|
33
|
-
request,
|
|
34
|
-
)
|
|
34
|
+
from asc_common import get_json, make_jwt, request
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
|
|
38
|
-
MAX_RECURSIONS = 2
|
|
39
38
|
|
|
40
39
|
|
|
41
40
|
def _log(msg: str) -> None:
|
|
@@ -58,136 +57,50 @@ def env(name: str) -> str:
|
|
|
58
57
|
return val
|
|
59
58
|
|
|
60
59
|
|
|
61
|
-
def
|
|
62
|
-
|
|
60
|
+
def calculate_next_version(current_version: str) -> str:
|
|
61
|
+
"""Rollover bump: patch+1; patch>=10 resets to 0 and minor+1;
|
|
62
|
+
minor>=10 resets both to 0 and major+1. Accepts "M.m" or "M.m.p"."""
|
|
63
|
+
m = SEM_RE.match(current_version or "")
|
|
63
64
|
if not m:
|
|
64
|
-
|
|
65
|
-
f"::error::
|
|
66
|
-
f"semantic version (expected MAJOR.MINOR[.PATCH])."
|
|
67
|
-
file=sys.stderr,
|
|
65
|
+
raise SystemExit(
|
|
66
|
+
f"::error::Latest App Store versionString {current_version!r} is "
|
|
67
|
+
f"not a valid semantic version (expected MAJOR.MINOR[.PATCH])."
|
|
68
68
|
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
major = int(m.group(1))
|
|
70
|
+
minor = int(m.group(2))
|
|
71
|
+
patch = int(m.group(3) or "0")
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return (0, 0, 0)
|
|
77
|
-
return int(m.group(1)), int(m.group(2)), int(m.group(3) or "0")
|
|
73
|
+
new_patch = patch + 1
|
|
74
|
+
new_minor = minor
|
|
75
|
+
new_major = major
|
|
78
76
|
|
|
77
|
+
if new_patch >= 10:
|
|
78
|
+
new_patch = 0
|
|
79
|
+
new_minor = minor + 1
|
|
80
|
+
if new_minor >= 10:
|
|
81
|
+
new_minor = 0
|
|
82
|
+
new_patch = 0
|
|
83
|
+
new_major = major + 1
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
"""1.2 == 1.2.0 via normalized tuple comparison."""
|
|
82
|
-
return to_tuple(a) == to_tuple(b)
|
|
85
|
+
return f"{new_major}.{new_minor}.{new_patch}"
|
|
83
86
|
|
|
84
87
|
|
|
85
88
|
def fetch_versions(app_id: str, token: str) -> list[dict]:
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
token,
|
|
90
|
-
params={"limit": 200, "filter[platform]": "IOS"},
|
|
91
|
-
)
|
|
89
|
+
"""All appStoreVersions for the app. Mirrors gowalk-step: no sort, no
|
|
90
|
+
limit, no platform filter -- we do client-side selection."""
|
|
91
|
+
data = get_json(f"/apps/{app_id}/appStoreVersions", token)
|
|
92
92
|
out: list[dict] = []
|
|
93
93
|
for item in data.get("data", []):
|
|
94
94
|
attrs = item.get("attributes") or {}
|
|
95
|
-
out.append(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
)
|
|
95
|
+
out.append({
|
|
96
|
+
"versionString": attrs.get("versionString") or "",
|
|
97
|
+
"state": attrs.get("appStoreState") or "",
|
|
98
|
+
"id": item.get("id") or "",
|
|
99
|
+
"createdDate": attrs.get("createdDate") or "",
|
|
100
|
+
})
|
|
103
101
|
return out
|
|
104
102
|
|
|
105
103
|
|
|
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 = [], [], []
|
|
109
|
-
for v in versions:
|
|
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 ""):
|
|
133
|
-
continue
|
|
134
|
-
t = to_tuple(v["versionString"])
|
|
135
|
-
if best is None or t > best:
|
|
136
|
-
best = t
|
|
137
|
-
return best
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def fail_terminal(v: str, state: str) -> None:
|
|
141
|
-
print(
|
|
142
|
-
f"::error::Version {v} is already {state} on App Store Connect. "
|
|
143
|
-
f"Bump MARKETING_VERSION in the Xcode project to publish a new "
|
|
144
|
-
f"release.",
|
|
145
|
-
file=sys.stderr,
|
|
146
|
-
)
|
|
147
|
-
raise SystemExit(1)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def fail_blocking(v: str) -> None:
|
|
151
|
-
print(
|
|
152
|
-
f"::error::Version {v} is PENDING_DEVELOPER_RELEASE on App Store "
|
|
153
|
-
f"Connect. Release it manually (or bump MARKETING_VERSION in the "
|
|
154
|
-
f"Xcode project), then retry.",
|
|
155
|
-
file=sys.stderr,
|
|
156
|
-
)
|
|
157
|
-
raise SystemExit(1)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def fail_not_higher(v: str, published: tuple[int, int, int]) -> None:
|
|
161
|
-
p = ".".join(str(x) for x in published)
|
|
162
|
-
print(
|
|
163
|
-
f"::error::MARKETING_VERSION ({v}) is not higher than the published "
|
|
164
|
-
f"version ({p}) on App Store Connect. Bump MARKETING_VERSION in the "
|
|
165
|
-
f"Xcode project.",
|
|
166
|
-
file=sys.stderr,
|
|
167
|
-
)
|
|
168
|
-
raise SystemExit(1)
|
|
169
|
-
|
|
170
|
-
|
|
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
104
|
def create_version(app_id: str, version: str, token: str):
|
|
192
105
|
body = {
|
|
193
106
|
"data": {
|
|
@@ -208,68 +121,26 @@ def create_version(app_id: str, version: str, token: str):
|
|
|
208
121
|
)
|
|
209
122
|
|
|
210
123
|
|
|
211
|
-
def
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
124
|
+
def create_or_reuse(app_id: str, version: str, token: str) -> str:
|
|
125
|
+
"""POST a new version; on 409 race, re-fetch and return existing id.
|
|
126
|
+
Returns the appStoreVersion id."""
|
|
127
|
+
resp = create_version(app_id, version, token)
|
|
128
|
+
if resp.status_code != 409:
|
|
129
|
+
return resp.json()["data"]["id"]
|
|
130
|
+
|
|
131
|
+
# Race: someone else created it between our GET and POST. Find by name.
|
|
218
132
|
print(
|
|
219
|
-
f"
|
|
220
|
-
|
|
133
|
+
f"POST /appStoreVersions returned 409 for {version}; "
|
|
134
|
+
"re-fetching to locate the existing slot.",
|
|
221
135
|
file=sys.stderr,
|
|
222
136
|
)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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)
|
|
137
|
+
for v in fetch_versions(app_id, token):
|
|
138
|
+
if v["versionString"] == version:
|
|
139
|
+
return v["id"]
|
|
140
|
+
raise SystemExit(
|
|
141
|
+
f"::error::POST /appStoreVersions returned 409 for {version} but "
|
|
142
|
+
f"no matching versionString was found on re-fetch."
|
|
143
|
+
)
|
|
273
144
|
|
|
274
145
|
|
|
275
146
|
def main() -> None:
|
|
@@ -277,11 +148,48 @@ def main() -> None:
|
|
|
277
148
|
issuer_id = env("ASC_ISSUER_ID")
|
|
278
149
|
key_path = env("ASC_KEY_PATH")
|
|
279
150
|
app_id = env("APP_STORE_APPLE_ID")
|
|
280
|
-
project_version = normalize(env("PROJECT_MARKETING_VERSION"))
|
|
281
151
|
|
|
282
152
|
token = make_jwt(key_id, issuer_id, key_path)
|
|
283
|
-
|
|
284
|
-
|
|
153
|
+
versions = fetch_versions(app_id, token)
|
|
154
|
+
|
|
155
|
+
# 1. First ever release.
|
|
156
|
+
if not versions:
|
|
157
|
+
new_version = "1.0.0"
|
|
158
|
+
new_id = create_or_reuse(app_id, new_version, token)
|
|
159
|
+
_log(f"CREATE {new_version} (first release) -> appStoreVersion id={new_id}")
|
|
160
|
+
print(json.dumps(_result("CREATE", new_version, new_id)))
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# 2. Pick the latest by Apple's createdDate.
|
|
164
|
+
latest = max(versions, key=lambda v: v.get("createdDate", ""))
|
|
165
|
+
state = latest["state"]
|
|
166
|
+
version_string = latest["versionString"]
|
|
167
|
+
version_id = latest["id"]
|
|
168
|
+
|
|
169
|
+
# 3. Blocking: pending developer release.
|
|
170
|
+
if state == "PENDING_DEVELOPER_RELEASE":
|
|
171
|
+
print(
|
|
172
|
+
f"::error::Latest App Store version {version_string} is "
|
|
173
|
+
f"PENDING_DEVELOPER_RELEASE. Release it in App Store Connect, "
|
|
174
|
+
f"then retry.",
|
|
175
|
+
file=sys.stderr,
|
|
176
|
+
)
|
|
177
|
+
raise SystemExit(1)
|
|
178
|
+
|
|
179
|
+
# 4. Live: auto-bump to next version.
|
|
180
|
+
if state == "READY_FOR_SALE":
|
|
181
|
+
new_version = calculate_next_version(version_string)
|
|
182
|
+
new_id = create_or_reuse(app_id, new_version, token)
|
|
183
|
+
_log(
|
|
184
|
+
f"CREATE {new_version} (previous={version_string}, "
|
|
185
|
+
f"previous_state={state}) -> appStoreVersion id={new_id}"
|
|
186
|
+
)
|
|
187
|
+
print(json.dumps(_result("CREATE", new_version, new_id)))
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# 5. Editable/in-review/rejected/etc: reuse as-is.
|
|
191
|
+
_log(f"REUSE {version_string} (state={state}, id={version_id})")
|
|
192
|
+
print(json.dumps(_result("REUSE", version_string, version_id)))
|
|
285
193
|
|
|
286
194
|
|
|
287
195
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Set the App Store "What's New" text (appStoreVersionLocalizations.whatsNew) for
|
|
4
|
+
the draft App Store version slot prepared by manage_marketing_version.py.
|
|
5
|
+
|
|
6
|
+
Mirrors gowalk-step's `fastlane run set_changelog` behavior against the ASC
|
|
7
|
+
REST API (no fastlane dependency here):
|
|
8
|
+
|
|
9
|
+
* Uses the pre-resolved APP_STORE_VERSION_ID env var from the
|
|
10
|
+
"Resolve App Store version slot" step -- no polling, no lookup by version.
|
|
11
|
+
* Skips the first release (1.0 / 1.0.0 / 0.0 / 0.0.0) -- there are no prior
|
|
12
|
+
release notes to announce.
|
|
13
|
+
* Skips when the slot is not in a state that permits editing whatsNew.
|
|
14
|
+
* Either PATCHes the existing localization for the requested locale or POSTs
|
|
15
|
+
a new one pointing at the appStoreVersion.
|
|
16
|
+
|
|
17
|
+
Non-fatal by design: any failure is reported as a ::warning:: and the action
|
|
18
|
+
continues. The TestFlight upload itself has already succeeded by this point.
|
|
19
|
+
|
|
20
|
+
Environment:
|
|
21
|
+
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
|
|
22
|
+
MARKETING_VERSION -- e.g. "1.2.3"
|
|
23
|
+
APP_STORE_VERSION_ID -- appStoreVersion id (REUSE or CREATE)
|
|
24
|
+
APP_STORE_WHATS_NEW -- release notes text (empty = skip)
|
|
25
|
+
APP_STORE_LOCALE -- locale code, default "en-US"
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
from asc_common import get_json, make_jwt, request
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# gowalk-step parity: skip whatsNew for the initial release.
|
|
37
|
+
SKIP_VERSIONS = {"1.0", "1.0.0", "0.0", "0.0.0"}
|
|
38
|
+
|
|
39
|
+
# Apple permits editing appStoreVersionLocalizations.whatsNew only while the
|
|
40
|
+
# version is in an editable state. WAITING_FOR_REVIEW / IN_REVIEW /
|
|
41
|
+
# READY_FOR_SALE etc. are not editable -- skip non-fatally.
|
|
42
|
+
WHATSNEW_EDITABLE_STATES = {
|
|
43
|
+
"PREPARE_FOR_SUBMISSION",
|
|
44
|
+
"REJECTED",
|
|
45
|
+
"METADATA_REJECTED",
|
|
46
|
+
"DEVELOPER_REJECTED",
|
|
47
|
+
"INVALID_BINARY",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _require_env(name: str) -> str:
|
|
52
|
+
value = os.environ.get(name, "").strip()
|
|
53
|
+
if not value:
|
|
54
|
+
print(f"::error::{name} env var is required", file=sys.stderr)
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _warn(msg: str) -> None:
|
|
60
|
+
print(f"::warning::{msg}", file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _log(msg: str) -> None:
|
|
64
|
+
print(msg, file=sys.stderr)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _patch_localization(token: str, localization_id: str, whats_new: str) -> None:
|
|
68
|
+
request(
|
|
69
|
+
"PATCH",
|
|
70
|
+
f"/appStoreVersionLocalizations/{localization_id}",
|
|
71
|
+
token,
|
|
72
|
+
json_body={
|
|
73
|
+
"data": {
|
|
74
|
+
"type": "appStoreVersionLocalizations",
|
|
75
|
+
"id": localization_id,
|
|
76
|
+
"attributes": {"whatsNew": whats_new},
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _create_localization(
|
|
83
|
+
token: str, version_id: str, locale: str, whats_new: str
|
|
84
|
+
) -> str:
|
|
85
|
+
resp = request(
|
|
86
|
+
"POST",
|
|
87
|
+
"/appStoreVersionLocalizations",
|
|
88
|
+
token,
|
|
89
|
+
json_body={
|
|
90
|
+
"data": {
|
|
91
|
+
"type": "appStoreVersionLocalizations",
|
|
92
|
+
"attributes": {"locale": locale, "whatsNew": whats_new},
|
|
93
|
+
"relationships": {
|
|
94
|
+
"appStoreVersion": {
|
|
95
|
+
"data": {"type": "appStoreVersions", "id": version_id}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
return resp.json()["data"]["id"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> int:
|
|
105
|
+
whats_new = os.environ.get("APP_STORE_WHATS_NEW", "")
|
|
106
|
+
if not whats_new.strip():
|
|
107
|
+
_log("APP_STORE_WHATS_NEW empty; skipping.")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
version = _require_env("MARKETING_VERSION")
|
|
111
|
+
version_id = _require_env("APP_STORE_VERSION_ID")
|
|
112
|
+
locale = os.environ.get("APP_STORE_LOCALE", "").strip() or "en-US"
|
|
113
|
+
|
|
114
|
+
if version in SKIP_VERSIONS:
|
|
115
|
+
_log(f"Skipping whatsNew for first release {version}.")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
token = make_jwt(
|
|
119
|
+
_require_env("ASC_KEY_ID"),
|
|
120
|
+
_require_env("ASC_ISSUER_ID"),
|
|
121
|
+
_require_env("ASC_KEY_PATH"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
ver = get_json(f"/appStoreVersions/{version_id}", token)
|
|
125
|
+
state = (ver.get("data") or {}).get("attributes", {}).get("appStoreState", "")
|
|
126
|
+
if state not in WHATSNEW_EDITABLE_STATES:
|
|
127
|
+
_warn(
|
|
128
|
+
f"appStoreVersion {version} is {state}; whatsNew not editable in "
|
|
129
|
+
f"this state, skipping."
|
|
130
|
+
)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
locs = get_json(
|
|
134
|
+
f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
|
|
135
|
+
)
|
|
136
|
+
existing = next(
|
|
137
|
+
(
|
|
138
|
+
item
|
|
139
|
+
for item in (locs.get("data") or [])
|
|
140
|
+
if (item.get("attributes") or {}).get("locale") == locale
|
|
141
|
+
),
|
|
142
|
+
None,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if existing:
|
|
146
|
+
_patch_localization(token, existing["id"], whats_new)
|
|
147
|
+
_log(
|
|
148
|
+
f"PATCHed appStoreVersionLocalization {existing['id']} whatsNew "
|
|
149
|
+
f"for {version} ({locale})"
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
new_id = _create_localization(token, version_id, locale, whats_new)
|
|
153
|
+
_log(
|
|
154
|
+
f"CREATEd appStoreVersionLocalization {new_id} whatsNew for "
|
|
155
|
+
f"{version} ({locale})"
|
|
156
|
+
)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
try:
|
|
162
|
+
sys.exit(main())
|
|
163
|
+
except SystemExit:
|
|
164
|
+
raise
|
|
165
|
+
except Exception as exc: # non-fatal per gowalk-step's set_changelog parity
|
|
166
|
+
_warn(f"whatsNew setter failed (non-fatal): {exc!r}")
|
|
167
|
+
sys.exit(0)
|
|
@@ -1,146 +0,0 @@
|
|
|
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())
|