gowalk-cicd 1.0.0

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.
Files changed (84) hide show
  1. package/CLAUDE.md +94 -0
  2. package/README.md +417 -0
  3. package/action/.daemux-version +1 -0
  4. package/action/MIGRATION.md +232 -0
  5. package/action/action.yml +973 -0
  6. package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
  7. package/action/prompts/generate_descriptions.prompt.yml +59 -0
  8. package/action/scripts/app_context_scanner.py +239 -0
  9. package/action/scripts/asc_build_history.py +284 -0
  10. package/action/scripts/asc_common.py +246 -0
  11. package/action/scripts/asc_metadata_applier.py +368 -0
  12. package/action/scripts/asc_metadata_detector.py +250 -0
  13. package/action/scripts/asc_version_create.py +365 -0
  14. package/action/scripts/asc_version_fetch.py +274 -0
  15. package/action/scripts/asc_version_reuse.py +177 -0
  16. package/action/scripts/auto_detect.py +391 -0
  17. package/action/scripts/autoupdate_check.sh +118 -0
  18. package/action/scripts/cert_factory.py +190 -0
  19. package/action/scripts/cfg_io.py +27 -0
  20. package/action/scripts/cfg_resolve.py +147 -0
  21. package/action/scripts/commit_bot_changes.sh +82 -0
  22. package/action/scripts/creds_store.py +328 -0
  23. package/action/scripts/keychain.py +103 -0
  24. package/action/scripts/lookup_app_id.py +51 -0
  25. package/action/scripts/manage_marketing_version.py +337 -0
  26. package/action/scripts/metadata_constants.py +102 -0
  27. package/action/scripts/mmv_decide_create.py +181 -0
  28. package/action/scripts/mmv_floor_check.py +265 -0
  29. package/action/scripts/next_build_number.py +195 -0
  30. package/action/scripts/pbxproj_editor.py +220 -0
  31. package/action/scripts/prepare_signing.py +223 -0
  32. package/action/scripts/profile_io.py +64 -0
  33. package/action/scripts/profile_manager.py +307 -0
  34. package/action/scripts/read_config.py +315 -0
  35. package/action/scripts/resolve_marketing_version.py +173 -0
  36. package/action/scripts/set_app_store_whats_new.py +317 -0
  37. package/action/scripts/team_resolver.py +130 -0
  38. package/action/scripts/test_app_context_scanner.py +167 -0
  39. package/action/scripts/test_asc_build_history.py +221 -0
  40. package/action/scripts/test_asc_builds_prerelease.py +219 -0
  41. package/action/scripts/test_asc_common_timeouts.py +121 -0
  42. package/action/scripts/test_asc_metadata_applier.py +850 -0
  43. package/action/scripts/test_asc_metadata_detector.py +445 -0
  44. package/action/scripts/test_auto_detect.py +432 -0
  45. package/action/scripts/test_cfg_resolve_credentials.py +48 -0
  46. package/action/scripts/test_compute_next_version.py +95 -0
  47. package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
  48. package/action/scripts/test_compute_next_version_strictness.py +67 -0
  49. package/action/scripts/test_fetch_versions.py +163 -0
  50. package/action/scripts/test_ground_truth_floor.py +104 -0
  51. package/action/scripts/test_manage_marketing_version.py +134 -0
  52. package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
  53. package/action/scripts/test_manage_marketing_version_floor.py +170 -0
  54. package/action/scripts/test_manage_marketing_version_match.py +187 -0
  55. package/action/scripts/test_mmv_409_classifier.py +231 -0
  56. package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
  57. package/action/scripts/test_mmv_decide_create.py +119 -0
  58. package/action/scripts/test_mmv_floor_check.py +158 -0
  59. package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
  60. package/action/scripts/test_mmv_helpers.py +57 -0
  61. package/action/scripts/test_next_build_number.py +174 -0
  62. package/action/scripts/test_read_config_auto_detect.py +257 -0
  63. package/action/scripts/test_resolve_marketing_version.py +298 -0
  64. package/action/scripts/test_reuse_stale_editable.py +182 -0
  65. package/action/scripts/test_set_app_store_whats_new.py +71 -0
  66. package/action/scripts/test_team_fallback_wiring.py +157 -0
  67. package/action/scripts/test_team_resolver.py +249 -0
  68. package/action/scripts/tests_common.py +167 -0
  69. package/action/scripts/version_utils.py +373 -0
  70. package/android-action/.daemux-version +1 -0
  71. package/android-action/action.yml +92 -0
  72. package/android-action/scripts/android_config.py +130 -0
  73. package/android-action/scripts/bitrise_deploy.py +160 -0
  74. package/android-action/scripts/find_bundle.py +25 -0
  75. package/android-action/scripts/play_preflight.py +69 -0
  76. package/android-action/scripts/resolve_android.py +70 -0
  77. package/android-action/scripts/sign_bundle.py +70 -0
  78. package/android-action/scripts/test_android_config.py +97 -0
  79. package/android-action/scripts/test_bitrise_deploy.py +124 -0
  80. package/android-action/scripts/test_sign_bundle.py +82 -0
  81. package/bin/cli.mjs +63 -0
  82. package/package.json +57 -0
  83. package/src/install.mjs +208 -0
  84. package/templates/deploy.yml +150 -0
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Detect empty App Store Connect metadata fields across all localizations.
4
+
5
+ Fetches:
6
+ * the editable appInfo record (app-level metadata resource) and its
7
+ appInfoLocalizations (name, subtitle, privacyPolicyUrl, privacyChoicesUrl),
8
+ * the pre-resolved appStoreVersion's appStoreVersionLocalizations
9
+ (description, keywords, promotionalText, whatsNew, supportUrl, marketingUrl).
10
+
11
+ Computes a per-locale diff of empty fields, excluding URL fields unconditionally
12
+ (SKIP_URL_FIELDS), and emits a single JSON document to stdout used by the AI
13
+ prompt and the applier downstream.
14
+
15
+ Fail-open: any uncaught failure reports a ``::warning::`` and emits
16
+ ``{"empty_fields": {}}`` so downstream steps short-circuit cleanly. Non-fatal
17
+ by design -- the TestFlight upload itself has already succeeded.
18
+
19
+ Environment:
20
+ ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
21
+ APP_STORE_APPLE_ID -- numeric app id
22
+ APP_STORE_VERSION_ID -- appStoreVersion id from
23
+ manage_marketing_version.py
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import sys
30
+
31
+ from asc_common import get_json, make_jwt
32
+ from metadata_constants import (
33
+ APP_INFO_LOC_FIELDS,
34
+ APP_LEVEL_FIELDS,
35
+ EDITABLE_APP_INFO_STATES,
36
+ EDITABLE_VERSION_STATES,
37
+ SKIP_URL_FIELDS,
38
+ VERSION_LEVEL_FIELDS,
39
+ VERSION_LOC_FIELDS,
40
+ log,
41
+ require_env,
42
+ warn,
43
+ )
44
+
45
+
46
+ def fetch_editable_app_info(app_id: str, token: str) -> dict | None:
47
+ """Return the first appInfo record in an editable state, or None.
48
+
49
+ Apple returns multiple appInfo records (current live + next draft). We
50
+ want the draft. No server-side filter is 100% reliable across Apple's
51
+ state transitions, so we fetch all and classify client-side.
52
+ """
53
+ data = get_json(f"/apps/{app_id}/appInfos", token)
54
+ for item in data.get("data") or []:
55
+ attrs = item.get("attributes") or {}
56
+ state = attrs.get("appStoreState") or attrs.get("state") or ""
57
+ if state in EDITABLE_APP_INFO_STATES:
58
+ return {"id": item.get("id") or "", "state": state}
59
+ return None
60
+
61
+
62
+ def _locs_to_map(data: list[dict], fields: tuple[str, ...]) -> dict[str, dict]:
63
+ out: dict[str, dict] = {}
64
+ for item in data:
65
+ attrs = item.get("attributes") or {}
66
+ locale = attrs.get("locale") or ""
67
+ if not locale:
68
+ continue
69
+ entry: dict = {"id": item.get("id") or ""}
70
+ entry.update({f: attrs.get(f) for f in fields})
71
+ out[locale] = entry
72
+ return out
73
+
74
+
75
+ def fetch_app_info_localizations(app_info_id: str, token: str) -> dict[str, dict]:
76
+ data = get_json(
77
+ f"/appInfos/{app_info_id}/appInfoLocalizations",
78
+ token,
79
+ params={"limit": 50},
80
+ )
81
+ return _locs_to_map(data.get("data") or [], APP_INFO_LOC_FIELDS)
82
+
83
+
84
+ def fetch_version_localizations(version_id: str, token: str) -> dict[str, dict]:
85
+ data = get_json(
86
+ f"/appStoreVersions/{version_id}/appStoreVersionLocalizations",
87
+ token,
88
+ params={"limit": 50},
89
+ )
90
+ return _locs_to_map(data.get("data") or [], VERSION_LOC_FIELDS)
91
+
92
+
93
+ def fetch_version_state(version_id: str, token: str) -> str:
94
+ """Return the current appStoreState of the version, or '' on failure.
95
+
96
+ Used to decide whether PATCHes on appStoreVersionLocalizations are safe.
97
+ Non-editable states (e.g. IN_REVIEW, READY_FOR_SALE) cause Apple to return
98
+ 409 Conflict on version-level localization PATCHes even though the version
99
+ record still exists.
100
+ """
101
+ data = get_json(f"/appStoreVersions/{version_id}", token)
102
+ attrs = (data.get("data") or {}).get("attributes") or {}
103
+ return attrs.get("appStoreState") or ""
104
+
105
+
106
+ def _clear_version_ids(state: dict) -> None:
107
+ """Zero out every locale's version_localization_id in-place.
108
+
109
+ Invoked when the app store version is in a non-editable state -- the
110
+ applier treats None ids as "skip version-level fields for this locale",
111
+ so the downstream PATCH is safely bypassed while app-level fields
112
+ (name, subtitle via appInfoLocalizations) still flow through.
113
+ """
114
+ for loc in (state.get("localizations") or {}).values():
115
+ loc["version_localization_id"] = None
116
+
117
+
118
+ def build_state(
119
+ app_info_id: str,
120
+ version_id: str,
121
+ app_info_locs: dict[str, dict],
122
+ version_locs: dict[str, dict],
123
+ ) -> dict:
124
+ """Union locales across the two resources and merge their field dicts."""
125
+ locales = sorted(set(app_info_locs) | set(version_locs))
126
+ localizations: dict[str, dict] = {}
127
+ for locale in locales:
128
+ app_loc = app_info_locs.get(locale) or {}
129
+ ver_loc = version_locs.get(locale) or {}
130
+ fields = {f: app_loc.get(f) for f in APP_INFO_LOC_FIELDS}
131
+ fields.update({f: ver_loc.get(f) for f in VERSION_LOC_FIELDS})
132
+ localizations[locale] = {
133
+ "app_info_localization_id": app_loc.get("id") or None,
134
+ "version_localization_id": ver_loc.get("id") or None,
135
+ "fields": fields,
136
+ }
137
+ return {
138
+ "app_info_id": app_info_id,
139
+ "version_id": version_id,
140
+ "locales": locales,
141
+ "localizations": localizations,
142
+ }
143
+
144
+
145
+ def _is_empty(value) -> bool:
146
+ return value is None or (isinstance(value, str) and not value.strip())
147
+
148
+
149
+ def _empty_in_group(
150
+ fields: dict, group: tuple[str, ...], resource_id: str | None
151
+ ) -> list[str]:
152
+ """Return editable (non-URL) group fields that are empty, or [] if the
153
+ owning resource id is missing for this locale."""
154
+ if not resource_id:
155
+ return []
156
+ return [
157
+ f for f in group
158
+ if f not in SKIP_URL_FIELDS and _is_empty(fields.get(f))
159
+ ]
160
+
161
+
162
+ def diff_empty_fields(state: dict) -> dict[str, list[str]]:
163
+ """List empty, non-URL fields per locale, skipping those whose owning
164
+ resource id is missing for that locale."""
165
+ out: dict[str, list[str]] = {}
166
+ for locale, loc in (state.get("localizations") or {}).items():
167
+ fields = loc.get("fields") or {}
168
+ empty = (
169
+ _empty_in_group(fields, APP_LEVEL_FIELDS, loc.get("app_info_localization_id"))
170
+ + _empty_in_group(fields, VERSION_LEVEL_FIELDS, loc.get("version_localization_id"))
171
+ )
172
+ if empty:
173
+ out[locale] = empty
174
+ return out
175
+
176
+
177
+ def extract_existing_fields(state: dict) -> dict[str, dict[str, str]]:
178
+ """Current ASC value for every non-URL field, per locale.
179
+
180
+ Always returns all 6 editable fields (APP_LEVEL_FIELDS + VERSION_LEVEL_FIELDS)
181
+ per locale, normalizing missing/None values to "". Phase-2 orchestration
182
+ reads this as authoritative "what ASC currently holds" context for the AI
183
+ prompt -- especially ``description``, which seeds localization prompts.
184
+ URL fields are excluded (SKIP_URL_FIELDS, never AI-generated).
185
+ """
186
+ all_fields = APP_LEVEL_FIELDS + VERSION_LEVEL_FIELDS
187
+ out: dict[str, dict[str, str]] = {}
188
+ for locale, loc in (state.get("localizations") or {}).items():
189
+ fields = loc.get("fields") or {}
190
+ out[locale] = {f: (fields.get(f) or "") for f in all_fields}
191
+ return out
192
+
193
+
194
+ def run() -> dict:
195
+ app_id = require_env("APP_STORE_APPLE_ID")
196
+ version_id = require_env("APP_STORE_VERSION_ID")
197
+ token = make_jwt(
198
+ require_env("ASC_KEY_ID"),
199
+ require_env("ASC_ISSUER_ID"),
200
+ require_env("ASC_KEY_PATH"),
201
+ )
202
+
203
+ app_info = fetch_editable_app_info(app_id, token)
204
+ if app_info is None:
205
+ log(
206
+ "no editable appInfo state; AI metadata step skipped "
207
+ "(expected for freshly-launched app)"
208
+ )
209
+ return {"empty_fields": {}}
210
+
211
+ app_info_locs = fetch_app_info_localizations(app_info["id"], token)
212
+ version_locs = fetch_version_localizations(version_id, token)
213
+ state = build_state(app_info["id"], version_id, app_info_locs, version_locs)
214
+
215
+ version_state = fetch_version_state(version_id, token)
216
+ state["app_store_state"] = version_state
217
+ if version_state and version_state not in EDITABLE_VERSION_STATES:
218
+ warn(
219
+ f"appStoreVersion in non-editable state={version_state}; "
220
+ f"skipping version-level metadata "
221
+ f"(will only update app-level fields)"
222
+ )
223
+ _clear_version_ids(state)
224
+
225
+ state["empty_fields"] = diff_empty_fields(state)
226
+ state["existing_fields"] = extract_existing_fields(state)
227
+ log(
228
+ f"detector: app_info_state={app_info['state']} "
229
+ f"app_store_state={version_state or '?'} "
230
+ f"locales={len(state['locales'])} "
231
+ f"empty_locales={len(state['empty_fields'])}"
232
+ )
233
+ return state
234
+
235
+
236
+ def main() -> int:
237
+ try:
238
+ result = run()
239
+ except SystemExit:
240
+ raise
241
+ except Exception as exc:
242
+ warn(f"metadata detector failed (non-fatal): {exc!r}")
243
+ print(json.dumps({"empty_fields": {}}))
244
+ return 0
245
+ print(json.dumps(result))
246
+ return 0
247
+
248
+
249
+ if __name__ == "__main__":
250
+ sys.exit(main())
@@ -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)