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,246 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared helpers for App Store Connect API calls.
4
+
5
+ Provides JWT generation, a retrying HTTP client, and state-classification
6
+ constants used by multiple scripts in this action.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ import time
14
+ from typing import Any
15
+
16
+ import jwt
17
+ import requests
18
+
19
+
20
+ ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
21
+
22
+ # ----------------------------------------------------------------------
23
+ # ASC version-state taxonomy (consumed by manage_marketing_version's
24
+ # ``_classify_match_at_target``):
25
+ #
26
+ # terminal -- READY_FOR_SALE / PROCESSING_FOR_APP_STORE /
27
+ # PENDING_APPLE_RELEASE / archived (REPLACED_WITH_NEW_VERSION,
28
+ # REMOVED_FROM_SALE, NOT_APPLICABLE). Slot is locked; auto-roll
29
+ # may advance the project's MARKETING_VERSION to the next
30
+ # patch instead of rejecting.
31
+ # in_review -- WAITING_FOR_REVIEW / IN_REVIEW (Apple is actively reviewing)
32
+ # OR PENDING_DEVELOPER_RELEASE (Apple approved; awaiting the
33
+ # developer's manual release click). Auto-roll is forbidden:
34
+ # advancing past either state would race the review process or
35
+ # bypass the developer's release decision.
36
+ # editable -- PREPARE_FOR_SUBMISSION / REJECTED / METADATA_REJECTED /
37
+ # DEVELOPER_REJECTED / INVALID_BINARY. REUSE the row's id
38
+ # (developer-actionable; mutating is safe).
39
+ # unknown -- any state in NONE of the above. Round-12 fail-closed: the
40
+ # classifier rejects rather than guessing, so a future ASC
41
+ # state cannot silently 409 on CREATE or interfere with
42
+ # whatever Apple is doing with the row.
43
+ #
44
+ # Round-11 design decision: the classifier uses POSITIVE allowlists below
45
+ # rather than ``not in EDITABLE_STATES``. The negative test misclassified
46
+ # PENDING_DEVELOPER_RELEASE as terminal -- it's NOT editable, but it's also
47
+ # NOT auto-rollable (the developer has an approved build awaiting their
48
+ # manual release). Positive allowlists make the taxonomy explicit and
49
+ # prevent future ASC states from silently falling into the auto-roll
50
+ # bucket.
51
+ # ----------------------------------------------------------------------
52
+
53
+ # App Store version states that allow editing the current draft.
54
+ EDITABLE_STATES = {
55
+ "PREPARE_FOR_SUBMISSION",
56
+ "WAITING_FOR_REVIEW",
57
+ "IN_REVIEW",
58
+ "REJECTED",
59
+ "METADATA_REJECTED",
60
+ "INVALID_BINARY",
61
+ "DEVELOPER_REJECTED",
62
+ }
63
+
64
+ # Subset of EDITABLE_STATES safe to mutate (attach a build, rename via PATCH)
65
+ # without going through Apple Review. WAITING_FOR_REVIEW and IN_REVIEW are
66
+ # editable in the broad ASC sense (developer can withdraw), but Apple is
67
+ # actively reviewing them: attaching a TestFlight build or PATCH-renaming
68
+ # such a row would silently interfere with App Review. REJECTED /
69
+ # METADATA_REJECTED / INVALID_BINARY come back from Apple Review and are
70
+ # explicitly developer-actionable, so mutating them is safe.
71
+ REUSABLE_STATES = {
72
+ "PREPARE_FOR_SUBMISSION",
73
+ "DEVELOPER_REJECTED",
74
+ "REJECTED",
75
+ "METADATA_REJECTED",
76
+ "INVALID_BINARY",
77
+ }
78
+
79
+ # Editable states that are currently under Apple Review. Mutating these
80
+ # would interfere with the review process.
81
+ IN_REVIEW_STATES = {
82
+ "WAITING_FOR_REVIEW",
83
+ "IN_REVIEW",
84
+ }
85
+
86
+ # Terminal states: the version is locked and a new one must be created.
87
+ TERMINAL_STATES = {
88
+ "READY_FOR_SALE",
89
+ "PROCESSING_FOR_APP_STORE",
90
+ "PENDING_APPLE_RELEASE",
91
+ "REPLACED_WITH_NEW_VERSION",
92
+ "REMOVED_FROM_SALE",
93
+ "NOT_APPLICABLE",
94
+ }
95
+
96
+ # Blocking states: the version is approved but awaiting developer action.
97
+ BLOCKING_STATES = {"PENDING_DEVELOPER_RELEASE"}
98
+
99
+
100
+ # Retry policy for App Store Connect API calls.
101
+ #
102
+ # Apple's API intermittently returns 5xx under load and uses 429 for throttling.
103
+ # These are retryable; non-listed 4xx responses are not (retrying just produces
104
+ # the same failure).
105
+ _RETRY_STATUSES = {429, 500, 502, 503, 504}
106
+ _RETRY_BACKOFFS = (2, 8, 30) # seconds
107
+
108
+ # Per-request timeouts (seconds). Splitting connect vs read so a slow TLS
109
+ # handshake can't masquerade as a slow API response (and vice versa).
110
+ # Without these, ``requests.request(...)`` would block the CI runner
111
+ # indefinitely on a hung TCP socket -- the runner only kills the job at
112
+ # its 6h hard limit, by which point the on-call has already paged.
113
+ _DEFAULT_TIMEOUT_CONNECT_SEC = 10.0
114
+ _DEFAULT_TIMEOUT_READ_SEC = 30.0
115
+ # Env overrides for slow networks / unusually large ASC responses.
116
+ _TIMEOUT_CONNECT_ENV = "ASC_REQUEST_TIMEOUT_CONNECT_SEC"
117
+ _TIMEOUT_READ_ENV = "ASC_REQUEST_TIMEOUT_READ_SEC"
118
+
119
+
120
+ def make_jwt(key_id: str, issuer_id: str, key_path: str) -> str:
121
+ """Return an ES256 JWT valid for 20 minutes, audience appstoreconnect-v1."""
122
+ with open(key_path, "r") as f:
123
+ key = f.read()
124
+ now = int(time.time())
125
+ payload = {
126
+ "iss": issuer_id,
127
+ "iat": now,
128
+ "exp": now + 1200,
129
+ "aud": "appstoreconnect-v1",
130
+ }
131
+ return jwt.encode(
132
+ payload, key, algorithm="ES256", headers={"kid": key_id, "typ": "JWT"}
133
+ )
134
+
135
+
136
+ def _request_timeouts() -> tuple[float, float]:
137
+ """Return ``(connect, read)`` timeouts in seconds. Both env-overridable
138
+ for slow networks (``ASC_REQUEST_TIMEOUT_CONNECT_SEC`` /
139
+ ``ASC_REQUEST_TIMEOUT_READ_SEC``); invalid or non-positive values
140
+ fall back to the defaults so a typo can't disable the timeout."""
141
+ def read(env_name: str, default: float) -> float:
142
+ raw = (os.environ.get(env_name) or "").strip()
143
+ if not raw:
144
+ return default
145
+ try:
146
+ value = float(raw)
147
+ except ValueError:
148
+ return default
149
+ return value if value > 0 else default
150
+ return (
151
+ read(_TIMEOUT_CONNECT_ENV, _DEFAULT_TIMEOUT_CONNECT_SEC),
152
+ read(_TIMEOUT_READ_ENV, _DEFAULT_TIMEOUT_READ_SEC),
153
+ )
154
+
155
+
156
+ def _retry_network_error(
157
+ method: str, path: str, attempt: int, total: int,
158
+ backoffs: tuple[int, ...], exc: BaseException,
159
+ ) -> None:
160
+ """Network error path: sleep and return when retries remain, else
161
+ raise SystemExit (terminal). Caller ``continue``s after return."""
162
+ if attempt >= total - 1:
163
+ raise SystemExit(
164
+ f"ASC {method} {path} network error after {total} "
165
+ f"attempts: {exc!r}"
166
+ )
167
+ delay = backoffs[attempt]
168
+ print(
169
+ f"ASC {method} {path} network error ({exc!r}); "
170
+ f"retrying in {delay}s ({attempt + 1}/{total - 1})",
171
+ file=sys.stderr,
172
+ )
173
+ time.sleep(delay)
174
+
175
+
176
+ def _retry_status(
177
+ method: str, path: str, attempt: int, total: int,
178
+ backoffs: tuple[int, ...], status: int,
179
+ ) -> bool:
180
+ """Retryable status code: sleep and return True when retries remain,
181
+ else return False so the caller breaks to the final SystemExit."""
182
+ if attempt >= total - 1:
183
+ return False
184
+ delay = backoffs[attempt]
185
+ print(
186
+ f"ASC {method} {path} returned {status}; "
187
+ f"retrying in {delay}s ({attempt + 1}/{total - 1})",
188
+ file=sys.stderr,
189
+ )
190
+ time.sleep(delay)
191
+ return True
192
+
193
+
194
+ def request(
195
+ method: str,
196
+ path: str,
197
+ token: str,
198
+ *,
199
+ json_body: Any = None,
200
+ params: dict | None = None,
201
+ allow_status: set[int] | None = None,
202
+ max_attempts: int = 3,
203
+ ) -> requests.Response:
204
+ """HTTP request with retry on 429/5xx and explicit connect/read timeouts.
205
+
206
+ `path` is the portion after `/v1` (e.g. "/apps/123/appStoreVersions").
207
+ `allow_status` lists non-2xx statuses the caller wants returned without
208
+ raising (useful for 409 conflict handling). Per-request timeouts come
209
+ from ``_request_timeouts()`` (env-overridable); without them a hung
210
+ TCP socket would block the CI runner indefinitely.
211
+ Raises SystemExit on non-retryable failure with a clear stderr message.
212
+ """
213
+ url = f"{ASC_BASE}{path}"
214
+ headers = {"Authorization": f"Bearer {token}"}
215
+ if json_body is not None:
216
+ headers["Content-Type"] = "application/json"
217
+ backoffs = _RETRY_BACKOFFS[: max(0, max_attempts - 1)]
218
+ total = len(backoffs) + 1
219
+ timeout = _request_timeouts()
220
+ resp: requests.Response | None = None
221
+ for attempt in range(total):
222
+ try:
223
+ resp = requests.request(
224
+ method, url, headers=headers, params=params,
225
+ json=json_body, timeout=timeout,
226
+ )
227
+ except requests.RequestException as exc:
228
+ _retry_network_error(method, path, attempt, total, backoffs, exc)
229
+ continue
230
+ if resp.status_code < 400:
231
+ return resp
232
+ if allow_status and resp.status_code in allow_status:
233
+ return resp
234
+ if resp.status_code in _RETRY_STATUSES and _retry_status(
235
+ method, path, attempt, total, backoffs, resp.status_code):
236
+ continue
237
+ break
238
+ assert resp is not None
239
+ raise SystemExit(
240
+ f"ASC {method} {path} failed: {resp.status_code}\n{resp.text[:2000]}"
241
+ )
242
+
243
+
244
+ def get_json(path: str, token: str, *, params: dict | None = None) -> dict:
245
+ """GET `path` and return the decoded JSON body."""
246
+ return request("GET", path, token, params=params).json()
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Apply AI-generated App Store Connect metadata to the resources identified by
4
+ asc_metadata_detector.
5
+
6
+ Reads two JSON files:
7
+
8
+ --state detector output. Authoritative allow-list of empty fields per
9
+ locale plus resource ids (appInfoLocalizationId /
10
+ appStoreVersionLocalizationId).
11
+ --response raw AI inference JSON. Expected shape:
12
+ {"localizations": {"en-US": {"name": "...", ...}, ...}}
13
+
14
+ PATCHes ONLY fields that appear in state['empty_fields'][locale] AND are not in
15
+ SKIP_URL_FIELDS -- belt-and-suspenders double-gate against the detector losing
16
+ track of a URL field or a subsequent manual edit populating a previously empty
17
+ field.
18
+
19
+ Non-fatal by design -- mirrors set_app_store_whats_new.py's fail-open pattern.
20
+ Any unexpected error becomes a ::warning:: and exits 0 so the TestFlight job
21
+ does not fail on best-effort metadata polish.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import re
29
+ import sys
30
+ from typing import Any
31
+
32
+ from asc_common import make_jwt, request
33
+ from metadata_constants import (
34
+ APP_LEVEL_FIELDS,
35
+ CHAR_LIMITS,
36
+ SKIP_URL_FIELDS,
37
+ VERSION_LEVEL_FIELDS,
38
+ log,
39
+ require_env,
40
+ warn,
41
+ )
42
+
43
+
44
+ APP_INFO_RESOURCE = "appInfoLocalizations"
45
+ VERSION_RESOURCE = "appStoreVersionLocalizations"
46
+
47
+ # Collapses any run of ASCII whitespace (including the raw control chars AI
48
+ # sometimes emits inside string literals: \n, \r, \t, and interior spaces)
49
+ # into a single space. Applied after non-strict JSON load so field values
50
+ # stay within Apple's char-limit gates and don't contain newlines that
51
+ # ASC UI renders as literal line breaks in the store listing.
52
+ _WHITESPACE_RUN = re.compile(r"\s+")
53
+
54
+
55
+ def _normalize_whitespace(value: Any) -> Any:
56
+ """Return str with any whitespace run -> single space, trimmed. Non-str passthrough."""
57
+ if not isinstance(value, str):
58
+ return value
59
+ return _WHITESPACE_RUN.sub(" ", value).strip()
60
+
61
+
62
+ def _normalize_ai_response(data: dict[str, Any]) -> dict[str, Any]:
63
+ """Recursively collapse whitespace in every string value under
64
+ ``data['localizations'][locale][field]``.
65
+
66
+ Defensive against AI responses that emit literal newlines inside string
67
+ literals (permitted by non-strict json.loads but rejected by ASC field
68
+ validation).
69
+ """
70
+ locs = data.get("localizations")
71
+ if not isinstance(locs, dict):
72
+ return data
73
+ for locale, fields in list(locs.items()):
74
+ if not isinstance(fields, dict):
75
+ continue
76
+ for field, value in list(fields.items()):
77
+ fields[field] = _normalize_whitespace(value)
78
+ return data
79
+
80
+
81
+ def _load_json(path: str) -> dict[str, Any] | None:
82
+ try:
83
+ with open(path, "r", encoding="utf-8") as f:
84
+ raw = f.read()
85
+ except OSError as exc:
86
+ warn(f"could not read {path}: {exc!r}")
87
+ return None
88
+ try:
89
+ # strict=False permits unescaped control chars (e.g. raw \n, \t)
90
+ # inside JSON string literals per RFC 8259 s.7 relaxed reading.
91
+ # Apple's AI responses occasionally contain literal newlines that
92
+ # a strict parser would reject, collapsing the applier to 0 writes.
93
+ data = json.loads(raw, strict=False)
94
+ except json.JSONDecodeError as exc:
95
+ # Surface WHAT the file actually contained so a broken upstream
96
+ # capture (e.g. ai-inference output wrapped in markdown fences) is
97
+ # diagnosable from logs alone. Cap at 500 chars to stay well under
98
+ # GitHub Actions annotation limits.
99
+ preview = raw[:500].replace("\n", "\\n")
100
+ warn(
101
+ f"could not parse {path} as JSON: {exc!r}; "
102
+ f"length={len(raw)} first 500 chars: {preview!r}"
103
+ )
104
+ return None
105
+ if not isinstance(data, dict):
106
+ warn(f"{path} did not contain a JSON object")
107
+ return None
108
+ return data
109
+
110
+
111
+ def _validate_field(field: str, value: Any) -> str | None:
112
+ """Return normalized value (trimmed, within char limit) or None to drop."""
113
+ if field in SKIP_URL_FIELDS or not isinstance(value, str):
114
+ return None
115
+ trimmed = value.strip()
116
+ if not trimmed:
117
+ return None
118
+ limit = CHAR_LIMITS.get(field)
119
+ if limit is not None and len(trimmed) > limit:
120
+ warn(f"field '{field}' length {len(trimmed)} exceeds {limit}; dropping")
121
+ return None
122
+ return trimmed
123
+
124
+
125
+ def _build_writes(
126
+ ai_locale: dict[str, Any],
127
+ empty_list: list[str],
128
+ group_fields: tuple[str, ...],
129
+ ) -> dict[str, str]:
130
+ """Filter AI values for one field-group (app-level OR version-level).
131
+
132
+ Double-gates against `empty_list` (detector said field is empty) AND
133
+ SKIP_URL_FIELDS (never generate URLs). Validates char limits. Returns
134
+ {field: normalized_value} for every field that survives all gates.
135
+ """
136
+ empty_set = set(empty_list or ())
137
+ writes: dict[str, str] = {}
138
+ for field in group_fields:
139
+ if field not in empty_set or field in SKIP_URL_FIELDS or field not in ai_locale:
140
+ continue
141
+ normalized = _validate_field(field, ai_locale[field])
142
+ if normalized is not None:
143
+ writes[field] = normalized
144
+ return writes
145
+
146
+
147
+ def _patch_localization(
148
+ token: str, resource: str, loc_id: str, writes: dict[str, str]
149
+ ) -> None:
150
+ request(
151
+ "PATCH",
152
+ f"/{resource}/{loc_id}",
153
+ token,
154
+ json_body={
155
+ "data": {"type": resource, "id": loc_id, "attributes": writes}
156
+ },
157
+ )
158
+
159
+
160
+ def _extract_asc_detail(exc: SystemExit) -> str:
161
+ """Pull ASC's human-readable `errors[].detail` out of a SystemExit.
162
+
163
+ `asc_common.request` formats failures as
164
+ ``"ASC PATCH /path failed: {status}\\n{body[:2000]}"``. The body is
165
+ ASC's standard JSON errors envelope:
166
+ ``{"errors":[{"status":"409","code":"...","detail":"..."}]}``.
167
+ Parse best-effort; on any failure fall back to the raw message so we
168
+ never lose context.
169
+ """
170
+ raw = str(exc)
171
+ # Take everything after the first newline (the response body portion).
172
+ body = raw.split("\n", 1)[1] if "\n" in raw else ""
173
+ if not body:
174
+ return raw
175
+ try:
176
+ payload = json.loads(body)
177
+ errors = payload.get("errors") if isinstance(payload, dict) else None
178
+ if isinstance(errors, list) and errors:
179
+ first = errors[0] or {}
180
+ detail = first.get("detail") or ""
181
+ code = first.get("code") or ""
182
+ status = first.get("status") or ""
183
+ # Prefer detail (human-readable); fall back to code/status if absent.
184
+ if detail:
185
+ return f"{status} {code}: {detail}".strip(": ").strip()
186
+ if code or status:
187
+ return f"{status} {code}".strip()
188
+ except (json.JSONDecodeError, TypeError, AttributeError):
189
+ pass
190
+ return raw
191
+
192
+
193
+ def _patch_group(
194
+ token: str,
195
+ resource: str,
196
+ locale: str,
197
+ loc_id: str | None,
198
+ writes: dict[str, str],
199
+ ) -> int:
200
+ """PATCH one (resource, locale) pair -- one request PER FIELD.
201
+
202
+ Per-field attempts so a single ASC 409 (e.g. whatsNew rejected because
203
+ the version is past editable state while description/keywords/
204
+ promotionalText still accept edits) does not block the other fields.
205
+
206
+ Returns count of fields successfully written (0 on total skip).
207
+ """
208
+ if not writes:
209
+ return 0
210
+ if not loc_id:
211
+ if resource == VERSION_RESOURCE:
212
+ # Detector deliberately clears version_localization_id when the
213
+ # app store version is in a non-editable state (e.g. IN_REVIEW,
214
+ # WAITING_FOR_REVIEW with PATCH rejection). Surface it as a
215
+ # normal log, not a warning -- this is the intended no-op path.
216
+ log(
217
+ f"skipping version-level fields for {locale} "
218
+ f"(version not in editable state): {sorted(writes)}"
219
+ )
220
+ else:
221
+ warn(f"{locale}: {resource} id missing; skipping fields {sorted(writes)}")
222
+ return 0
223
+
224
+ written = 0
225
+ for field in sorted(writes):
226
+ value = writes[field]
227
+ try:
228
+ _patch_localization(token, resource, loc_id, {field: value})
229
+ except SystemExit as exc:
230
+ detail = _extract_asc_detail(exc)
231
+ warn(
232
+ f"{locale}: /{resource} {field} PATCH rejected by ASC: {detail}"
233
+ )
234
+ continue
235
+ preview = value if len(value) <= 60 else value[:57] + "..."
236
+ log(f"PATCHed /{resource}/{loc_id} ({locale}) {field}={preview!r}")
237
+ written += 1
238
+ return written
239
+
240
+
241
+ def _apply_locale(
242
+ token: str,
243
+ locale: str,
244
+ loc_state: dict[str, Any],
245
+ ai_locale: dict[str, Any],
246
+ empty_list: list[str],
247
+ ) -> int:
248
+ """PATCH the writes for one locale. Returns count of fields written."""
249
+ app_writes = _build_writes(ai_locale, empty_list, APP_LEVEL_FIELDS)
250
+ ver_writes = _build_writes(ai_locale, empty_list, VERSION_LEVEL_FIELDS)
251
+ return (
252
+ _patch_group(token, APP_INFO_RESOURCE, locale,
253
+ loc_state.get("app_info_localization_id"), app_writes)
254
+ + _patch_group(token, VERSION_RESOURCE, locale,
255
+ loc_state.get("version_localization_id"), ver_writes)
256
+ )
257
+
258
+
259
+ def apply(
260
+ state: dict[str, Any],
261
+ ai_json: dict[str, Any],
262
+ token: str,
263
+ fields_filter: set[str] | None = None,
264
+ ) -> tuple[int, int]:
265
+ """Drive `_apply_locale` across every locale in `state['empty_fields']`.
266
+
267
+ When ``fields_filter`` is provided, only fields in the set are considered
268
+ -- the detector's empty_list is intersected with the filter before gating,
269
+ preserving the double-gate invariant (detector must still have flagged the
270
+ field as empty). Used by the two-phase orchestrator to PATCH
271
+ ``description`` first (phase 1) then the remaining fields (phase 2).
272
+
273
+ Returns (total_fields_written, locales_touched).
274
+ """
275
+ empty_fields = state.get("empty_fields") or {}
276
+ localizations = state.get("localizations") or {}
277
+ ai_locs = (ai_json or {}).get("localizations") or {}
278
+
279
+ total_written = 0
280
+ locales_touched = 0
281
+ for locale, empty_list in empty_fields.items():
282
+ if not empty_list:
283
+ continue
284
+ effective = (
285
+ [f for f in empty_list if f in fields_filter]
286
+ if fields_filter is not None
287
+ else list(empty_list)
288
+ )
289
+ if not effective:
290
+ continue
291
+ loc_state = localizations.get(locale) or {}
292
+ ai_locale = ai_locs.get(locale) or {}
293
+ if not ai_locale:
294
+ continue
295
+ written = _apply_locale(token, locale, loc_state, ai_locale, effective)
296
+ if written:
297
+ total_written += written
298
+ locales_touched += 1
299
+ return total_written, locales_touched
300
+
301
+
302
+ def _parse_fields_filter(raw: str | None) -> set[str] | None:
303
+ """Parse the ``--fields-filter`` CLI arg into a set or None."""
304
+ if not raw:
305
+ return None
306
+ parsed = {f.strip() for f in raw.split(",") if f.strip()}
307
+ return parsed or None
308
+
309
+
310
+ def main() -> int:
311
+ parser = argparse.ArgumentParser(description="Apply AI-generated ASC metadata")
312
+ parser.add_argument("--state", required=True, help="detector state JSON")
313
+ parser.add_argument("--response", required=True, help="AI response JSON")
314
+ parser.add_argument(
315
+ "--fields-filter",
316
+ default=None,
317
+ help="comma-separated subset of fields to PATCH (intersected with empty_fields)",
318
+ )
319
+ args = parser.parse_args()
320
+
321
+ fields_filter = _parse_fields_filter(args.fields_filter)
322
+ if fields_filter is None:
323
+ log("field-filter: none (all empty fields)")
324
+ else:
325
+ log(f"field-filter active: {sorted(fields_filter)}")
326
+
327
+ state = _load_json(args.state)
328
+ ai_json = _load_json(args.response)
329
+ if state is None or ai_json is None:
330
+ return 0
331
+
332
+ # Normalize any unescaped control chars / multi-line runs the AI may
333
+ # have emitted -- keeps field values within Apple's char limits and
334
+ # prevents literal newlines leaking into the store listing.
335
+ ai_json = _normalize_ai_response(ai_json)
336
+
337
+ if not state.get("empty_fields"):
338
+ log("metadata fully populated; AI applier no-op")
339
+ return 0
340
+ if not (ai_json.get("localizations") or {}):
341
+ warn("AI response contains no localizations; nothing to apply")
342
+ return 0
343
+
344
+ try:
345
+ token = make_jwt(
346
+ require_env("ASC_KEY_ID"),
347
+ require_env("ASC_ISSUER_ID"),
348
+ require_env("ASC_KEY_PATH"),
349
+ )
350
+ except SystemExit:
351
+ raise
352
+ except Exception as exc:
353
+ warn(f"ASC JWT generation failed: {exc!r}")
354
+ return 0
355
+
356
+ written, locales = apply(state, ai_json, token, fields_filter=fields_filter)
357
+ log(f"applied {written} writes across {locales} locales")
358
+ return 0
359
+
360
+
361
+ if __name__ == "__main__":
362
+ try:
363
+ sys.exit(main())
364
+ except SystemExit:
365
+ raise
366
+ except Exception as exc: # fail-open parity with set_app_store_whats_new
367
+ warn(f"asc_metadata_applier failed (non-fatal): {exc!r}")
368
+ sys.exit(0)