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,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
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Note: ~68-line helper exceeds the 50-line function cap. Pre-existing
|
|
3
|
+
# in the source-of-truth; refactor tracked separately to avoid bundling
|
|
4
|
+
# unrelated edits into round-13 trims.
|
|
5
|
+
"""
|
|
6
|
+
App Store Connect stale-editable appStoreVersion reuse.
|
|
7
|
+
|
|
8
|
+
Split out of asc_version_create.py to keep each module under the 400-line
|
|
9
|
+
cap. Owns the "PATCH then DELETE+POST fallback" flow that recovers from
|
|
10
|
+
ASC's "You cannot create a new version of the App in the current state."
|
|
11
|
+
409 (CI run 24640907316).
|
|
12
|
+
|
|
13
|
+
Why: ASC allows only ONE editable appStoreVersion at a time. When a
|
|
14
|
+
stale editable is sitting below our target marketing floor, a straight
|
|
15
|
+
POST of the new version returns 409 ENTITY_ERROR.RELATIONSHIP.INVALID
|
|
16
|
+
with pointer /data/relationships/app. Bumping the version cannot fix
|
|
17
|
+
this -- the editable itself must be renamed (PATCH) or removed (DELETE)
|
|
18
|
+
first.
|
|
19
|
+
|
|
20
|
+
Exit code:
|
|
21
|
+
5 -- PATCH failed AND DELETE failed (e.g. version locked by review);
|
|
22
|
+
manual intervention required in ASC.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json as _json
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
from asc_common import request
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Substrings in error.detail that indicate "a stale editable exists and
|
|
34
|
+
# blocks creating a new version". ASC returns this as 409 with
|
|
35
|
+
# code=ENTITY_ERROR.RELATIONSHIP.INVALID and pointer=/data/relationships/app
|
|
36
|
+
# (CI run 24640907316). Case-insensitive match.
|
|
37
|
+
_EDITABLE_EXISTS_DETAIL_HINTS = (
|
|
38
|
+
"create a new version",
|
|
39
|
+
"cannot create a new version",
|
|
40
|
+
"current state",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _log_body(body: dict, tag: str) -> None:
|
|
45
|
+
"""Pretty-print a request body to stderr before the call."""
|
|
46
|
+
try:
|
|
47
|
+
pretty = _json.dumps(body, indent=2, sort_keys=True)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
pretty = repr(body)
|
|
50
|
+
print(f"[{tag}] request body: {pretty}", file=sys.stderr)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def patch_version(app_id: str, existing_id: str, target_version: str, token: str):
|
|
54
|
+
"""PATCH /appStoreVersions/{id} to rename versionString. Preserves
|
|
55
|
+
metadata and review attachments. 4xx statuses allowed so the caller
|
|
56
|
+
can fall back to DELETE+POST.
|
|
57
|
+
|
|
58
|
+
ASC PATCH shape per JSON:API: data.type=appStoreVersions, data.id
|
|
59
|
+
must match the URL id, attributes carries the rename."""
|
|
60
|
+
body = {
|
|
61
|
+
"data": {
|
|
62
|
+
"type": "appStoreVersions",
|
|
63
|
+
"id": str(existing_id),
|
|
64
|
+
"attributes": {"versionString": target_version},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
_log_body(body, "patch-version")
|
|
68
|
+
return request(
|
|
69
|
+
"PATCH", f"/appStoreVersions/{existing_id}", token,
|
|
70
|
+
json_body=body, allow_status={400, 403, 404, 409, 422},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def delete_version(app_id: str, existing_id: str, token: str):
|
|
75
|
+
"""DELETE /appStoreVersions/{id}. 4xx statuses allowed so the caller
|
|
76
|
+
can surface a clear error when the record is locked by review."""
|
|
77
|
+
return request(
|
|
78
|
+
"DELETE", f"/appStoreVersions/{existing_id}", token,
|
|
79
|
+
allow_status={400, 403, 404, 409, 422},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_2xx(resp) -> bool:
|
|
84
|
+
return 200 <= getattr(resp, "status_code", 0) < 300
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _body_text(resp) -> str:
|
|
88
|
+
t = getattr(resp, "text", None)
|
|
89
|
+
return t if isinstance(t, str) else ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def reuse_stale_editable(
|
|
93
|
+
*,
|
|
94
|
+
app_id: str,
|
|
95
|
+
existing_id: str,
|
|
96
|
+
target_version: str,
|
|
97
|
+
token: str,
|
|
98
|
+
patch_fn=None,
|
|
99
|
+
delete_fn=None,
|
|
100
|
+
post_fn=None,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""Reuse a stale editable appStoreVersion that blocks creating a new
|
|
103
|
+
version. Tries PATCH (rename) first; on failure falls back to
|
|
104
|
+
DELETE + POST. SystemExit(5) when DELETE also fails.
|
|
105
|
+
|
|
106
|
+
All three ASC calls are injected so tests can drive the sequence
|
|
107
|
+
without hitting the network. ``post_fn`` must accept
|
|
108
|
+
``(app_id, version, token)`` -- same shape as ``create_version``.
|
|
109
|
+
"""
|
|
110
|
+
patch = patch_fn if patch_fn is not None else patch_version
|
|
111
|
+
delete = delete_fn if delete_fn is not None else delete_version
|
|
112
|
+
if post_fn is None:
|
|
113
|
+
# Lazy import to avoid a circular dep with asc_version_create.
|
|
114
|
+
from asc_version_create import create_version as _default_post
|
|
115
|
+
post = _default_post
|
|
116
|
+
else:
|
|
117
|
+
post = post_fn
|
|
118
|
+
|
|
119
|
+
print(
|
|
120
|
+
f"[reuse-stale] PATCH /appStoreVersions/{existing_id} "
|
|
121
|
+
f"versionString -> {target_version}",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
)
|
|
124
|
+
resp = patch(app_id, existing_id, target_version, token)
|
|
125
|
+
if _is_2xx(resp):
|
|
126
|
+
print("[reuse-stale] PATCH ok", file=sys.stderr)
|
|
127
|
+
return existing_id
|
|
128
|
+
|
|
129
|
+
print(
|
|
130
|
+
f"[reuse-stale] PATCH failed status={resp.status_code} "
|
|
131
|
+
f"body={_body_text(resp)}",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
print(
|
|
136
|
+
f"[reuse-stale] DELETE /appStoreVersions/{existing_id}",
|
|
137
|
+
file=sys.stderr,
|
|
138
|
+
)
|
|
139
|
+
del_resp = delete(app_id, existing_id, token)
|
|
140
|
+
if not _is_2xx(del_resp):
|
|
141
|
+
print(
|
|
142
|
+
f"::error::stale editable appStoreVersion id={existing_id} "
|
|
143
|
+
f"cannot be renamed or deleted -- manually intervene in ASC. "
|
|
144
|
+
f"DELETE status={del_resp.status_code} body={_body_text(del_resp)}",
|
|
145
|
+
file=sys.stderr,
|
|
146
|
+
)
|
|
147
|
+
raise SystemExit(5)
|
|
148
|
+
|
|
149
|
+
print("[reuse-stale] DELETE ok, retrying POST", file=sys.stderr)
|
|
150
|
+
post_resp = post(app_id, target_version, token)
|
|
151
|
+
if not _is_2xx(post_resp):
|
|
152
|
+
print(
|
|
153
|
+
f"::error::stale editable {existing_id} was deleted but POST "
|
|
154
|
+
f"for {target_version} still failed "
|
|
155
|
+
f"status={post_resp.status_code} body={_body_text(post_resp)}",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
raise SystemExit(5)
|
|
159
|
+
return post_resp.json()["data"]["id"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_editable_exists_409(errors: list[dict]) -> bool:
|
|
163
|
+
"""True iff the 409 is attributable to a pre-existing editable
|
|
164
|
+
appStoreVersion (ASC allows only one editable at a time). Matches
|
|
165
|
+
the classifier in the CI run 24640907316 spec: code is
|
|
166
|
+
ENTITY_ERROR.RELATIONSHIP.INVALID AND detail contains "create a
|
|
167
|
+
new version" (or similar hint)."""
|
|
168
|
+
for e in errors:
|
|
169
|
+
if not isinstance(e, dict):
|
|
170
|
+
continue
|
|
171
|
+
code = (e.get("code") or "").strip()
|
|
172
|
+
detail = (e.get("detail") or "").strip().lower()
|
|
173
|
+
if code == "ENTITY_ERROR.RELATIONSHIP.INVALID" and any(
|
|
174
|
+
hint in detail for hint in _EDITABLE_EXISTS_DETAIL_HINTS
|
|
175
|
+
):
|
|
176
|
+
return True
|
|
177
|
+
return False
|