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.
- package/CLAUDE.md +94 -0
- package/README.md +417 -0
- package/action/.daemux-version +1 -0
- package/action/MIGRATION.md +232 -0
- package/action/action.yml +973 -0
- package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
- package/action/prompts/generate_descriptions.prompt.yml +59 -0
- package/action/scripts/app_context_scanner.py +239 -0
- package/action/scripts/asc_build_history.py +284 -0
- package/action/scripts/asc_common.py +246 -0
- package/action/scripts/asc_metadata_applier.py +368 -0
- package/action/scripts/asc_metadata_detector.py +250 -0
- package/action/scripts/asc_version_create.py +365 -0
- package/action/scripts/asc_version_fetch.py +274 -0
- package/action/scripts/asc_version_reuse.py +177 -0
- package/action/scripts/auto_detect.py +391 -0
- package/action/scripts/autoupdate_check.sh +118 -0
- package/action/scripts/cert_factory.py +190 -0
- package/action/scripts/cfg_io.py +27 -0
- package/action/scripts/cfg_resolve.py +147 -0
- package/action/scripts/commit_bot_changes.sh +82 -0
- package/action/scripts/creds_store.py +328 -0
- package/action/scripts/keychain.py +103 -0
- package/action/scripts/lookup_app_id.py +51 -0
- package/action/scripts/manage_marketing_version.py +337 -0
- package/action/scripts/metadata_constants.py +102 -0
- package/action/scripts/mmv_decide_create.py +181 -0
- package/action/scripts/mmv_floor_check.py +265 -0
- package/action/scripts/next_build_number.py +195 -0
- package/action/scripts/pbxproj_editor.py +220 -0
- package/action/scripts/prepare_signing.py +223 -0
- package/action/scripts/profile_io.py +64 -0
- package/action/scripts/profile_manager.py +307 -0
- package/action/scripts/read_config.py +315 -0
- package/action/scripts/resolve_marketing_version.py +173 -0
- package/action/scripts/set_app_store_whats_new.py +317 -0
- package/action/scripts/team_resolver.py +130 -0
- package/action/scripts/test_app_context_scanner.py +167 -0
- package/action/scripts/test_asc_build_history.py +221 -0
- package/action/scripts/test_asc_builds_prerelease.py +219 -0
- package/action/scripts/test_asc_common_timeouts.py +121 -0
- package/action/scripts/test_asc_metadata_applier.py +850 -0
- package/action/scripts/test_asc_metadata_detector.py +445 -0
- package/action/scripts/test_auto_detect.py +432 -0
- package/action/scripts/test_cfg_resolve_credentials.py +48 -0
- package/action/scripts/test_compute_next_version.py +95 -0
- package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
- package/action/scripts/test_compute_next_version_strictness.py +67 -0
- package/action/scripts/test_fetch_versions.py +163 -0
- package/action/scripts/test_ground_truth_floor.py +104 -0
- package/action/scripts/test_manage_marketing_version.py +134 -0
- package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
- package/action/scripts/test_manage_marketing_version_floor.py +170 -0
- package/action/scripts/test_manage_marketing_version_match.py +187 -0
- package/action/scripts/test_mmv_409_classifier.py +231 -0
- package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
- package/action/scripts/test_mmv_decide_create.py +119 -0
- package/action/scripts/test_mmv_floor_check.py +158 -0
- package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
- package/action/scripts/test_mmv_helpers.py +57 -0
- package/action/scripts/test_next_build_number.py +174 -0
- package/action/scripts/test_read_config_auto_detect.py +257 -0
- package/action/scripts/test_resolve_marketing_version.py +298 -0
- package/action/scripts/test_reuse_stale_editable.py +182 -0
- package/action/scripts/test_set_app_store_whats_new.py +71 -0
- package/action/scripts/test_team_fallback_wiring.py +157 -0
- package/action/scripts/test_team_resolver.py +249 -0
- package/action/scripts/tests_common.py +167 -0
- package/action/scripts/version_utils.py +373 -0
- package/android-action/.daemux-version +1 -0
- package/android-action/action.yml +92 -0
- package/android-action/scripts/android_config.py +130 -0
- package/android-action/scripts/bitrise_deploy.py +160 -0
- package/android-action/scripts/find_bundle.py +25 -0
- package/android-action/scripts/play_preflight.py +69 -0
- package/android-action/scripts/resolve_android.py +70 -0
- package/android-action/scripts/sign_bundle.py +70 -0
- package/android-action/scripts/test_android_config.py +97 -0
- package/android-action/scripts/test_bitrise_deploy.py +124 -0
- package/android-action/scripts/test_sign_bundle.py +82 -0
- package/bin/cli.mjs +63 -0
- package/package.json +57 -0
- package/src/install.mjs +208 -0
- 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)
|