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,78 @@
1
+ name: Generate ASC Dependent Fields
2
+ description: ASO-optimized name/subtitle/keywords/promotionalText/whatsNew derived from authoritative descriptions (Phase 2)
3
+ model: openai/gpt-4o
4
+ modelParameters:
5
+ temperature: 0.7
6
+ max_tokens: 8000
7
+ messages:
8
+ - role: system
9
+ content: |
10
+ You are a senior App Store Optimization (ASO) specialist and localization expert.
11
+ Given an iOS app's source-code fingerprint, authoritative per-locale descriptions,
12
+ and a list of empty metadata fields per locale, generate ASO-optimized,
13
+ guideline-compliant content.
14
+
15
+ STRICT RULES per field:
16
+ - name: <=30 chars. Include primary keyword naturally. No "app"/"free"/"best". No trademarks.
17
+ - subtitle: <=30 chars. Complement name WITHOUT repeating its words. Secondary keyword.
18
+ - keywords: <=100 chars TOTAL. Comma-separated NO SPACES after commas. Do not repeat words
19
+ from name or subtitle (Apple indexes them separately). No app category, no "app", no trademarks.
20
+ - promotionalText: <=170 chars. Current benefit or seasonal hook.
21
+ - whatsNew: <=4000 chars. Release notes bullets. If locale has no prior notes,
22
+ default to performance/bug-fix/security boilerplate.
23
+
24
+ DEPENDENCY RULES:
25
+ The `description` field for each locale is PROVIDED as authoritative context.
26
+ For every dependent field you generate, you MUST:
27
+ - promotionalText: extract the single strongest benefit from description's
28
+ first sentence; paraphrase to <=170 chars. Do NOT invent new value props.
29
+ - keywords: extract high-intent nouns/phrases present in description;
30
+ avoid words already in name/subtitle.
31
+ - subtitle: state a secondary benefit from description paragraph 2;
32
+ must not duplicate name tokens.
33
+ name and whatsNew are INDEPENDENT — generate without description context.
34
+
35
+ CRITICAL:
36
+ - You receive a JSON list of `empty_fields` per locale. ONLY generate the listed fields
37
+ for the listed locales. Do not generate fields that were not requested.
38
+ - Never generate a `description` field — descriptions are authoritative input, not output.
39
+ - Translate faithfully for each locale's market - not literal word-for-word. Adapt ASO
40
+ keywords to local search behavior. Respect character limits in the target script
41
+ (CJK char counts differ from byte counts).
42
+ - Never output URLs (privacyPolicyUrl, supportUrl, marketingUrl, privacyChoicesUrl).
43
+ - Output STRICT JSON matching the schema - no markdown, no commentary.
44
+
45
+ - role: user
46
+ content: |
47
+ APP CONTEXT:
48
+ {{app_context}}
49
+
50
+ DESCRIPTIONS BY LOCALE (AUTHORITATIVE):
51
+ {{descriptions_by_locale}}
52
+
53
+ EMPTY FIELDS TO FILL:
54
+ {{empty_fields}}
55
+
56
+ Generate now.
57
+
58
+ responseFormat: json_schema
59
+ jsonSchema:
60
+ name: ascDependentFields
61
+ strict: true
62
+ schema:
63
+ type: object
64
+ additionalProperties: false
65
+ required: [localizations]
66
+ properties:
67
+ localizations:
68
+ type: object
69
+ description: "Keyed by Apple locale code (en-US, ja, de-DE, ...)"
70
+ additionalProperties:
71
+ type: object
72
+ additionalProperties: false
73
+ properties:
74
+ name: { type: string, maxLength: 30 }
75
+ subtitle: { type: string, maxLength: 30 }
76
+ keywords: { type: string, maxLength: 100 }
77
+ promotionalText: { type: string, maxLength: 170 }
78
+ whatsNew: { type: string, maxLength: 4000 }
@@ -0,0 +1,59 @@
1
+ name: Generate ASC Descriptions
2
+ description: ASO-optimized App Store long descriptions per locale (Phase 1)
3
+ model: openai/gpt-4o
4
+ modelParameters:
5
+ temperature: 0.7
6
+ max_tokens: 8000
7
+ messages:
8
+ - role: system
9
+ content: |
10
+ You are a senior App Store Optimization (ASO) specialist and localization expert.
11
+ Given an iOS app's source-code fingerprint and a list of locales that need a
12
+ long description, generate ASO-optimized, guideline-compliant descriptions.
13
+
14
+ STRICT RULES for description:
15
+ - description: 800-4000 chars. First 3 lines = hook + value prop (visible before Read More).
16
+ Short paragraphs, line breaks, 3-5 bullet features, closing CTA.
17
+
18
+ CRITICAL:
19
+ - You receive a JSON list of `locales_needing_description`. ONLY generate a
20
+ description for each locale in that list. Do not generate any other fields.
21
+ - Translate faithfully for each locale's market - not literal word-for-word. Adapt ASO
22
+ keywords to local search behavior. Respect character limits in the target script
23
+ (CJK char counts differ from byte counts).
24
+ - The first paragraph MUST be a strong hook fully visible before the "Read More"
25
+ cutoff on the App Store product page.
26
+ - Use short paragraphs separated by line breaks for scannability.
27
+ - Include 3-5 bullet-style feature lines highlighting key capabilities.
28
+ - End with a closing call-to-action inviting download or engagement.
29
+ - Never output URLs (privacyPolicyUrl, supportUrl, marketingUrl, privacyChoicesUrl).
30
+ - Output STRICT JSON matching the schema - no markdown, no commentary.
31
+
32
+ - role: user
33
+ content: |
34
+ APP CONTEXT:
35
+ {{app_context}}
36
+
37
+ LOCALES NEEDING DESCRIPTION:
38
+ {{locales_needing_description}}
39
+
40
+ Generate the description for each locale now.
41
+
42
+ responseFormat: json_schema
43
+ jsonSchema:
44
+ name: ascDescriptions
45
+ strict: true
46
+ schema:
47
+ type: object
48
+ additionalProperties: false
49
+ required: [localizations]
50
+ properties:
51
+ localizations:
52
+ type: object
53
+ description: "Keyed by Apple locale code (en-US, ja, de-DE, ...)"
54
+ additionalProperties:
55
+ type: object
56
+ additionalProperties: false
57
+ required: [description]
58
+ properties:
59
+ description: { type: string, maxLength: 4000 }
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Smoke-scan the consumer repo to produce a deterministic <=6KB text blob that
4
+ captures enough product signal for the AI prompt to generate ASO-optimized
5
+ App Store metadata without reading every file.
6
+
7
+ Sources (in fixed order, each hard-capped):
8
+ * README.md / readme.md (2048B) human pitch
9
+ * $SCHEME/Info.plist (512B) display name + capability keys
10
+ * Package.swift / Podfile(1024B) SDK fingerprint (Firebase, RevenueCat, ...)
11
+ * top 3 *.swift by size (1536B) class / view names reveal feature set
12
+ * Bundle id + scheme ( 128B) naming signal
13
+ * *.entitlements ( 256B) capability keys (VPN, iCloud, ...)
14
+
15
+ Total hard cap: ~6KB. Walk is scoped to $GITHUB_WORKSPACE (or os.getcwd());
16
+ paths resolved against that root are re-checked to prevent traversal.
17
+ Fail-open: any uncaught error -> warning, "<unavailable>" blob, exit 0.
18
+
19
+ Environment:
20
+ GITHUB_WORKSPACE -- repo root (falls back to cwd)
21
+ BUNDLE_ID -- app bundle id (from auto_detect or ci.config.yaml)
22
+ SCHEME -- Xcode scheme name
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import plistlib
29
+ import re
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ from metadata_constants import warn
34
+
35
+
36
+ README_CAP = 2048
37
+ INFO_PLIST_CAP = 512
38
+ DEPS_CAP = 1024
39
+ SWIFT_TOTAL_CAP = 1536
40
+ SWIFT_PER_FILE_CAP = 500
41
+ NAMING_CAP = 128
42
+ ENTITLEMENTS_CAP = 256
43
+
44
+
45
+ def _root() -> Path:
46
+ workspace = os.environ.get("GITHUB_WORKSPACE", "").strip() or os.getcwd()
47
+ return Path(workspace).resolve()
48
+
49
+
50
+ def _safe_path(root: Path, candidate: Path) -> Path | None:
51
+ """Return resolved candidate if it is inside root, else None.
52
+
53
+ Guards against symlinked or traversal-crafted paths reaching outside
54
+ the workspace. Non-existent paths return None.
55
+ """
56
+ try:
57
+ resolved = candidate.resolve()
58
+ except OSError:
59
+ return None
60
+ try:
61
+ resolved.relative_to(root)
62
+ except ValueError:
63
+ return None
64
+ if not resolved.exists():
65
+ return None
66
+ return resolved
67
+
68
+
69
+ def _read_capped(path: Path, cap: int) -> str:
70
+ try:
71
+ raw = path.read_bytes()[:cap]
72
+ return raw.decode("utf-8", errors="replace").strip()
73
+ except OSError as exc:
74
+ warn(f"scanner: read failed for {path.name}: {exc!r}")
75
+ return ""
76
+
77
+
78
+ def read_readme(root: Path) -> str:
79
+ for name in ("README.md", "readme.md", "README.MD", "Readme.md"):
80
+ p = _safe_path(root, root / name)
81
+ if p:
82
+ return _read_capped(p, README_CAP)
83
+ return ""
84
+
85
+
86
+ def _scheme_root(root: Path, scheme: str) -> Path | None:
87
+ if not scheme:
88
+ return None
89
+ for candidate in (root / scheme, root / "Sources" / scheme, root / "Sources"):
90
+ p = _safe_path(root, candidate)
91
+ if p and p.is_dir():
92
+ return p
93
+ return None
94
+
95
+
96
+ def read_info_plist(root: Path, scheme: str) -> str:
97
+ search_dirs = [d for d in (_scheme_root(root, scheme), root) if d]
98
+ for base in search_dirs:
99
+ plist = _safe_path(base, base / "Info.plist")
100
+ if plist:
101
+ return _extract_plist_summary(plist)
102
+ for plist in sorted(root.rglob("Info.plist"))[:5]:
103
+ safe = _safe_path(root, plist)
104
+ if safe:
105
+ summary = _extract_plist_summary(safe)
106
+ if summary:
107
+ return summary
108
+ return ""
109
+
110
+
111
+ def _extract_plist_summary(path: Path) -> str:
112
+ try:
113
+ with open(path, "rb") as f:
114
+ data = plistlib.load(f)
115
+ except (OSError, plistlib.InvalidFileException, ValueError) as exc:
116
+ warn(f"scanner: plist parse failed for {path.name}: {exc!r}")
117
+ return ""
118
+ if not isinstance(data, dict):
119
+ return ""
120
+ display = data.get("CFBundleDisplayName") or data.get("CFBundleName") or ""
121
+ version = data.get("CFBundleShortVersionString") or ""
122
+ perms = sorted(k for k in data if k.startswith("NS") and "UsageDescription" in k)
123
+ lines = []
124
+ if display:
125
+ lines.append(f"display_name={display}")
126
+ if version:
127
+ lines.append(f"version={version}")
128
+ if perms:
129
+ lines.append("permissions=" + ",".join(perms))
130
+ return "\n".join(lines)[:INFO_PLIST_CAP]
131
+
132
+
133
+ def read_dependencies(root: Path) -> str:
134
+ pkg_swift = _safe_path(root, root / "Package.swift")
135
+ if pkg_swift:
136
+ return _grep_lines(pkg_swift, r"\.package\s*\(", DEPS_CAP)
137
+ podfile = _safe_path(root, root / "Podfile")
138
+ if podfile:
139
+ return _grep_lines(podfile, r"^\s*pod\s+", DEPS_CAP)
140
+ return ""
141
+
142
+
143
+ def _grep_lines(path: Path, pattern: str, cap: int) -> str:
144
+ try:
145
+ text = path.read_text(encoding="utf-8", errors="replace")
146
+ except OSError as exc:
147
+ warn(f"scanner: read failed for {path.name}: {exc!r}")
148
+ return ""
149
+ regex = re.compile(pattern)
150
+ matches = [line.strip() for line in text.splitlines() if regex.search(line)]
151
+ return "\n".join(matches)[:cap]
152
+
153
+
154
+ def read_swift_files(root: Path, scheme: str) -> str:
155
+ base = _scheme_root(root, scheme) or root
156
+ swift_paths: list[Path] = []
157
+ for p in base.rglob("*.swift"):
158
+ safe = _safe_path(root, p)
159
+ if not safe or "Tests" in safe.parts:
160
+ continue
161
+ try:
162
+ safe.stat() # confirm readable; sort key below will re-stat
163
+ except OSError:
164
+ continue
165
+ swift_paths.append(safe)
166
+ swift_paths.sort(key=lambda p: (-p.stat().st_size, p.name))
167
+ chunks: list[str] = []
168
+ used = 0
169
+ for p in swift_paths[:3]:
170
+ if used >= SWIFT_TOTAL_CAP:
171
+ break
172
+ snippet = _read_capped(p, SWIFT_PER_FILE_CAP)
173
+ if not snippet:
174
+ continue
175
+ block = f"--- {p.name} ---\n{snippet}"
176
+ if used + len(block) > SWIFT_TOTAL_CAP:
177
+ block = block[: SWIFT_TOTAL_CAP - used]
178
+ chunks.append(block)
179
+ used += len(block) + 1
180
+ return "\n".join(chunks)
181
+
182
+
183
+ def read_entitlements(root: Path) -> str:
184
+ for ent in sorted(root.rglob("*.entitlements"))[:3]:
185
+ safe = _safe_path(root, ent)
186
+ if not safe:
187
+ continue
188
+ try:
189
+ with open(safe, "rb") as f:
190
+ data = plistlib.load(f)
191
+ except (OSError, plistlib.InvalidFileException, ValueError):
192
+ continue
193
+ if isinstance(data, dict) and data:
194
+ return "\n".join(sorted(data))[:ENTITLEMENTS_CAP]
195
+ return ""
196
+
197
+
198
+ def build_blob() -> str:
199
+ root = _root()
200
+ bundle_id = os.environ.get("BUNDLE_ID", "").strip()
201
+ scheme = os.environ.get("SCHEME", "").strip()
202
+
203
+ naming = []
204
+ if bundle_id:
205
+ naming.append(f"BUNDLE_ID: {bundle_id}")
206
+ if scheme:
207
+ naming.append(f"SCHEME: {scheme}")
208
+ naming_blob = "\n".join(naming)[:NAMING_CAP]
209
+
210
+ sections = [
211
+ ("===APP CONTEXT===", naming_blob),
212
+ ("===README===", read_readme(root)),
213
+ ("===INFO_PLIST===", read_info_plist(root, scheme)),
214
+ ("===DEPENDENCIES===", read_dependencies(root)),
215
+ ("===SWIFT_FILES===", read_swift_files(root, scheme)),
216
+ ("===ENTITLEMENTS===", read_entitlements(root)),
217
+ ]
218
+ out_parts: list[str] = []
219
+ for header, content in sections:
220
+ out_parts.extend((header, content or "<unavailable>"))
221
+ out_parts.append("===END===")
222
+ return "\n".join(out_parts)
223
+
224
+
225
+ def main() -> int:
226
+ try:
227
+ blob = build_blob()
228
+ except SystemExit:
229
+ raise
230
+ except Exception as exc:
231
+ warn(f"app context scanner failed (non-fatal): {exc!r}")
232
+ print("===APP CONTEXT===\n<unavailable>\n===END===")
233
+ return 0
234
+ print(blob)
235
+ return 0
236
+
237
+
238
+ if __name__ == "__main__":
239
+ sys.exit(main())
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env python3
2
+ # Note: fetch_prerelease_versions (~58 lines) and
3
+ # fetch_builds_prerelease_versions (~59 lines) exceed the 50-line
4
+ # function cap. Pre-existing in the source-of-truth; refactor tracked
5
+ # separately to avoid bundling unrelated edits into round-13 trims.
6
+ """
7
+ App Store Connect pre-release-version history fetcher.
8
+
9
+ Split out of asc_version_fetch.py so each module stays focused and under
10
+ the 400-line cap. This module owns:
11
+
12
+ - `fetch_prerelease_versions`: paginated list of every TestFlight train
13
+ versionString (/v1/apps/{id}/preReleaseVersions -> attributes.version).
14
+ TestFlight trains persist across app updates and feed ASC's upload
15
+ validator's "previously approved version" floor.
16
+
17
+ Why NOT /v1/apps/{id}/builds? Two reasons, both learned the hard way:
18
+
19
+ 1. The app-scoped /builds relationship rejects
20
+ ``filter[preReleaseVersion.platform]`` with HTTP 400 ("parameter is
21
+ not permitted on this endpoint"). That filter is only valid on the
22
+ top-level /v1/builds collection, not the relationship view.
23
+
24
+ 2. Even if the filter worked, /builds.attributes.version is the INTEGER
25
+ build number (CFBundleVersion), NOT the marketing versionString.
26
+ Empirical proof from CI run 24640182161:
27
+ ASC /builds: scanning 6 builds -> v='3','2','1'
28
+ Those '3','2','1' are build numbers. Marketing versions on the same
29
+ app looked like 1.0, 1.0.1, 1.0.4.
30
+
31
+ So /v1/apps/{id}/builds is unfit for marketing-version discovery.
32
+ /v1/apps/{id}/preReleaseVersions is the authoritative source:
33
+ ``attributes.version`` there IS the marketing versionString.
34
+
35
+ Why no ``filter[platform]`` on the request? CI run 24640348572 proved
36
+ the app-scoped ``/apps/{id}/preReleaseVersions`` relationship endpoint
37
+ rejects ``filter[platform]`` with HTTP 400 ("parameter is not permitted
38
+ on this endpoint"). The filter is only valid on the top-level
39
+ ``/preReleaseVersions`` collection. So we fetch unfiltered and filter
40
+ client-side.
41
+
42
+ Defensive-inclusion rule (CI run 24640430898): records with an EXPLICIT
43
+ non-iOS platform (``MAC_OS``, ``TV_OS``, ``VISION_OS``) are excluded;
44
+ records with iOS OR null/missing platform are INCLUDED. Rationale: the
45
+ 409 failure proved ASC can hide preReleaseVersion records that still
46
+ collide with a POST to /appStoreVersions. Better to overbump one patch
47
+ than to miss a hidden collision.
48
+
49
+ Also in this module (CI run 24640430898): ``fetch_builds_prerelease_versions``
50
+ queries ``/v1/builds?filter[app]={id}&include=preReleaseVersion``,
51
+ cross-references the ``included`` array, and extracts each build's
52
+ preReleaseVersion.attributes.version (the marketing version, not the
53
+ build number). This reveals preReleaseVersions that the direct
54
+ /preReleaseVersions query might miss (pagination truncation or state
55
+ filtering). The build's own ``attributes.version`` is the INTEGER build
56
+ number (CFBundleVersion) and is NOT used.
57
+
58
+ Why the TOP-LEVEL ``/v1/builds`` collection (not ``/v1/apps/{id}/builds``)?
59
+ CI run 24640675162 proved the app-scoped relationship endpoint rejects
60
+ ``include=preReleaseVersion`` with HTTP 400 PARAMETER_ERROR.ILLEGAL
61
+ (`"The parameter 'include' can not be used with this request"`). The
62
+ top-level ``/v1/builds`` collection DOES accept both ``filter[app]``
63
+ and ``include``, so we query that instead and scope by ``filter[app]``.
64
+
65
+ (``next_build_number.py`` still uses /builds -- but it needs build
66
+ numbers, not marketing versions, so that usage is correct.)
67
+ """
68
+
69
+ from __future__ import annotations
70
+
71
+ import sys
72
+
73
+ from asc_common import request
74
+
75
+
76
+ def _iter_pages(path: str, token: str, params: dict):
77
+ """Generator over `.data` lists across paginated ASC responses.
78
+
79
+ Follows `links.next` verbatim (stripping the `/v1` prefix so we stay
80
+ on the retrying client). Yields each record; callers decide what to
81
+ extract and how to log.
82
+ """
83
+ resp = request("GET", path, token, params=params)
84
+ while True:
85
+ data = resp.json()
86
+ for item in data.get("data", []):
87
+ yield item
88
+ next_url = ((data.get("links") or {}).get("next")) or ""
89
+ if not next_url:
90
+ return
91
+ next_path = next_url.split("/v1", 1)[-1] if "/v1" in next_url else next_url
92
+ resp = request("GET", next_path, token)
93
+
94
+
95
+ _EXPLICIT_NON_IOS_PLATFORMS = {"MAC_OS", "TV_OS", "VISION_OS"}
96
+
97
+
98
+ def _platform_is_counted(platform: str | None) -> bool:
99
+ """Defensive inclusion rule: include iOS and null/missing platform;
100
+ exclude only explicit known-wrong platforms. Keeps hidden records
101
+ from slipping past the floor (CI run 24640430898)."""
102
+ if platform in _EXPLICIT_NON_IOS_PLATFORMS:
103
+ return False
104
+ return True
105
+
106
+
107
+ def fetch_prerelease_versions(app_id: str, token: str) -> list[str]:
108
+ """Return every TestFlight train's versionString for the app.
109
+
110
+ Paginates through links.next so mature apps with >200 trains are not
111
+ truncated. Filters client-side -- the app-scoped
112
+ ``/apps/{id}/preReleaseVersions`` relationship endpoint rejects
113
+ ``filter[platform]`` with HTTP 400 (CI run 24640348572), so we drop
114
+ the server-side filter and evaluate ``attributes.platform`` on each
115
+ returned record instead.
116
+
117
+ Defensive inclusion (CI run 24640430898): iOS records AND records
118
+ with null/missing platform are INCLUDED; only explicit MAC_OS /
119
+ TV_OS / VISION_OS are excluded. Records with missing/null version
120
+ are skipped regardless.
121
+ """
122
+ path = f"/apps/{app_id}/preReleaseVersions"
123
+ params = {"limit": 200}
124
+ out: list[str] = []
125
+ skipped = 0
126
+ for item in _iter_pages(path, token, params):
127
+ attrs = item.get("attributes") or {}
128
+ version = attrs.get("version")
129
+ platform = attrs.get("platform")
130
+ record_id = item.get("id", "")
131
+ if not version:
132
+ print(
133
+ f"[prerelease-history] skip id={record_id} "
134
+ f"platform={platform or '<null>'} reason=missing-version",
135
+ file=sys.stderr,
136
+ )
137
+ skipped += 1
138
+ continue
139
+ if not _platform_is_counted(platform):
140
+ print(
141
+ f"[prerelease-history] skip id={record_id} version={version} "
142
+ f"platform={platform or '<null>'} reason=not-ios",
143
+ file=sys.stderr,
144
+ )
145
+ skipped += 1
146
+ continue
147
+ out.append(version)
148
+ if platform == "IOS":
149
+ log_msg = (
150
+ f"[prerelease-history] version={version} "
151
+ f"platform=IOS id={record_id}"
152
+ )
153
+ else:
154
+ log_msg = (
155
+ f"[prerelease-history] version={version} "
156
+ f"platform=<null> id={record_id} "
157
+ f"reason=included-defensively"
158
+ )
159
+ print(log_msg, file=sys.stderr)
160
+ print(
161
+ f"[prerelease-history] done: trains={len(out)} skipped={skipped}",
162
+ file=sys.stderr,
163
+ )
164
+ return out
165
+
166
+
167
+ def fetch_builds_prerelease_versions(app_id: str, token: str) -> list[str]:
168
+ """Return every build's referenced preReleaseVersion marketing version.
169
+
170
+ Queries the TOP-LEVEL
171
+ ``/v1/builds?filter[app]={id}&include=preReleaseVersion`` collection
172
+ and cross-references the ``included`` array to resolve each build's
173
+ ``relationships.preReleaseVersion.data.id`` -> the corresponding
174
+ ``included`` record's ``attributes.version`` (marketing version,
175
+ NOT the build number).
176
+
177
+ The app-scoped relationship endpoint ``/v1/apps/{id}/builds`` rejects
178
+ the ``include`` parameter with HTTP 400 PARAMETER_ERROR.ILLEGAL
179
+ (`"The parameter 'include' can not be used with this request"`,
180
+ CI run 24640675162). The top-level collection accepts both
181
+ ``filter[app]`` and ``include``, so we query that and scope by app.
182
+
183
+ Paginates through links.next. Same defensive platform rule as
184
+ fetch_prerelease_versions: iOS / null included, explicit non-iOS
185
+ excluded. Each cross-reference is logged.
186
+
187
+ Rationale: CI run 24640430898 exposed a hidden preReleaseVersion
188
+ that the direct /preReleaseVersions query did not surface, yet a
189
+ POST to /appStoreVersions for that version returned 409. The
190
+ builds->preReleaseVersion path is a second, independent view of
191
+ those same records and catches ones missed by the direct query.
192
+ """
193
+ path = "/builds"
194
+ params = {
195
+ "filter[app]": app_id,
196
+ "include": "preReleaseVersion",
197
+ "limit": 200,
198
+ }
199
+ out: list[str] = []
200
+ counters = {"builds_scanned": 0, "skipped": 0}
201
+
202
+ resp = request("GET", path, token, params=params)
203
+ while True:
204
+ data = resp.json()
205
+ included_by_id = _index_included(data.get("included") or [])
206
+ for build in data.get("data", []):
207
+ counters["builds_scanned"] += 1
208
+ version = _extract_build_prerelease_version(
209
+ build, included_by_id, counters,
210
+ )
211
+ if version is not None:
212
+ out.append(version)
213
+
214
+ next_url = ((data.get("links") or {}).get("next")) or ""
215
+ if not next_url:
216
+ break
217
+ next_path = next_url.split("/v1", 1)[-1] if "/v1" in next_url else next_url
218
+ resp = request("GET", next_path, token)
219
+
220
+ print(
221
+ f"[builds-prerelease] done: builds={counters['builds_scanned']} "
222
+ f"versions={len(out)} skipped={counters['skipped']}",
223
+ file=sys.stderr,
224
+ )
225
+ return out
226
+
227
+
228
+ def _extract_build_prerelease_version(
229
+ build: dict, included_by_id: dict[str, dict], counters: dict,
230
+ ) -> str | None:
231
+ """Return the build's referenced preReleaseVersion marketing version
232
+ if it passes the defensive platform filter; else None. ``counters``
233
+ is mutated to track skips for the done-line summary."""
234
+ rel_id = _prerelease_ref_id(build)
235
+ if not rel_id:
236
+ return None
237
+ ref = included_by_id.get(rel_id)
238
+ if ref is None:
239
+ return None
240
+ attrs = ref.get("attributes") or {}
241
+ version = attrs.get("version")
242
+ platform = attrs.get("platform")
243
+ if not version:
244
+ counters["skipped"] += 1
245
+ return None
246
+ if not _platform_is_counted(platform):
247
+ print(
248
+ f"[builds-prerelease] skip build={build.get('id', '')} "
249
+ f"prerelease_id={rel_id} version={version} "
250
+ f"platform={platform or '<null>'} reason=not-ios",
251
+ file=sys.stderr,
252
+ )
253
+ counters["skipped"] += 1
254
+ return None
255
+ print(
256
+ f"[builds-prerelease] build={build.get('id', '')} "
257
+ f"prerelease_id={rel_id} version={version} "
258
+ f"platform={platform or '<null>'}",
259
+ file=sys.stderr,
260
+ )
261
+ return version
262
+
263
+
264
+ def _index_included(included: list[dict]) -> dict[str, dict]:
265
+ """Build {id: record} map restricted to preReleaseVersions for O(1)
266
+ cross-reference from a build's relationships.preReleaseVersion.data.id."""
267
+ out: dict[str, dict] = {}
268
+ for rec in included:
269
+ if rec.get("type") != "preReleaseVersions":
270
+ continue
271
+ rid = rec.get("id")
272
+ if rid:
273
+ out[rid] = rec
274
+ return out
275
+
276
+
277
+ def _prerelease_ref_id(build: dict) -> str | None:
278
+ """Return the preReleaseVersion id referenced by a build, or None
279
+ if the relationship is absent/empty."""
280
+ rels = build.get("relationships") or {}
281
+ pre = rels.get("preReleaseVersion") or {}
282
+ data = pre.get("data") or {}
283
+ rid = data.get("id")
284
+ return rid if rid else None