@daemux/store-automator 0.10.95 → 0.10.97

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.
@@ -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/ios-native-testflight/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)
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Floor-check helpers for manage_marketing_version.
4
+
5
+ The combined floor is the max versionString across:
6
+ - /appStoreVersions (caller-provided, ship-blocking states only)
7
+ - /preReleaseVersions (TestFlight trains)
8
+ - /builds->preReleaseVersion (hidden trains visible only via builds)
9
+
10
+ Plus an independent narrow per-state ground-truth query as defence-in-depth
11
+ against truncated/buggy main fetches.
12
+
13
+ This module owns the semver primitives, the floor-resolving wrappers
14
+ that tests patch (``fetch_versions``, ``get_ground_truth_floor``,
15
+ ``get_combined_floor``), and the strict/non-strict assertion that
16
+ ``decide_for_version`` runs before REUSE/CREATE branching.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import re
23
+ import sys
24
+
25
+ import asc_version_fetch
26
+ import version_utils
27
+
28
+
29
+ SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
30
+
31
+ # DESIGN DECISION: auto-bump policy is read from the env var so the action
32
+ # step can wire it from `inputs.marketing-version-auto-bump` without a
33
+ # CLI-flag re-plumb of every helper. Default 'rollover' matches the action
34
+ # input default -- patch with carry at .9 (1.0.9 -> 1.1.0) is the more
35
+ # natural semver progression for unattended CI. ``'patch'`` is preserved
36
+ # unchanged for consumers pinning the historical (unbounded) behavior.
37
+ # ``'none'`` preserves the historical fail-the-build path.
38
+ _AUTO_BUMP_ENV = "MARKETING_VERSION_AUTO_BUMP"
39
+
40
+ # DESIGN DECISION: auto-bump must only fire when the resulting bumped
41
+ # value can be committed back to the repo in the same run. The
42
+ # commit-back step in action.yml is gated on `event=push` AND
43
+ # `ref=refs/heads/<default_branch>`. Auto-bumping on any other event
44
+ # (workflow_dispatch, pull_request, push to feature branches) would
45
+ # upload an IPA at the bumped version while git stays at the old
46
+ # value, causing the next run to recompute against stale state and
47
+ # either re-bump or drift.
48
+ #
49
+ # These envs are set by the action.yml step from
50
+ # ``${{ github.event_name }}`` and ``${{ github.ref }}`` /
51
+ # ``${{ github.event.repository.default_branch }}``. Empty / unset
52
+ # values are treated as "context unknown -> refuse to auto-bump"
53
+ # (defensive: when run outside the GitHub Actions context, e.g. local
54
+ # dev or a third-party orchestrator, the safe default is to require a
55
+ # human bump).
56
+ _EVENT_ENV = "GITHUB_EVENT_NAME"
57
+ _REF_ENV = "GITHUB_REF"
58
+ _DEFAULT_BRANCH_ENV = "GITHUB_DEFAULT_BRANCH"
59
+
60
+ # These constants and helpers are intentionally public (no leading
61
+ # underscore) because manage_marketing_version.py imports them across the
62
+ # module boundary. Module-internal helpers below keep the leading
63
+ # underscore to mark them as private.
64
+ MIGRATION_HINT = (
65
+ "See .github/actions/ios-native-testflight/MIGRATION.md for the "
66
+ "migration procedure."
67
+ )
68
+ BUILD_SETTING_SOURCES = (
69
+ "project.yml [xcodegen], the .xcodeproj's MARKETING_VERSION xcconfig, "
70
+ "or Info.plist's CFBundleShortVersionString"
71
+ )
72
+
73
+
74
+ def semver_tuple(version_string: str) -> tuple[int, int, int]:
75
+ """Parse 'M.m[.p]' -> (M, m, p). Non-semver returns (-1,-1,-1) so it
76
+ sorts below any real version."""
77
+ m = SEM_RE.match(version_string or "")
78
+ if not m:
79
+ return (-1, -1, -1)
80
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3) or "0"))
81
+
82
+
83
+ def bump_patch_for_message(floor: str) -> str:
84
+ """Suggest a next-patch value to surface in error text. Best-effort."""
85
+ m = SEM_RE.match(floor or "")
86
+ if not m:
87
+ return f"{floor} (bump above this)"
88
+ major, minor = int(m.group(1)), int(m.group(2))
89
+ patch = int(m.group(3) or "0")
90
+ return f"{major}.{minor}.{patch + 1}"
91
+
92
+
93
+ def fetch_versions(app_id: str, token: str) -> list[dict]:
94
+ return asc_version_fetch.fetch_versions(app_id, token)
95
+
96
+
97
+ def get_ground_truth_floor(app_id: str, token: str) -> str | None:
98
+ return asc_version_fetch.get_ground_truth_floor(app_id, token, semver_tuple)
99
+
100
+
101
+ def get_combined_floor(
102
+ app_id: str, token: str, appstore_versions: list[dict],
103
+ ) -> tuple[str | None, dict[str, str | None]]:
104
+ """Wrapper around asc_version_fetch.get_combined_floor that forwards
105
+ both the floor AND the per-source breakdown so the cross-check error
106
+ message can name the real source contributing the binding floor (not
107
+ a synthesized one)."""
108
+ return asc_version_fetch.get_combined_floor(
109
+ app_id, token, semver_tuple, appstore_versions=appstore_versions,
110
+ )
111
+
112
+
113
+ def _resolve_floor(app_id: str, token: str, appstore_versions: list[dict]):
114
+ """Return (floor, per_source). floor is None when every ASC source is
115
+ empty (true first-release case). Late-binds the seam wrappers via the
116
+ parent ``manage_marketing_version`` module so test patches at
117
+ ``mmv.get_combined_floor`` / ``mmv.get_ground_truth_floor`` win."""
118
+ import manage_marketing_version as mmv
119
+ combined, per_source = mmv.get_combined_floor(
120
+ app_id, token, appstore_versions,
121
+ )
122
+ narrow = mmv.get_ground_truth_floor(app_id, token)
123
+ candidates = [c for c in (combined, narrow) if c]
124
+ floor = max(candidates, key=semver_tuple) if candidates else None
125
+ return floor, per_source
126
+
127
+
128
+ def _floor_error(target: str, floor: str, per_source: dict, *, strict: bool) -> None:
129
+ relation = "strictly greater than" if strict else "greater than or equal to"
130
+ print(
131
+ f"::error::MARKETING_VERSION {target} in your project's build "
132
+ f"settings (typically " + BUILD_SETTING_SOURCES + f") must be "
133
+ f"{relation} the App Store Connect floor {floor}. Bump it (e.g. "
134
+ f"to {bump_patch_for_message(floor)}), commit, and rerun. "
135
+ f"Sources contributing to floor: "
136
+ f"appStoreVersions={per_source.get('appStoreVersions') or '<none>'}, "
137
+ f"preReleaseVersions={per_source.get('preReleaseVersions') or '<none>'}, "
138
+ f"buildsViaPreRelease={per_source.get('buildsViaPreRelease') or '<none>'}. "
139
+ + MIGRATION_HINT,
140
+ file=sys.stderr,
141
+ )
142
+ raise SystemExit(2)
143
+
144
+
145
+ def _persist_context_ok(policy: str) -> bool:
146
+ """True when this run can persist a project-file bump back to the
147
+ default branch via the action's commit-back step. The commit-back
148
+ step is gated on ``event=push`` AND ``ref=refs/heads/<default>``;
149
+ any other context (workflow_dispatch / pull_request / push to a
150
+ feature branch) would upload an IPA at the bumped version while
151
+ git stays at the old value, drifting the project from ASC.
152
+
153
+ On refusal, emits the ``::warning::`` describing why this run
154
+ cannot persist the bump (event/ref/default_branch values from the
155
+ environment). The warning names the active policy (rollover /
156
+ patch / minor) so consumers reading CI logs can see which mode
157
+ was attempted. Side-effecting the warning here -- rather than at
158
+ the caller -- keeps ``maybe_auto_bump`` under the per-function LOC
159
+ cap without splitting the env-read + warning into two helpers
160
+ (which would also push the file over the per-file functions cap)."""
161
+ event = (os.environ.get(_EVENT_ENV) or "").strip()
162
+ ref = (os.environ.get(_REF_ENV) or "").strip()
163
+ default_branch = (os.environ.get(_DEFAULT_BRANCH_ENV) or "").strip()
164
+ ok = (
165
+ event == "push"
166
+ and bool(default_branch)
167
+ and ref == f"refs/heads/{default_branch}"
168
+ )
169
+ if ok:
170
+ return True
171
+ print(
172
+ f"::warning::auto-bump={policy} is enabled, but this run "
173
+ f"cannot persist the bump (event={event or '<unset>'}, "
174
+ f"ref={ref or '<unset>'}, "
175
+ f"default_branch={default_branch or '<unset>'}). The "
176
+ f"action's commit-back step only runs on push to "
177
+ f"refs/heads/{default_branch or '<unset>'}; auto-bumping on "
178
+ f"any other event would upload an IPA at the bumped version "
179
+ f"while git stays at the old value. Falling back to fail-on-"
180
+ f"floor; either push to the default branch with auto-bump "
181
+ f"enabled, or manually bump MARKETING_VERSION and rerun.",
182
+ file=sys.stderr,
183
+ )
184
+ return False
185
+
186
+
187
+ def maybe_auto_bump(target: str, floor: str) -> str | None:
188
+ """Return the bumped version when auto-bump is enabled, the run
189
+ can persist the bump, and writes succeed. Returns None when policy
190
+ is 'none', the persistence context disallows commit-back, or the
191
+ project-file write failed -- the caller falls back to the historical
192
+ ``_floor_error`` path.
193
+
194
+ Public seam so ``manage_marketing_version`` can fire the auto-roll
195
+ when target == a terminal-state floor row (READY_FOR_SALE auto-roll)
196
+ -- not just when target < floor (the historical floor-violation
197
+ case).
198
+
199
+ Side effects: writes the new version into the project file (pbxproj
200
+ / xcconfig / Info.plist / project.yml depending on resolution) and
201
+ updates ``os.environ['MARKETING_VERSION']`` so downstream steps see
202
+ the bumped value. ``version_utils.write_marketing_version`` ALSO
203
+ runs ``git add -f`` on the touched path so the auto-bump is staged
204
+ in the index before any later step (e.g. prepare_signing) mutates
205
+ the same file."""
206
+ policy = (os.environ.get(_AUTO_BUMP_ENV) or "rollover").strip().lower()
207
+ if policy == "none":
208
+ return None
209
+ if policy not in ("rollover", "patch", "minor"):
210
+ print(
211
+ f"::warning::Unknown {_AUTO_BUMP_ENV}={policy!r}; "
212
+ f"falling back to fail-on-floor behavior",
213
+ file=sys.stderr,
214
+ )
215
+ return None
216
+ if not _persist_context_ok(policy):
217
+ return None
218
+ new_version = version_utils.compute_next_version(target, floor, policy)
219
+ if not version_utils.write_marketing_version(new_version):
220
+ print(
221
+ f"::warning::auto-bump could not locate a writable "
222
+ f"MARKETING_VERSION source (pbxproj or Info.plist); "
223
+ f"falling back to fail-on-floor behavior",
224
+ file=sys.stderr,
225
+ )
226
+ return None
227
+ os.environ["MARKETING_VERSION"] = new_version
228
+ print(
229
+ f"::notice::mmv: auto-bumped {target} -> {new_version} "
230
+ f"(policy={policy}, ASC floor was {floor})",
231
+ file=sys.stderr,
232
+ )
233
+ return new_version
234
+
235
+
236
+ def assert_target_meets_floor(
237
+ target: str, app_id: str, token: str,
238
+ *, appstore_versions: list[dict], strict: bool,
239
+ ) -> str:
240
+ """Reject when the floor outranks `target`. ``strict=True`` requires
241
+ target > floor (CREATE: a new row must not collide with anything
242
+ shipped/uploaded). ``strict=False`` allows target == floor (REUSE:
243
+ the editable matching target IS a floor contributor, so equality is
244
+ expected and fine).
245
+
246
+ Returns the effective target (which may differ from the input when
247
+ auto-bump fires on the non-strict pre-check). Callers MUST use the
248
+ returned value for any subsequent floor-relative reasoning, otherwise
249
+ they'd compare a stale target against the post-bump state.
250
+ """
251
+ floor, per_source = _resolve_floor(app_id, token, appstore_versions)
252
+ if floor is None:
253
+ return target
254
+ target_t, floor_t = semver_tuple(target), semver_tuple(floor)
255
+ ok = target_t > floor_t if strict else target_t >= floor_t
256
+ if ok:
257
+ relation = ">" if strict else ">="
258
+ print(f"[decision] floor_check OK: target={target} {relation} "
259
+ f"floor={floor}", file=sys.stderr)
260
+ return target
261
+ bumped = maybe_auto_bump(target, floor)
262
+ if bumped is not None:
263
+ return bumped
264
+ _floor_error(target, floor, per_source, strict=strict)
265
+ raise AssertionError("unreachable") # _floor_error raises SystemExit
@@ -128,17 +128,30 @@ def _handle_localization_409(localization_id: str, resp) -> None:
128
128
  or transitioning) -- a race the version-level state check at the top
129
129
  of main() cannot see.
130
130
 
131
- Without an allow_status={409} entry in the request, asc_common.request()
132
- raises SystemExit on the 409, which the script's top-level try/except
133
- cannot swallow (SystemExit is explicitly re-raised). The whole CI run
134
- then fails at exit code 1 even though the IPA upload already succeeded.
135
-
136
131
  The benign-lock detail is logged plain -- it's the expected,
137
- non-actionable case and emitting `::warning::` annotations every clean
138
- run produces noise in the GH workflow summary. Any OTHER 409 detail
139
- (genuinely unexpected) is still surfaced as `::warning::`.
132
+ non-actionable case and emitting `::warning::` every clean run is
133
+ noise. Any OTHER 409 detail (genuinely unexpected) is surfaced as
134
+ `::warning::`.
135
+
136
+ ASC error envelope: ``{"errors": [{"status": "409", "code":
137
+ "STATE_ERROR", "detail": "Attribute 'whatsNew' cannot be edited at
138
+ this time"}]}``. We collapse every error.detail into one string so
139
+ the benign-lock substring match survives multi-error responses.
140
+ Falls back to raw response text if JSON parsing fails (network
141
+ layer occasionally returns HTML on infrastructure faults).
140
142
  """
141
- detail = _extract_409_detail(resp)
143
+ try:
144
+ body = resp.json()
145
+ except ValueError:
146
+ body = None
147
+ if isinstance(body, dict):
148
+ errors = body.get("errors") or []
149
+ parts = [e.get("detail") or e.get("title") or ""
150
+ for e in errors if isinstance(e, dict)]
151
+ detail = " | ".join(p for p in parts if p)
152
+ else:
153
+ detail = resp.text or ""
154
+
142
155
  if _WHATSNEW_LOCKED_DETAIL in detail:
143
156
  _log(
144
157
  f"[whatsNew] localization {localization_id} is currently "
@@ -153,33 +166,6 @@ def _handle_localization_409(localization_id: str, resp) -> None:
153
166
  )
154
167
 
155
168
 
156
- def _extract_409_detail(resp) -> str:
157
- """Pull the human-readable detail string out of an ASC 409 response.
158
-
159
- ASC error envelope: {"errors": [{"status": "409", "code": "STATE_ERROR",
160
- "title": "...", "detail": "Attribute 'whatsNew' cannot be edited at this
161
- time"}]}. We collapse all error details into a single string so the
162
- benign-lock substring match survives Apple returning multiple errors
163
- in one response. Falls back to raw response text if JSON parsing fails
164
- (defensive -- Apple's error envelopes have been stable for years but
165
- the network layer occasionally returns HTML on infrastructure faults).
166
- """
167
- try:
168
- body = resp.json()
169
- except ValueError:
170
- return resp.text or ""
171
- errors = body.get("errors") if isinstance(body, dict) else None
172
- if not errors:
173
- return ""
174
- parts = []
175
- for err in errors:
176
- if isinstance(err, dict):
177
- detail = err.get("detail") or err.get("title") or ""
178
- if detail:
179
- parts.append(detail)
180
- return " | ".join(parts)
181
-
182
-
183
169
  def _create_localization(
184
170
  token: str, version_id: str, locale: str, whats_new: str
185
171
  ) -> str:
@@ -232,7 +218,6 @@ def _update_all_localizations(
232
218
  f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
233
219
  )
234
220
  entries = locs.get("data") or []
235
-
236
221
  if not entries:
237
222
  new_id = _create_localization(token, version_id, seed_locale, whats_new)
238
223
  _log(
@@ -240,7 +225,6 @@ def _update_all_localizations(
240
225
  f"{version} ({seed_locale}) -- no prior localizations"
241
226
  )
242
227
  return 1
243
-
244
228
  count = 0
245
229
  skipped = 0
246
230
  for item in entries:
@@ -258,12 +242,6 @@ def _update_all_localizations(
258
242
  else:
259
243
  skipped += 1
260
244
  if skipped:
261
- # Per-localization detail (lock state, unexpected 409, etc.) was
262
- # already emitted above by _patch_localization -- this is just the
263
- # rollup count so the workflow log shows a single summary line.
264
- # We deliberately avoid "see warnings above": for the benign-lock
265
- # case _patch_localization emits plain _log entries (no ::warning::),
266
- # so a "warnings above" pointer would mislead the reader.
267
245
  _log(f"whatsNew skipped for {skipped} locked localization(s)")
268
246
  return count
269
247