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