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,51 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Resolve the App Store Connect numeric app id for a bundle id.
4
+
5
+ Environment:
6
+ ASC_KEY_ID - API key ID
7
+ ASC_ISSUER_ID - API issuer ID
8
+ ASC_KEY_PATH - Path to the .p8 key file
9
+ BUNDLE_ID - Application bundle identifier
10
+
11
+ Prints the app id to stdout on success. On 0 or >1 matches, exits 1 with a
12
+ guidance message on stderr.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ from asc_common import get_json, make_jwt
20
+ from cfg_io import fail
21
+
22
+
23
+ def env(name: str) -> str:
24
+ val = os.environ.get(name)
25
+ if not val:
26
+ fail(f"missing env var: {name}")
27
+ return val
28
+
29
+
30
+ def main() -> None:
31
+ bundle_id = env("BUNDLE_ID")
32
+ token = make_jwt(env("ASC_KEY_ID"), env("ASC_ISSUER_ID"), env("ASC_KEY_PATH"))
33
+ data = get_json("/apps", token, params={"filter[bundleId]": bundle_id, "limit": "10"})
34
+ apps = data.get("data", [])
35
+ if not apps:
36
+ fail(
37
+ f"No ASC app found for bundleId={bundle_id}. "
38
+ "Run `fastlane create_app_ios` once manually to register the app, "
39
+ "or set app.app_store_apple_id in ci.config.yaml."
40
+ )
41
+ if len(apps) > 1:
42
+ ids = ", ".join(a.get("id", "?") for a in apps)
43
+ fail(
44
+ f"Multiple ASC apps match bundleId={bundle_id} (ids: {ids}). "
45
+ "Set app.app_store_apple_id in ci.config.yaml to disambiguate."
46
+ )
47
+ print(apps[0]["id"])
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Resolve the App Store Connect version slot for this run.
4
+
5
+ The project's MARKETING_VERSION build setting is the single source of truth
6
+ for the marketing version. CI does not bump it. This script finds-or-creates
7
+ the App Store Connect (ASC) row matching that exact version, and only the
8
+ build number is bumped automatically (see next_build_number.py).
9
+
10
+ Algorithm:
11
+ 1. Read MARKETING_VERSION from env (set by action.yml from
12
+ xcodebuild -showBuildSettings or PlistBuddy).
13
+ 2. Floor-check (non-strict): target >= combined floor across
14
+ appStoreVersions + preReleaseVersions + builds->preReleaseVersion.
15
+ 3. If the target row already exists in /appStoreVersions:
16
+ - any non-editable match (terminal/blocking) -> SystemExit(2);
17
+ the slot is taken by a shipped or in-review row.
18
+ - editable-only match -> REUSE its id (steady-state TestFlight
19
+ iteration at unchanged MARKETING_VERSION).
20
+ 4. CREATE path: floor-check (strict, target > floor); PATCH-rename any
21
+ stale editable to target; or POST a new row.
22
+
23
+ This module is intentionally thin: floor logic lives in
24
+ ``mmv_floor_check``; CREATE wiring (POST/PATCH-rename, stale-editable
25
+ detection) lives in ``mmv_decide_create``. Both submodules re-bind back
26
+ through this one so test patches at ``mmv.<seam>`` keep winning.
27
+
28
+ Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID,
29
+ MARKETING_VERSION (project source of truth, set by action.yml).
30
+ Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import sys
38
+
39
+ from asc_common import (
40
+ BLOCKING_STATES,
41
+ IN_REVIEW_STATES,
42
+ REUSABLE_STATES,
43
+ TERMINAL_STATES,
44
+ make_jwt,
45
+ )
46
+
47
+ from mmv_floor_check import (
48
+ BUILD_SETTING_SOURCES,
49
+ MIGRATION_HINT,
50
+ SEM_RE,
51
+ assert_target_meets_floor,
52
+ bump_patch_for_message,
53
+ fetch_versions,
54
+ get_combined_floor,
55
+ get_ground_truth_floor,
56
+ maybe_auto_bump,
57
+ semver_tuple,
58
+ )
59
+ from mmv_decide_create import (
60
+ create_or_reuse,
61
+ create_or_reuse_with_stale,
62
+ create_version,
63
+ decide_create,
64
+ )
65
+
66
+ # Re-export the public seams so tests that patch ``mmv.<name>`` keep
67
+ # working. The submodules look these up through this module at call
68
+ # time, so a patch on ``mmv.fetch_versions`` propagates correctly.
69
+ __all__ = (
70
+ "SEM_RE",
71
+ "create_or_reuse",
72
+ "create_version",
73
+ "decide_for_version",
74
+ "env",
75
+ "fetch_versions",
76
+ "get_combined_floor",
77
+ "get_ground_truth_floor",
78
+ "main",
79
+ "make_jwt",
80
+ "semver_tuple",
81
+ )
82
+
83
+
84
+ _APP_ID_MISSING_HINT = (
85
+ "::error::APP_STORE_APPLE_ID is empty. This usually means auto-detect "
86
+ "could not resolve PRODUCT_BUNDLE_IDENTIFIER from your Xcode project "
87
+ "(check the auto-detect: ... log lines in the 'Resolve credentials + "
88
+ "auto-detect' step above). Fix options: (1) commit your .xcodeproj or "
89
+ "add an xcodegen project.yml; (2) pass `app-store-apple-id:` explicitly "
90
+ "via the action's `with:` block."
91
+ )
92
+
93
+ _MARKETING_VERSION_MISSING_HINT = (
94
+ "::error::MARKETING_VERSION env var is missing or invalid. The "
95
+ "action.yml step that reads it from xcodebuild -showBuildSettings "
96
+ "(or PlistBuddy fallback) must set it before this script runs. "
97
+ + MIGRATION_HINT
98
+ )
99
+
100
+
101
+ def _log(msg: str) -> None:
102
+ print(f"[decision] {msg}", file=sys.stderr)
103
+
104
+
105
+ def _result(decision: str, version: str, vid: str, state: str = "") -> dict:
106
+ out = {
107
+ "decision": decision,
108
+ "versionString": version,
109
+ "appStoreVersionId": vid,
110
+ }
111
+ if state:
112
+ out["state"] = state
113
+ return out
114
+
115
+
116
+ def env(name: str) -> str:
117
+ val = os.environ.get(name)
118
+ if not val:
119
+ if name == "APP_STORE_APPLE_ID":
120
+ msg = _APP_ID_MISSING_HINT
121
+ elif name == "MARKETING_VERSION":
122
+ msg = _MARKETING_VERSION_MISSING_HINT
123
+ else:
124
+ msg = f"::error::Missing required env var: {name}"
125
+ print(msg, file=sys.stderr)
126
+ raise SystemExit(1)
127
+ return val
128
+
129
+
130
+ def _validate_target(target_version: str) -> None:
131
+ """SystemExit(1) when target is not a parseable semver."""
132
+ if SEM_RE.match(target_version or ""):
133
+ return
134
+ print(
135
+ f"::error::MARKETING_VERSION {target_version!r} is not a "
136
+ f"valid semantic version (expected MAJOR.MINOR[.PATCH]). "
137
+ f"Set MARKETING_VERSION in " + BUILD_SETTING_SOURCES + ". "
138
+ + MIGRATION_HINT,
139
+ file=sys.stderr,
140
+ )
141
+ raise SystemExit(1)
142
+
143
+
144
+ def _classify_match_at_target(
145
+ versions: list[dict], target: str,
146
+ ) -> tuple[str, dict | None, bool]:
147
+ """Classify rows at ``target`` -> ``(status, match, also_editable)``.
148
+
149
+ Status values: ``"none"`` (no match -> CREATE), ``"reuse"`` (editable
150
+ -> REUSE its id), ``"terminal"`` (settled, slot locked -- caller may
151
+ auto-roll), ``"in_review"`` (under Apple Review or awaiting dev
152
+ release click -- caller MUST reject), ``"unknown"`` (matched but
153
+ state is in NO allowlist -- caller MUST reject; round 12 fail-closed
154
+ so an unfamiliar ASC state can't silently 409 on CREATE). The state
155
+ taxonomy lives in ``asc_common.py`` next to the constant set
156
+ definitions; positive allowlists (round 11) replaced the historical
157
+ ``not in EDITABLE_STATES`` test that misclassified
158
+ PENDING_DEVELOPER_RELEASE as terminal."""
159
+ matches = [v for v in versions if v.get("versionString") == target]
160
+ if not matches:
161
+ return "none", None, False
162
+ in_review = [
163
+ v for v in matches
164
+ if v.get("state") in IN_REVIEW_STATES
165
+ or v.get("state") in BLOCKING_STATES
166
+ ]
167
+ if in_review:
168
+ return "in_review", in_review[0], False
169
+ terminal = [v for v in matches if v.get("state") in TERMINAL_STATES]
170
+ if terminal:
171
+ return "terminal", terminal[0], len(matches) > 1
172
+ reusable = [v for v in matches if v.get("state") in REUSABLE_STATES]
173
+ if reusable:
174
+ return "reuse", reusable[0], False
175
+ # Round 12: matches exist but no state is recognized -- fail closed.
176
+ return "unknown", matches[0], len(matches) > 1
177
+
178
+
179
+ def _reject_match(
180
+ target: str, match: dict, *,
181
+ also_editable: bool = False, unknown: bool = False,
182
+ ) -> None:
183
+ """Reject a non-reusable match at ``target``. When ``unknown`` is
184
+ True the matched row's ASC state is in none of our allowlists --
185
+ bail rather than guess (round 12: silently CREATEing would 409;
186
+ silently REUSEing could interfere with whatever Apple is doing
187
+ with the row). Otherwise this is the historical terminal-collision
188
+ rejection path."""
189
+ state, vid = match.get("state", ""), match.get("id", "")
190
+ if unknown:
191
+ print(
192
+ f"::error::MARKETING_VERSION {target} exists in App Store "
193
+ f"Connect in unrecognized state {state!r} (id={vid}); "
194
+ f"bailing rather than guessing whether to REUSE or CREATE. "
195
+ f"Update mmv classifier (asc_common.TERMINAL_STATES / "
196
+ f"IN_REVIEW_STATES / BLOCKING_STATES / REUSABLE_STATES) to "
197
+ f"cover {state!r}, or bump MARKETING_VERSION manually to a "
198
+ f"fresh value in your project's build settings ("
199
+ + BUILD_SETTING_SOURCES + "). " + MIGRATION_HINT,
200
+ file=sys.stderr,
201
+ )
202
+ raise SystemExit(2)
203
+ suffix = (
204
+ " (an editable row at the same versionString also exists)"
205
+ if also_editable else ""
206
+ )
207
+ print(
208
+ f"::error::MARKETING_VERSION {target} already exists in App "
209
+ f"Store Connect in state {state} (id={vid}){suffix}. REUSE/CREATE "
210
+ f"both require a fresh marketing version. Bump MARKETING_VERSION "
211
+ f"in your project's build settings (" + BUILD_SETTING_SOURCES +
212
+ "). " + MIGRATION_HINT,
213
+ file=sys.stderr,
214
+ )
215
+ raise SystemExit(2)
216
+
217
+
218
+ def _reject_in_review_match(target: str, match: dict) -> None:
219
+ """Issue 13: REUSE/CREATE must NOT touch a row Apple is reviewing
220
+ OR a row awaiting the developer's manual release click
221
+ (PENDING_DEVELOPER_RELEASE). Both states require human action;
222
+ auto-rolling past them would either race App Review or bypass the
223
+ developer's release decision."""
224
+ state, vid = match.get("state", ""), match.get("id", "")
225
+ if state == "PENDING_DEVELOPER_RELEASE":
226
+ situation = (
227
+ "is approved by App Review and awaiting your manual release. "
228
+ "Either release the existing build via App Store Connect, or"
229
+ )
230
+ else:
231
+ situation = (
232
+ "is currently in App Review and cannot be modified. Either "
233
+ "wait for review to complete, or"
234
+ )
235
+ print(
236
+ f"::error::MARKETING_VERSION {target} exists in App Store Connect "
237
+ f"in state {state} (id={vid}). The version {situation} "
238
+ f"bump MARKETING_VERSION to a fresh value in your project's "
239
+ f"build settings (" + BUILD_SETTING_SOURCES + "). "
240
+ + MIGRATION_HINT,
241
+ file=sys.stderr,
242
+ )
243
+ raise SystemExit(2)
244
+
245
+
246
+ def _auto_roll_past_locked(
247
+ target_version: str, versions: list[dict],
248
+ ) -> tuple[str, str, dict | None, bool]:
249
+ """Try to auto-roll past a TERMINAL row at target.
250
+
251
+ Classifies the row(s) at target. When the match is terminal
252
+ (READY_FOR_SALE / PROCESSING_FOR_APP_STORE / PENDING_APPLE_RELEASE
253
+ etc.), fires ``maybe_auto_bump`` to advance the project's
254
+ MARKETING_VERSION to the next patch and re-classifies against the
255
+ rolled value. When auto-bump is disabled (policy='none') or the
256
+ write target is unreachable, returns the original locked
257
+ classification so the caller can surface the historical
258
+ SystemExit(2) rejection.
259
+
260
+ DESIGN DECISION: in-review collisions (WAITING_FOR_REVIEW /
261
+ IN_REVIEW / PENDING_DEVELOPER_RELEASE) are deliberately NOT
262
+ auto-rolled. Apple is either actively reviewing the row at
263
+ ``target`` or has approved it and is waiting on the developer's
264
+ manual release click; advancing the project to a new version would
265
+ either submit a competing build while review is in flight or
266
+ bypass the developer's release decision -- both states require
267
+ human action. The historical SystemExit(2) rejection at these
268
+ states surfaces the conflict loudly so a human can resolve it.
269
+
270
+ Returns ``(effective_target, status, match, also_editable)``."""
271
+ status, match, also_editable = _classify_match_at_target(
272
+ versions, target_version,
273
+ )
274
+ if status != "terminal":
275
+ return target_version, status, match, also_editable
276
+ rolled = maybe_auto_bump(target_version, target_version)
277
+ if rolled is None or rolled == target_version:
278
+ return target_version, status, match, also_editable
279
+ _log(f"auto-roll: {target_version} is {match.get('state', '')}, "
280
+ f"advancing project to {rolled}")
281
+ new_status, new_match, new_also = _classify_match_at_target(
282
+ versions, rolled,
283
+ )
284
+ return rolled, new_status, new_match, new_also
285
+
286
+
287
+ def decide_for_version(
288
+ target_version: str, versions: list[dict],
289
+ app_id: str, token: str,
290
+ ) -> dict:
291
+ """Find-or-create the ASC row at exactly ``target_version``.
292
+
293
+ Validates semver, runs the non-strict floor check (auto-bump
294
+ rewrites MARKETING_VERSION when below the ASC floor; the returned
295
+ effective target is what downstream uses), then classifies any
296
+ row(s) at target. Terminal collisions auto-roll to the next patch
297
+ (or reject when policy='none'); in-review and unknown-state
298
+ collisions reject loudly (see ``_classify_match_at_target`` for
299
+ the full taxonomy). No match -> CREATE; reusable editable -> REUSE."""
300
+ _validate_target(target_version)
301
+ target_version = assert_target_meets_floor(
302
+ target_version, app_id, token,
303
+ appstore_versions=versions, strict=False,
304
+ )
305
+ target_version, status, match, also_editable = _auto_roll_past_locked(
306
+ target_version, versions,
307
+ )
308
+ if status == "terminal":
309
+ _reject_match(target_version, match, also_editable=also_editable)
310
+ if status == "in_review":
311
+ _reject_in_review_match(target_version, match)
312
+ if status == "unknown":
313
+ _reject_match(target_version, match, unknown=True)
314
+ if status == "none":
315
+ return decide_create(target_version, versions, app_id, token)
316
+ vid = match.get("id", "")
317
+ state = match.get("state", "")
318
+ _log(f"decision=REUSE versionString={target_version} "
319
+ f"(matches MARKETING_VERSION; state={state}, id={vid})")
320
+ return _result("REUSE", target_version, vid, state)
321
+
322
+
323
+ def main() -> None:
324
+ key_id = env("ASC_KEY_ID")
325
+ issuer_id = env("ASC_ISSUER_ID")
326
+ key_path = env("ASC_KEY_PATH")
327
+ app_id = env("APP_STORE_APPLE_ID")
328
+ target_version = env("MARKETING_VERSION")
329
+
330
+ token = make_jwt(key_id, issuer_id, key_path)
331
+ versions = fetch_versions(app_id, token)
332
+ result = decide_for_version(target_version, versions, app_id, token)
333
+ print(json.dumps(result))
334
+
335
+
336
+ if __name__ == "__main__":
337
+ main()
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared constants and tiny helpers for AI-driven App Store metadata automation.
4
+
5
+ Used by asc_metadata_detector.py, asc_metadata_applier.py, app_context_scanner.py
6
+ and their tests. Keep this file small and dependency-free -- it is the contract
7
+ between the read-side (detector), write-side (applier), and context scanner.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+
15
+
16
+ def warn(msg: str) -> None:
17
+ """Emit a GitHub-Actions-formatted warning on stderr."""
18
+ print(f"::warning::{msg}", file=sys.stderr)
19
+
20
+
21
+ def log(msg: str) -> None:
22
+ """Emit a plain stderr log line (not parsed by GitHub Actions)."""
23
+ print(msg, file=sys.stderr)
24
+
25
+
26
+ def require_env(name: str) -> str:
27
+ """Return a non-empty env var or raise SystemExit with a GHA error line."""
28
+ value = os.environ.get(name, "").strip()
29
+ if not value:
30
+ raise SystemExit(f"::error::{name} env var is required")
31
+ return value
32
+
33
+
34
+ # Fields owned by appInfoLocalizations (the "app-level" metadata resource).
35
+ # These persist across version submissions -- editing them requires an
36
+ # editable appInfo state (see EDITABLE_APP_INFO_STATES below).
37
+ APP_LEVEL_FIELDS = ("name", "subtitle")
38
+
39
+ # Fields owned by appStoreVersionLocalizations (per-version metadata).
40
+ # Edited through the version slot resolved by manage_marketing_version.py.
41
+ VERSION_LEVEL_FIELDS = (
42
+ "description",
43
+ "keywords",
44
+ "promotionalText",
45
+ "whatsNew",
46
+ )
47
+
48
+ # URL-typed metadata fields, grouped by owning resource. Never generated by AI,
49
+ # never PATCHed by the applier, never reported by the detector as "empty".
50
+ APP_LEVEL_URL_FIELDS = ("privacyPolicyUrl", "privacyChoicesUrl")
51
+ VERSION_LEVEL_URL_FIELDS = ("supportUrl", "marketingUrl")
52
+
53
+ # Unioned skip set used by both detector and applier as a single gate.
54
+ SKIP_URL_FIELDS = frozenset(APP_LEVEL_URL_FIELDS + VERSION_LEVEL_URL_FIELDS)
55
+
56
+ # Full per-resource field lists (editable + URL) as fetched from the ASC API.
57
+ APP_INFO_LOC_FIELDS = APP_LEVEL_FIELDS + APP_LEVEL_URL_FIELDS
58
+ VERSION_LOC_FIELDS = VERSION_LEVEL_FIELDS + VERSION_LEVEL_URL_FIELDS
59
+
60
+
61
+ # Apple allows editing appInfoLocalizations only while the parent appInfo
62
+ # record is in one of these states. Outside of these, the applier must
63
+ # no-op (the API would reject the PATCH).
64
+ #
65
+ # Mirrors asc_common.EDITABLE_STATES but narrowed -- IN_REVIEW /
66
+ # WAITING_FOR_REVIEW lock the appInfo resource even though they permit
67
+ # some version-level edits.
68
+ EDITABLE_APP_INFO_STATES = frozenset({
69
+ "PREPARE_FOR_SUBMISSION",
70
+ "REJECTED",
71
+ "METADATA_REJECTED",
72
+ "DEVELOPER_REJECTED",
73
+ "WAITING_FOR_REVIEW",
74
+ })
75
+
76
+
77
+ # App Store version states that accept PATCHes on appStoreVersionLocalizations
78
+ # (description, keywords, promotionalText, whatsNew). Outside of these, Apple's
79
+ # API returns 409 Conflict. Narrower than asc_common.EDITABLE_STATES -- we
80
+ # exclude IN_REVIEW and INVALID_BINARY which reject localization edits.
81
+ EDITABLE_VERSION_STATES = frozenset({
82
+ "PREPARE_FOR_SUBMISSION",
83
+ "DEVELOPER_REJECTED",
84
+ "METADATA_REJECTED",
85
+ "REJECTED",
86
+ "WAITING_FOR_REVIEW",
87
+ "READY_FOR_REVIEW",
88
+ })
89
+
90
+
91
+ # Per-field character caps enforced by Apple's App Store submission review.
92
+ # IMPORTANT: these are CHARACTER counts (len(str)) not BYTE counts. Apple's
93
+ # UI and API both measure by character, which matters for CJK locales
94
+ # where one character is often 3+ UTF-8 bytes. Validate with len(value).
95
+ CHAR_LIMITS = {
96
+ "name": 30,
97
+ "subtitle": 30,
98
+ "keywords": 100,
99
+ "description": 4000,
100
+ "promotionalText": 170,
101
+ "whatsNew": 4000,
102
+ }
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CREATE-path helpers for manage_marketing_version.
4
+
5
+ This module owns the wiring that turns a target versionString into either
6
+ a fresh /appStoreVersions POST or a PATCH-rename of a stale editable. The
7
+ ``_no_bump`` sentinel disarms ``asc_version_create.create_or_reuse``'s
8
+ bump-retry loop because the project's MARKETING_VERSION is the single
9
+ source of truth -- CI must never invent a new versionString.
10
+
11
+ The ``_via_module`` helper provides a late-binding seam back through
12
+ ``manage_marketing_version`` so test patches at ``mmv.fetch_versions`` /
13
+ ``mmv.create_version`` keep winning even though the call originates here.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+
20
+ from asc_common import EDITABLE_STATES, REUSABLE_STATES
21
+ import asc_build_history
22
+ import asc_version_create
23
+ from mmv_floor_check import assert_target_meets_floor, semver_tuple
24
+
25
+
26
+ def _no_bump(current: str) -> str:
27
+ """Default bump_fn: never bump. CI must not invent a marketing
28
+ version because the project's build setting is authoritative."""
29
+ print(
30
+ f"::error::POST /appStoreVersions returned 409 for {current}; "
31
+ f"ASC reports this versionString already exists but it was not "
32
+ f"visible in /appStoreVersions/preReleaseVersions/builds. "
33
+ f"Inspect the app in App Store Connect manually.",
34
+ file=sys.stderr,
35
+ )
36
+ raise SystemExit(4)
37
+
38
+
39
+ def _via_module(name: str):
40
+ """Late-binding seam for tests that patch attributes on
41
+ ``manage_marketing_version``. Returns a wrapper that defers attribute
42
+ lookup to call time so ``mock.patch.object(mmv, name)`` keeps winning."""
43
+ def _call(*args, **kwargs):
44
+ import manage_marketing_version
45
+ return getattr(manage_marketing_version, name)(*args, **kwargs)
46
+ return _call
47
+
48
+
49
+ def create_version(app_id: str, version: str, token: str):
50
+ return asc_version_create.create_version(app_id, version, token)
51
+
52
+
53
+ def create_or_reuse(
54
+ app_id: str, version: str, token: str,
55
+ *, bump_fn=None,
56
+ ) -> str:
57
+ """POST a new version at exactly `version`. Caller passes a `bump_fn`
58
+ that disarms the bump-retry path by raising; this script never wants
59
+ a server-side bump because the project's MARKETING_VERSION is the
60
+ single source of truth."""
61
+ return create_or_reuse_with_stale(
62
+ app_id, version, token, bump_fn=bump_fn,
63
+ )
64
+
65
+
66
+ def create_or_reuse_with_stale(
67
+ app_id: str, version: str, token: str,
68
+ *, bump_fn=None, stale_editable_id: str | None = None,
69
+ ) -> str:
70
+ """``create_or_reuse`` plus the optional stale-editable PATCH-rename
71
+ seam so ``decide_for_version`` can promote a leftover editable row at
72
+ a different versionString to the project's freshly-bumped target."""
73
+ return asc_version_create.create_or_reuse(
74
+ app_id, version, token,
75
+ bump_fn=bump_fn or _no_bump,
76
+ fetch_versions=_via_module("fetch_versions"),
77
+ fetch_prerelease_versions=asc_build_history.fetch_prerelease_versions,
78
+ fetch_builds_prerelease_versions=(
79
+ asc_build_history.fetch_builds_prerelease_versions
80
+ ),
81
+ post_fn=_via_module("create_version"),
82
+ stale_editable_id=stale_editable_id,
83
+ patch_fn=asc_version_create.patch_version,
84
+ delete_fn=asc_version_create.delete_version,
85
+ )
86
+
87
+
88
+ def _refuse_downgrade(target: str, version_string: str, vid: str, state: str) -> None:
89
+ """Emit a SystemExit(2) error when an editable exists at versionString >= target.
90
+
91
+ PATCH-renaming a higher-or-equal editable would silently downgrade an
92
+ in-progress draft. The CREATE-path floor check does NOT catch this
93
+ because editable rows do not contribute to combined_floor."""
94
+ print(
95
+ f"::error::Cannot CREATE MARKETING_VERSION {target}: an "
96
+ f"editable App Store version exists at {version_string} "
97
+ f"(id={vid}, state={state}). Renaming a "
98
+ f"higher or equal-version editable would downgrade an "
99
+ f"in-progress draft. Either bump MARKETING_VERSION to "
100
+ f">= {version_string} in your project's build settings "
101
+ f"(project.yml [xcodegen], the .xcodeproj's "
102
+ f"MARKETING_VERSION xcconfig, or Info.plist's "
103
+ f"CFBundleShortVersionString), or manually delete or "
104
+ f"rename the existing draft in App Store Connect. "
105
+ f"See .github/actions/swift-app/MIGRATION.md "
106
+ f"for the migration procedure.",
107
+ file=sys.stderr,
108
+ )
109
+ raise SystemExit(2)
110
+
111
+
112
+ def _stale_editable_id(versions: list[dict], target: str) -> str | None:
113
+ """Return the id of a REUSABLE row at a versionString strictly LESS
114
+ than target (a leftover lower-version draft to PATCH-rename onto the
115
+ project's freshly-bumped MARKETING_VERSION). Returns None when no
116
+ rename is needed.
117
+
118
+ SystemExit(2) when an editable exists at a versionString >= target:
119
+ PATCH-renaming it would silently downgrade an in-progress draft.
120
+ The CREATE-path floor check does NOT catch this because editable
121
+ rows do not contribute to combined_floor.
122
+
123
+ Issue 13: rows currently under Apple Review (WAITING_FOR_REVIEW /
124
+ IN_REVIEW) are EDITABLE in ASC's broad sense but mutating them would
125
+ interfere with the review process. They are still subject to the
126
+ >= target downgrade check (Issue 10 still applies), but they are
127
+ NOT candidates for PATCH-rename when version < target — the CREATE
128
+ path falls through to a plain POST instead, leaving the in-review
129
+ row alone."""
130
+ target_t = semver_tuple(target)
131
+ candidates: list[str] = []
132
+ for v in versions:
133
+ state = v.get("state")
134
+ if state not in EDITABLE_STATES:
135
+ continue
136
+ version_string = v.get("versionString") or ""
137
+ if version_string == target:
138
+ continue
139
+ vid = v.get("id")
140
+ if not vid:
141
+ continue
142
+ if semver_tuple(version_string) >= target_t:
143
+ _refuse_downgrade(target, version_string, vid, state)
144
+ # In-review rows (WAITING_FOR_REVIEW / IN_REVIEW) are editable
145
+ # but not safe to rename — leave them alone and POST a fresh row.
146
+ if state in REUSABLE_STATES:
147
+ candidates.append(vid)
148
+ return candidates[0] if candidates else None
149
+
150
+
151
+ def decide_create(
152
+ target_version: str, versions: list[dict],
153
+ app_id: str, token: str,
154
+ ) -> dict:
155
+ """CREATE path: enforce strict floor, PATCH-rename any stale editable,
156
+ then POST. SystemExit(2) when target is not strictly above the floor.
157
+
158
+ Late-binds ``mmv.create_or_reuse_with_stale`` and ``mmv._result`` so
159
+ test patches at the parent module win."""
160
+ import manage_marketing_version as mmv
161
+ # The strict floor-check may also auto-bump (e.g. on a CREATE-path
162
+ # call where the project is below the floor and the non-strict
163
+ # pre-check did not fire because the prior caller skipped it). Use
164
+ # the returned value for all subsequent reasoning.
165
+ target_version = assert_target_meets_floor(
166
+ target_version, app_id, token,
167
+ appstore_versions=versions, strict=True,
168
+ )
169
+ stale_id = _stale_editable_id(versions, target_version)
170
+ new_id = mmv.create_or_reuse_with_stale(
171
+ app_id, target_version, token, stale_editable_id=stale_id,
172
+ )
173
+ if stale_id:
174
+ msg = (f"decision=CREATE versionString={target_version} "
175
+ f"(PATCH-rename of stale editable id={stale_id}) "
176
+ f"-> appStoreVersion id={new_id}")
177
+ else:
178
+ msg = (f"decision=CREATE versionString={target_version} "
179
+ f"-> appStoreVersion id={new_id}")
180
+ print(f"[decision] {msg}", file=sys.stderr)
181
+ return mmv._result("CREATE", target_version, new_id)