@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.
@@ -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