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,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