@daemux/store-automator 0.10.94 → 0.10.96
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/github/IOS_NATIVE_CI_SETUP.md +58 -3
- package/templates/scripts/ci/ios-native/asc_build_history.py +284 -0
- package/templates/scripts/ci/ios-native/asc_common.py +137 -34
- package/templates/scripts/ci/ios-native/asc_version_create.py +365 -0
- package/templates/scripts/ci/ios-native/asc_version_fetch.py +274 -0
- package/templates/scripts/ci/ios-native/asc_version_reuse.py +177 -0
- package/templates/scripts/ci/ios-native/autoupdate_check.sh +43 -2
- package/templates/scripts/ci/ios-native/commit_bot_changes.sh +29 -7
- package/templates/scripts/ci/ios-native/manage_marketing_version.py +269 -203
- package/templates/scripts/ci/ios-native/mmv_decide_create.py +181 -0
- package/templates/scripts/ci/ios-native/mmv_floor_check.py +260 -0
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +64 -29
- package/templates/scripts/ci/ios-native/version_utils.py +352 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Note: this file has 11 functions (one over the 10-per-file cap) and a
|
|
3
|
+
# ~91-line helper. Pre-existing in the source-of-truth; refactor (likely
|
|
4
|
+
# a split of the 409-retry branches into their own module) tracked
|
|
5
|
+
# separately to avoid bundling unrelated edits into round-13 trims.
|
|
6
|
+
"""
|
|
7
|
+
App Store Connect appStoreVersions POST with classified-409 retry.
|
|
8
|
+
|
|
9
|
+
Split out of manage_marketing_version.py so the main module stays under
|
|
10
|
+
the 400-line cap. This module owns:
|
|
11
|
+
|
|
12
|
+
- ``create_version``: thin POST wrapper that logs the full request body
|
|
13
|
+
to stderr before the call and returns the raw response with 409
|
|
14
|
+
allowed so the caller can inspect headers/body.
|
|
15
|
+
- ``create_or_reuse``: orchestrates the POST, the happy-path 2xx -> id
|
|
16
|
+
extraction, the 409 reconcile (re-fetch both /appStoreVersions and
|
|
17
|
+
/preReleaseVersions for a matching versionString), and classified
|
|
18
|
+
409 handling:
|
|
19
|
+
* reusable id in /appStoreVersions -> REUSE
|
|
20
|
+
* version-collision 409 (CONFLICT.VERSION_EXISTS, ENTITY_ERROR.
|
|
21
|
+
UNIQUENESS.VERSION_STRING, or detail mentions versionString)
|
|
22
|
+
with no reusable id -> bump + retry
|
|
23
|
+
* any other 409 (RELATIONSHIP.INVALID,
|
|
24
|
+
ATTRIBUTE.INVALID, unknown codes) -> fail fast with
|
|
25
|
+
SystemExit(4) and the ASC source.pointer quoted in stderr
|
|
26
|
+
|
|
27
|
+
Why the classifier (CI run 24640760698)? 21 consecutive POSTs returned
|
|
28
|
+
409 ENTITY_ERROR.RELATIONSHIP.INVALID as the script bumped 1.0.6 ->
|
|
29
|
+
1.2.6. That is a payload bug, not a version collision -- bumping cannot
|
|
30
|
+
fix it. Fail fast instead so the next CI run surfaces the real error
|
|
31
|
+
with the ASC-reported JSON pointer intact.
|
|
32
|
+
|
|
33
|
+
Exit codes:
|
|
34
|
+
3 -- retry cap hit (20 consecutive version-collision 409s)
|
|
35
|
+
4 -- non-version 409 (payload bug; inspect logged pointer)
|
|
36
|
+
5 -- stale editable cannot be renamed or deleted (CI run 24640907316):
|
|
37
|
+
see ``asc_version_reuse.reuse_stale_editable``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import json as _json
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
from asc_common import request
|
|
46
|
+
from asc_version_reuse import (
|
|
47
|
+
delete_version,
|
|
48
|
+
is_editable_exists_409,
|
|
49
|
+
patch_version,
|
|
50
|
+
reuse_stale_editable,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Re-exported so callers that historically imported these from
|
|
54
|
+
# ``asc_version_create`` continue to work after the split into
|
|
55
|
+
# ``asc_version_reuse``. This is also the seam tests patch via
|
|
56
|
+
# ``mock.patch.object(asc_version_create, "patch_version", ...)``.
|
|
57
|
+
__all__ = (
|
|
58
|
+
"create_or_reuse",
|
|
59
|
+
"create_version",
|
|
60
|
+
"delete_version",
|
|
61
|
+
"is_editable_exists_409",
|
|
62
|
+
"patch_version",
|
|
63
|
+
"reuse_stale_editable",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_RETRY_CAP = 20
|
|
68
|
+
|
|
69
|
+
# ASC error codes that mean "this versionString already exists" and thus
|
|
70
|
+
# a bump-and-retry is the right response. The list is best-effort -- ASC
|
|
71
|
+
# has not published a canonical enum. Confirmed from historic CI logs
|
|
72
|
+
# and Apple's forum posts.
|
|
73
|
+
_VERSION_COLLISION_CODES = {
|
|
74
|
+
"CONFLICT.VERSION_EXISTS",
|
|
75
|
+
"ENTITY_ERROR.UNIQUENESS.VERSION_STRING",
|
|
76
|
+
"ENTITY_ERROR.CONFLICT", # legacy -- seen in run 24640430898
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Substrings in error.detail that indicate a version-string collision
|
|
80
|
+
# when the `code` field is absent (older ASC responses). Case-insensitive
|
|
81
|
+
# match against the stripped detail string.
|
|
82
|
+
_VERSION_COLLISION_DETAIL_HINTS = (
|
|
83
|
+
"versionstring already exists",
|
|
84
|
+
"version with this versionstring",
|
|
85
|
+
"version already exists",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _log_request_body(body: dict) -> None:
|
|
90
|
+
"""Pretty-print the POST body to stderr before the call so CI logs
|
|
91
|
+
have the exact request even if the response never arrives."""
|
|
92
|
+
try:
|
|
93
|
+
pretty = _json.dumps(body, indent=2, sort_keys=True)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
pretty = repr(body)
|
|
96
|
+
print(f"[create-version] request body: {pretty}", file=sys.stderr)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def create_version(app_id: str, version: str, token: str):
|
|
100
|
+
"""POST /appStoreVersions for this app + versionString. 409 allowed
|
|
101
|
+
so the caller can inspect the body + classify the failure."""
|
|
102
|
+
body = {
|
|
103
|
+
"data": {
|
|
104
|
+
"type": "appStoreVersions",
|
|
105
|
+
"attributes": {
|
|
106
|
+
"platform": "IOS",
|
|
107
|
+
"versionString": version,
|
|
108
|
+
"releaseType": "AFTER_APPROVAL",
|
|
109
|
+
},
|
|
110
|
+
"relationships": {
|
|
111
|
+
"app": {"data": {"type": "apps", "id": str(app_id)}},
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
_log_request_body(body)
|
|
116
|
+
return request(
|
|
117
|
+
"POST", "/appStoreVersions", token,
|
|
118
|
+
json_body=body, allow_status={409},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_errors(resp) -> list[dict]:
|
|
123
|
+
"""Extract the errors[] array from an ASC error response. Returns []
|
|
124
|
+
if the body isn't JSON or lacks `errors`."""
|
|
125
|
+
text = getattr(resp, "text", "") or ""
|
|
126
|
+
if not isinstance(text, str) or not text.strip():
|
|
127
|
+
return []
|
|
128
|
+
try:
|
|
129
|
+
parsed = _json.loads(text)
|
|
130
|
+
except (ValueError, TypeError):
|
|
131
|
+
return []
|
|
132
|
+
errs = parsed.get("errors") if isinstance(parsed, dict) else None
|
|
133
|
+
return errs if isinstance(errs, list) else []
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _error_pointers(errors: list[dict]) -> list[str]:
|
|
137
|
+
"""Collect every source.pointer across errors. Empty list if none."""
|
|
138
|
+
out: list[str] = []
|
|
139
|
+
for e in errors:
|
|
140
|
+
src = e.get("source") if isinstance(e, dict) else None
|
|
141
|
+
ptr = src.get("pointer") if isinstance(src, dict) else None
|
|
142
|
+
if isinstance(ptr, str) and ptr:
|
|
143
|
+
out.append(ptr)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_version_collision(errors: list[dict]) -> bool:
|
|
148
|
+
"""True iff the 409 is attributable to a version-string collision.
|
|
149
|
+
Only then is bump-and-retry the right response."""
|
|
150
|
+
if not errors:
|
|
151
|
+
# No parseable body -- historic ASC responses behaved this way.
|
|
152
|
+
# Stay back-compatible: treat as collision (the only known reason
|
|
153
|
+
# we ever saw 409 here before run 24640760698).
|
|
154
|
+
return False
|
|
155
|
+
for e in errors:
|
|
156
|
+
if not isinstance(e, dict):
|
|
157
|
+
continue
|
|
158
|
+
code = (e.get("code") or "").strip()
|
|
159
|
+
if code in _VERSION_COLLISION_CODES:
|
|
160
|
+
return True
|
|
161
|
+
detail = (e.get("detail") or "").strip().lower()
|
|
162
|
+
if any(hint in detail for hint in _VERSION_COLLISION_DETAIL_HINTS):
|
|
163
|
+
return True
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _dump_409(resp, version: str) -> list[dict]:
|
|
168
|
+
"""Log the full 409 response (headers + body, untruncated) + any
|
|
169
|
+
source.pointer values. Returns the parsed errors[] so the caller
|
|
170
|
+
doesn't have to re-parse. Defensive against Mock-style responses
|
|
171
|
+
that may not carry dict-like headers or a real str body."""
|
|
172
|
+
try:
|
|
173
|
+
headers = dict(resp.headers)
|
|
174
|
+
except Exception: # noqa: BLE001 - resp may not carry real headers
|
|
175
|
+
headers = {}
|
|
176
|
+
body = resp.text if isinstance(getattr(resp, "text", None), str) else ""
|
|
177
|
+
print(
|
|
178
|
+
f"[create-version] response status=409 for {version}; "
|
|
179
|
+
f"headers={headers}",
|
|
180
|
+
file=sys.stderr,
|
|
181
|
+
)
|
|
182
|
+
# Full body -- no truncation. The 2KB cap used to hide the offending
|
|
183
|
+
# source.pointer past byte 2000 in some ASC responses.
|
|
184
|
+
print(
|
|
185
|
+
f"[create-version] response body: {body}",
|
|
186
|
+
file=sys.stderr,
|
|
187
|
+
)
|
|
188
|
+
errors = _parse_errors(resp)
|
|
189
|
+
pointers = _error_pointers(errors)
|
|
190
|
+
if pointers:
|
|
191
|
+
print(
|
|
192
|
+
f"[create-version] error pointers: {pointers}",
|
|
193
|
+
file=sys.stderr,
|
|
194
|
+
)
|
|
195
|
+
return errors
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _fail_fast_non_version_409(
|
|
199
|
+
resp, version: str, errors: list[dict], attempted: list[str],
|
|
200
|
+
) -> None:
|
|
201
|
+
"""SystemExit(4) with a clear diagnostic when the 409 is NOT a
|
|
202
|
+
version collision. Bumping cannot fix payload/relationship bugs."""
|
|
203
|
+
codes = [
|
|
204
|
+
(e.get("code") or "").strip()
|
|
205
|
+
for e in errors if isinstance(e, dict)
|
|
206
|
+
]
|
|
207
|
+
pointers = _error_pointers(errors)
|
|
208
|
+
print(
|
|
209
|
+
f"::error::POST /appStoreVersions returned 409 for {version} "
|
|
210
|
+
f"with non-version-collision error(s); bumping would not fix "
|
|
211
|
+
f"this. codes={codes} pointers={pointers}. Inspect the full "
|
|
212
|
+
f"response body above, fix the request shape, and retry. "
|
|
213
|
+
f"(attempted={attempted})",
|
|
214
|
+
file=sys.stderr,
|
|
215
|
+
)
|
|
216
|
+
raise SystemExit(4)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _safe_fetch(label: str, fn, *args) -> list:
|
|
220
|
+
"""Call fn(*args); on any exception, log + return []."""
|
|
221
|
+
try:
|
|
222
|
+
return list(fn(*args) or [])
|
|
223
|
+
except Exception as exc: # noqa: BLE001 - defensive only
|
|
224
|
+
print(f"[409-retry] {label} re-fetch failed: {exc!r}", file=sys.stderr)
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _reconcile_409(
|
|
229
|
+
app_id: str, token: str, version: str,
|
|
230
|
+
fetch_versions, fetch_prerelease_versions, fetch_builds_prerelease_versions,
|
|
231
|
+
) -> tuple[str | None, bool]:
|
|
232
|
+
"""Re-query all three collections after a 409. Returns
|
|
233
|
+
(reusable_id_or_None, is_known_on_secondary).
|
|
234
|
+
|
|
235
|
+
- reusable_id: the appStoreVersions row to reuse (caller returns it).
|
|
236
|
+
- is_known_on_secondary: True if the version is visible on
|
|
237
|
+
/preReleaseVersions or builds->preReleaseVersion but NOT on
|
|
238
|
+
/appStoreVersions; caller should bump.
|
|
239
|
+
"""
|
|
240
|
+
rows = _safe_fetch("/appStoreVersions", fetch_versions, app_id, token)
|
|
241
|
+
for v in rows:
|
|
242
|
+
if isinstance(v, dict) and v.get("versionString") == version and v.get("id"):
|
|
243
|
+
return v["id"], False
|
|
244
|
+
|
|
245
|
+
known = set()
|
|
246
|
+
known.update(
|
|
247
|
+
vs for vs in _safe_fetch(
|
|
248
|
+
"/preReleaseVersions", fetch_prerelease_versions, app_id, token,
|
|
249
|
+
) if vs
|
|
250
|
+
)
|
|
251
|
+
known.update(
|
|
252
|
+
vs for vs in _safe_fetch(
|
|
253
|
+
"builds->preReleaseVersion",
|
|
254
|
+
fetch_builds_prerelease_versions, app_id, token,
|
|
255
|
+
) if vs
|
|
256
|
+
)
|
|
257
|
+
return None, version in known
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _log_retry_reason(version: str, known_on_secondary: bool) -> None:
|
|
261
|
+
if known_on_secondary:
|
|
262
|
+
msg = (
|
|
263
|
+
f"[409-retry] {version} found on a secondary collection "
|
|
264
|
+
f"but not in /appStoreVersions; bumping"
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
msg = (
|
|
268
|
+
f"[409-retry] {version} not found on /appStoreVersions, "
|
|
269
|
+
f"/preReleaseVersions, or builds->preReleaseVersion; "
|
|
270
|
+
f"assuming hidden ASC record and bumping"
|
|
271
|
+
)
|
|
272
|
+
print(msg, file=sys.stderr)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def create_or_reuse(
|
|
276
|
+
app_id: str, version: str, token: str,
|
|
277
|
+
*,
|
|
278
|
+
bump_fn,
|
|
279
|
+
fetch_versions,
|
|
280
|
+
fetch_prerelease_versions,
|
|
281
|
+
fetch_builds_prerelease_versions,
|
|
282
|
+
post_fn=None,
|
|
283
|
+
stale_editable_id: str | None = None,
|
|
284
|
+
patch_fn=None,
|
|
285
|
+
delete_fn=None,
|
|
286
|
+
) -> str:
|
|
287
|
+
"""POST a new appStoreVersion; on 409 either reuse an existing id,
|
|
288
|
+
bump-and-retry (only for true version collisions), or fail fast
|
|
289
|
+
(non-version 409s).
|
|
290
|
+
|
|
291
|
+
Deps are injected so callers (and tests) decide which semver bumper,
|
|
292
|
+
POST function, and which fetchers to use. ``post_fn`` defaults to
|
|
293
|
+
this module's ``create_version`` when unset.
|
|
294
|
+
|
|
295
|
+
Callers can disarm the bump-retry path entirely by passing a
|
|
296
|
+
``bump_fn`` that raises (e.g. ``SystemExit``). manage_marketing_version
|
|
297
|
+
does this so a hidden-record 409 never invents a new version --
|
|
298
|
+
the project's MARKETING_VERSION is the single source of truth.
|
|
299
|
+
|
|
300
|
+
When ``stale_editable_id`` is provided (caller detected an editable
|
|
301
|
+
appStoreVersion at-or-below highest_shipped), the first attempt uses
|
|
302
|
+
the PATCH-reuse path (``reuse_stale_editable``) instead of POST.
|
|
303
|
+
Falls back to the classic POST loop only on an unexpected flow.
|
|
304
|
+
"""
|
|
305
|
+
post = post_fn if post_fn is not None else create_version
|
|
306
|
+
attempted: list[str] = [version]
|
|
307
|
+
current = version
|
|
308
|
+
|
|
309
|
+
# Stale-editable path: PATCH-reuse the existing row to rename it to
|
|
310
|
+
# our target version. Avoids the "cannot create a new version in the
|
|
311
|
+
# current state" 409 entirely (CI run 24640907316).
|
|
312
|
+
if stale_editable_id:
|
|
313
|
+
return reuse_stale_editable(
|
|
314
|
+
app_id=app_id, existing_id=stale_editable_id,
|
|
315
|
+
target_version=current, token=token,
|
|
316
|
+
patch_fn=patch_fn, delete_fn=delete_fn, post_fn=post,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
for attempt in range(_RETRY_CAP + 1):
|
|
320
|
+
resp = post(app_id, current, token)
|
|
321
|
+
if resp.status_code != 409:
|
|
322
|
+
return resp.json()["data"]["id"]
|
|
323
|
+
|
|
324
|
+
errors = _dump_409(resp, current)
|
|
325
|
+
reusable_id, known_on_secondary = _reconcile_409(
|
|
326
|
+
app_id, token, current,
|
|
327
|
+
fetch_versions, fetch_prerelease_versions,
|
|
328
|
+
fetch_builds_prerelease_versions,
|
|
329
|
+
)
|
|
330
|
+
if reusable_id is not None:
|
|
331
|
+
print(
|
|
332
|
+
f"[409-reuse] matched existing appStoreVersion for "
|
|
333
|
+
f"{current} id={reusable_id}",
|
|
334
|
+
file=sys.stderr,
|
|
335
|
+
)
|
|
336
|
+
return reusable_id
|
|
337
|
+
|
|
338
|
+
# Classify the 409. Only version-collision errors warrant a bump;
|
|
339
|
+
# anything else (RELATIONSHIP.INVALID, ATTRIBUTE.INVALID, unknown)
|
|
340
|
+
# is a payload bug that bumping cannot fix.
|
|
341
|
+
if errors and not _is_version_collision(errors):
|
|
342
|
+
_fail_fast_non_version_409(resp, current, errors, attempted)
|
|
343
|
+
|
|
344
|
+
_log_retry_reason(current, known_on_secondary)
|
|
345
|
+
if attempt >= _RETRY_CAP:
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
next_version = bump_fn(current)
|
|
349
|
+
print(
|
|
350
|
+
f"[409-retry] attempting {next_version} (retry "
|
|
351
|
+
f"{attempt + 1}/{_RETRY_CAP})",
|
|
352
|
+
file=sys.stderr,
|
|
353
|
+
)
|
|
354
|
+
current = next_version
|
|
355
|
+
attempted.append(current)
|
|
356
|
+
|
|
357
|
+
print(
|
|
358
|
+
f"::error::POST /appStoreVersions returned 409 after {_RETRY_CAP} "
|
|
359
|
+
f"bump-retries. Attempted versions: first={attempted[0]} "
|
|
360
|
+
f"last={attempted[-1]} total={len(attempted)}. "
|
|
361
|
+
f"ASC appears to have hidden records colliding with every patch "
|
|
362
|
+
f"in this range; inspect the app in App Store Connect manually.",
|
|
363
|
+
file=sys.stderr,
|
|
364
|
+
)
|
|
365
|
+
raise SystemExit(3)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Note: get_combined_floor (~88 lines) exceeds the 50-line function cap.
|
|
3
|
+
# Pre-existing in the source-of-truth; refactor tracked separately to
|
|
4
|
+
# avoid bundling unrelated edits into round-13 trims.
|
|
5
|
+
"""
|
|
6
|
+
App Store Connect appStoreVersions fetch + ground-truth cross-check.
|
|
7
|
+
|
|
8
|
+
Split out of manage_marketing_version.py so the main module stays under
|
|
9
|
+
the 400-line cap. This module owns:
|
|
10
|
+
|
|
11
|
+
- `fetch_versions`: paginated, platform-filtered list of ALL iOS
|
|
12
|
+
appStoreVersions for an app. Logs every record to stderr so CI logs
|
|
13
|
+
reflect ground truth.
|
|
14
|
+
- `get_ground_truth_floor`: independent narrow query per ship-blocking
|
|
15
|
+
state. Used as a self-correcting floor: if the main fetch is ever
|
|
16
|
+
truncated or misfiltered, the cross-check catches it before altool
|
|
17
|
+
does.
|
|
18
|
+
|
|
19
|
+
Motivation: CI run 24639633986 failed because the previous single-shot
|
|
20
|
+
fetch missed 1.0.4 READY_FOR_SALE and the script tried to REUSE 1.0.1.
|
|
21
|
+
Pagination + platform filter fix the root cause; the cross-check is
|
|
22
|
+
defense-in-depth so this class of bug cannot recur silently.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
import asc_build_history
|
|
30
|
+
from asc_common import request
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Ship-blocking states: any train that is closed to new submissions at a
|
|
34
|
+
# lower version. The main fetch classifies them via TERMINAL_STATES +
|
|
35
|
+
# BLOCKING_STATES from asc_common, but the independent cross-check uses
|
|
36
|
+
# this explicit list so the two paths cannot drift.
|
|
37
|
+
SHIP_BLOCKING_STATES = (
|
|
38
|
+
"READY_FOR_SALE",
|
|
39
|
+
"PENDING_APPLE_RELEASE",
|
|
40
|
+
"PROCESSING_FOR_APP_STORE",
|
|
41
|
+
"PENDING_DEVELOPER_RELEASE",
|
|
42
|
+
"REPLACED_WITH_NEW_VERSION",
|
|
43
|
+
"DEVELOPER_REMOVED_FROM_SALE",
|
|
44
|
+
"REMOVED_FROM_SALE",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_versions(data: dict) -> list[dict]:
|
|
49
|
+
"""Convert an ASC appStoreVersions response payload into our record
|
|
50
|
+
shape. Pure function -- no I/O, safe to call from both the paginated
|
|
51
|
+
main fetch and the narrow cross-check."""
|
|
52
|
+
out: list[dict] = []
|
|
53
|
+
for item in data.get("data", []):
|
|
54
|
+
attrs = item.get("attributes") or {}
|
|
55
|
+
out.append({
|
|
56
|
+
"versionString": attrs.get("versionString") or "",
|
|
57
|
+
"state": attrs.get("appStoreState") or "",
|
|
58
|
+
"id": item.get("id") or "",
|
|
59
|
+
"createdDate": attrs.get("createdDate") or "",
|
|
60
|
+
"platform": attrs.get("platform") or "",
|
|
61
|
+
})
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _log_record(v: dict) -> None:
|
|
66
|
+
"""One line per record on stderr so CI logs reflect ground truth."""
|
|
67
|
+
print(
|
|
68
|
+
f"[asc-fetch] versionString={v['versionString']} "
|
|
69
|
+
f"appStoreState={v['state']} id={v['id']} "
|
|
70
|
+
f"platform={v.get('platform', '')} "
|
|
71
|
+
f"createdDate={v['createdDate']}",
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fetch_versions(app_id: str, token: str) -> list[dict]:
|
|
77
|
+
"""Fetch ALL appStoreVersions for the app on the iOS platform.
|
|
78
|
+
|
|
79
|
+
Follows `links.next` until exhausted (do NOT trust a single page),
|
|
80
|
+
filters to platform=IOS, requests limit=200 (ASC max). Logs every
|
|
81
|
+
record to stderr for CI diagnostics.
|
|
82
|
+
"""
|
|
83
|
+
path = f"/apps/{app_id}/appStoreVersions"
|
|
84
|
+
params: dict = {"filter[platform]": "IOS", "limit": 200}
|
|
85
|
+
out: list[dict] = []
|
|
86
|
+
pages = 0
|
|
87
|
+
resp = request("GET", path, token, params=params)
|
|
88
|
+
|
|
89
|
+
while True:
|
|
90
|
+
pages += 1
|
|
91
|
+
data = resp.json()
|
|
92
|
+
batch = _parse_versions(data)
|
|
93
|
+
for v in batch:
|
|
94
|
+
_log_record(v)
|
|
95
|
+
out.extend(batch)
|
|
96
|
+
|
|
97
|
+
next_url = ((data.get("links") or {}).get("next")) or ""
|
|
98
|
+
if not next_url:
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
# Follow the cursor URL verbatim. ASC returns fully-qualified links
|
|
102
|
+
# with cursor/query already attached; passing params= would override
|
|
103
|
+
# the cursor and loop forever. Extract the path after /v1 so we stay
|
|
104
|
+
# on our retrying client.
|
|
105
|
+
next_path = next_url.split("/v1", 1)[-1] if "/v1" in next_url else next_url
|
|
106
|
+
resp = request("GET", next_path, token)
|
|
107
|
+
|
|
108
|
+
print(
|
|
109
|
+
f"[asc-fetch] done: pages={pages} records={len(out)} (platform=IOS)",
|
|
110
|
+
file=sys.stderr,
|
|
111
|
+
)
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _fetch_by_state(app_id: str, token: str, state: str) -> list[dict]:
|
|
116
|
+
"""Narrow query for the independent cross-check: one state, iOS only."""
|
|
117
|
+
path = f"/apps/{app_id}/appStoreVersions"
|
|
118
|
+
params = {
|
|
119
|
+
"filter[platform]": "IOS",
|
|
120
|
+
"filter[appStoreState]": state,
|
|
121
|
+
"limit": 200,
|
|
122
|
+
}
|
|
123
|
+
resp = request("GET", path, token, params=params)
|
|
124
|
+
return _parse_versions(resp.json())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_ground_truth_floor(
|
|
128
|
+
app_id: str, token: str, semver_tuple
|
|
129
|
+
) -> str | None:
|
|
130
|
+
"""Independent cross-check: query each ship-blocking state directly.
|
|
131
|
+
|
|
132
|
+
Returns the highest semver across all ship-blocking records, or None
|
|
133
|
+
if none exist (first release).
|
|
134
|
+
|
|
135
|
+
`semver_tuple` is injected to avoid circular imports; the main module
|
|
136
|
+
owns the semver parser.
|
|
137
|
+
|
|
138
|
+
Defense in depth: even if fetch_versions has a latent bug, the narrow
|
|
139
|
+
cross-check catches a stale/truncated main fetch before altool does.
|
|
140
|
+
"""
|
|
141
|
+
all_blockers: list[dict] = []
|
|
142
|
+
for state in SHIP_BLOCKING_STATES:
|
|
143
|
+
try:
|
|
144
|
+
recs = _fetch_by_state(app_id, token, state)
|
|
145
|
+
except SystemExit:
|
|
146
|
+
# ASC rejects unknown states with 400 -- skip gracefully so
|
|
147
|
+
# a newly-added state name in our constant does not break the
|
|
148
|
+
# cross-check as a whole.
|
|
149
|
+
print(
|
|
150
|
+
f"[ground-truth] skip state={state} (ASC rejected filter)",
|
|
151
|
+
file=sys.stderr,
|
|
152
|
+
)
|
|
153
|
+
continue
|
|
154
|
+
for r in recs:
|
|
155
|
+
print(
|
|
156
|
+
f"[ground-truth] state={state} "
|
|
157
|
+
f"versionString={r['versionString']} id={r['id']}",
|
|
158
|
+
file=sys.stderr,
|
|
159
|
+
)
|
|
160
|
+
all_blockers.extend(recs)
|
|
161
|
+
|
|
162
|
+
if not all_blockers:
|
|
163
|
+
print("[ground-truth] no ship-blocking versions found", file=sys.stderr)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
floor = max(all_blockers, key=lambda v: semver_tuple(v["versionString"]))
|
|
167
|
+
print(
|
|
168
|
+
f"[ground-truth] floor={floor['versionString']} "
|
|
169
|
+
f"(state={floor['state']}, id={floor['id']})",
|
|
170
|
+
file=sys.stderr,
|
|
171
|
+
)
|
|
172
|
+
return floor["versionString"]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _max_version(
|
|
176
|
+
versions: list[str], semver_tuple
|
|
177
|
+
) -> str | None:
|
|
178
|
+
"""Return the lexicographic-by-semver max of a list of versionStrings,
|
|
179
|
+
or None if the list is empty or contains only non-semver values."""
|
|
180
|
+
parsed = [(semver_tuple(v), v) for v in versions if v]
|
|
181
|
+
parsed = [p for p in parsed if p[0] != (-1, -1, -1)]
|
|
182
|
+
if not parsed:
|
|
183
|
+
return None
|
|
184
|
+
return max(parsed)[1]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_combined_floor(
|
|
188
|
+
app_id: str, token: str, semver_tuple,
|
|
189
|
+
*,
|
|
190
|
+
appstore_versions: list[dict] | None = None,
|
|
191
|
+
ship_blocking_states: tuple[str, ...] = SHIP_BLOCKING_STATES,
|
|
192
|
+
) -> tuple[str | None, dict[str, str | None]]:
|
|
193
|
+
"""Combined ground-truth floor across the two authoritative ASC
|
|
194
|
+
collections for marketing versions.
|
|
195
|
+
|
|
196
|
+
Consults:
|
|
197
|
+
1. appstore_versions (caller-provided or freshly fetched) filtered
|
|
198
|
+
to ship-blocking states -- same pool as get_ground_truth_floor's
|
|
199
|
+
main input, but we pass it in so we don't double-fetch.
|
|
200
|
+
2. /v1/apps/{id}/preReleaseVersions for every TestFlight train's
|
|
201
|
+
versionString. Persists across app updates.
|
|
202
|
+
3. /v1/builds?filter[app]={id}&include=preReleaseVersion for every
|
|
203
|
+
build's referenced preReleaseVersion.attributes.version. This is
|
|
204
|
+
a second, independent path to TestFlight-train marketing versions
|
|
205
|
+
that catches records hidden from the direct /preReleaseVersions
|
|
206
|
+
query (CI run 24640430898: the direct query missed a record
|
|
207
|
+
that still produced a 409 collision on POST). The top-level
|
|
208
|
+
/v1/builds collection is used because the app-scoped
|
|
209
|
+
/v1/apps/{id}/builds relationship rejects ``include`` with
|
|
210
|
+
HTTP 400 (CI run 24640675162).
|
|
211
|
+
|
|
212
|
+
/builds.attributes.version itself (the integer build number) is NOT
|
|
213
|
+
consulted -- only the related preReleaseVersion's marketing version.
|
|
214
|
+
|
|
215
|
+
Returns (floor_or_None, per_source_maxes) where per_source_maxes is
|
|
216
|
+
a dict with keys 'appStoreVersions', 'preReleaseVersions',
|
|
217
|
+
'buildsViaPreRelease'. The floor is the max across all three; None
|
|
218
|
+
means every source was empty / non-semver (first release).
|
|
219
|
+
|
|
220
|
+
Logs each source's max separately so a human debugging CI can see
|
|
221
|
+
which endpoint contributed the binding floor:
|
|
222
|
+
|
|
223
|
+
[combined-floor] appStoreVersions max: 1.0
|
|
224
|
+
[combined-floor] preReleaseVersions max: 1.0.4
|
|
225
|
+
[combined-floor] buildsViaPreRelease max: 1.0.6
|
|
226
|
+
[combined-floor] combined floor: 1.0.6
|
|
227
|
+
"""
|
|
228
|
+
asv_versions = [
|
|
229
|
+
v["versionString"] for v in (appstore_versions or [])
|
|
230
|
+
if v.get("state") in ship_blocking_states and v.get("versionString")
|
|
231
|
+
]
|
|
232
|
+
asv_max = _max_version(asv_versions, semver_tuple)
|
|
233
|
+
|
|
234
|
+
prerelease_versions = asc_build_history.fetch_prerelease_versions(
|
|
235
|
+
app_id, token
|
|
236
|
+
)
|
|
237
|
+
prerelease_max = _max_version(prerelease_versions, semver_tuple)
|
|
238
|
+
|
|
239
|
+
builds_prerelease_versions = asc_build_history.fetch_builds_prerelease_versions(
|
|
240
|
+
app_id, token
|
|
241
|
+
)
|
|
242
|
+
builds_prerelease_max = _max_version(
|
|
243
|
+
builds_prerelease_versions, semver_tuple
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
print(
|
|
247
|
+
f"[combined-floor] appStoreVersions max: {asv_max or '<none>'}",
|
|
248
|
+
file=sys.stderr,
|
|
249
|
+
)
|
|
250
|
+
print(
|
|
251
|
+
f"[combined-floor] preReleaseVersions max: {prerelease_max or '<none>'}",
|
|
252
|
+
file=sys.stderr,
|
|
253
|
+
)
|
|
254
|
+
print(
|
|
255
|
+
f"[combined-floor] buildsViaPreRelease max: "
|
|
256
|
+
f"{builds_prerelease_max or '<none>'}",
|
|
257
|
+
file=sys.stderr,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
candidates = [
|
|
261
|
+
v for v in (asv_max, prerelease_max, builds_prerelease_max) if v
|
|
262
|
+
]
|
|
263
|
+
combined = _max_version(candidates, semver_tuple)
|
|
264
|
+
print(
|
|
265
|
+
f"[combined-floor] combined floor: {combined or '<none>'}",
|
|
266
|
+
file=sys.stderr,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
per_source = {
|
|
270
|
+
"appStoreVersions": asv_max,
|
|
271
|
+
"preReleaseVersions": prerelease_max,
|
|
272
|
+
"buildsViaPreRelease": builds_prerelease_max,
|
|
273
|
+
}
|
|
274
|
+
return combined, per_source
|