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