@daemux/store-automator 0.10.94 → 0.10.96
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-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/github/IOS_NATIVE_CI_SETUP.md +58 -3
- package/templates/scripts/ci/ios-native/asc_build_history.py +284 -0
- package/templates/scripts/ci/ios-native/asc_common.py +137 -34
- package/templates/scripts/ci/ios-native/asc_version_create.py +365 -0
- package/templates/scripts/ci/ios-native/asc_version_fetch.py +274 -0
- package/templates/scripts/ci/ios-native/asc_version_reuse.py +177 -0
- package/templates/scripts/ci/ios-native/autoupdate_check.sh +43 -2
- package/templates/scripts/ci/ios-native/commit_bot_changes.sh +29 -7
- package/templates/scripts/ci/ios-native/manage_marketing_version.py +269 -203
- package/templates/scripts/ci/ios-native/mmv_decide_create.py +181 -0
- package/templates/scripts/ci/ios-native/mmv_floor_check.py +260 -0
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +64 -29
- package/templates/scripts/ci/ios-native/version_utils.py +352 -0
|
@@ -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,260 @@
|
|
|
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 'patch' matches the action
|
|
34
|
+
# input default. ``'none'`` preserves the historical fail-the-build path.
|
|
35
|
+
_AUTO_BUMP_ENV = "MARKETING_VERSION_AUTO_BUMP"
|
|
36
|
+
|
|
37
|
+
# DESIGN DECISION: auto-bump must only fire when the resulting bumped
|
|
38
|
+
# value can be committed back to the repo in the same run. The
|
|
39
|
+
# commit-back step in action.yml is gated on `event=push` AND
|
|
40
|
+
# `ref=refs/heads/<default_branch>`. Auto-bumping on any other event
|
|
41
|
+
# (workflow_dispatch, pull_request, push to feature branches) would
|
|
42
|
+
# upload an IPA at the bumped version while git stays at the old
|
|
43
|
+
# value, causing the next run to recompute against stale state and
|
|
44
|
+
# either re-bump or drift.
|
|
45
|
+
#
|
|
46
|
+
# These envs are set by the action.yml step from
|
|
47
|
+
# ``${{ github.event_name }}`` and ``${{ github.ref }}`` /
|
|
48
|
+
# ``${{ github.event.repository.default_branch }}``. Empty / unset
|
|
49
|
+
# values are treated as "context unknown -> refuse to auto-bump"
|
|
50
|
+
# (defensive: when run outside the GitHub Actions context, e.g. local
|
|
51
|
+
# dev or a third-party orchestrator, the safe default is to require a
|
|
52
|
+
# human bump).
|
|
53
|
+
_EVENT_ENV = "GITHUB_EVENT_NAME"
|
|
54
|
+
_REF_ENV = "GITHUB_REF"
|
|
55
|
+
_DEFAULT_BRANCH_ENV = "GITHUB_DEFAULT_BRANCH"
|
|
56
|
+
|
|
57
|
+
# These constants and helpers are intentionally public (no leading
|
|
58
|
+
# underscore) because manage_marketing_version.py imports them across the
|
|
59
|
+
# module boundary. Module-internal helpers below keep the leading
|
|
60
|
+
# underscore to mark them as private.
|
|
61
|
+
MIGRATION_HINT = (
|
|
62
|
+
"See .github/actions/ios-native-testflight/MIGRATION.md for the "
|
|
63
|
+
"migration procedure."
|
|
64
|
+
)
|
|
65
|
+
BUILD_SETTING_SOURCES = (
|
|
66
|
+
"project.yml [xcodegen], the .xcodeproj's MARKETING_VERSION xcconfig, "
|
|
67
|
+
"or Info.plist's CFBundleShortVersionString"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def semver_tuple(version_string: str) -> tuple[int, int, int]:
|
|
72
|
+
"""Parse 'M.m[.p]' -> (M, m, p). Non-semver returns (-1,-1,-1) so it
|
|
73
|
+
sorts below any real version."""
|
|
74
|
+
m = SEM_RE.match(version_string or "")
|
|
75
|
+
if not m:
|
|
76
|
+
return (-1, -1, -1)
|
|
77
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or "0"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def bump_patch_for_message(floor: str) -> str:
|
|
81
|
+
"""Suggest a next-patch value to surface in error text. Best-effort."""
|
|
82
|
+
m = SEM_RE.match(floor or "")
|
|
83
|
+
if not m:
|
|
84
|
+
return f"{floor} (bump above this)"
|
|
85
|
+
major, minor = int(m.group(1)), int(m.group(2))
|
|
86
|
+
patch = int(m.group(3) or "0")
|
|
87
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def fetch_versions(app_id: str, token: str) -> list[dict]:
|
|
91
|
+
return asc_version_fetch.fetch_versions(app_id, token)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_ground_truth_floor(app_id: str, token: str) -> str | None:
|
|
95
|
+
return asc_version_fetch.get_ground_truth_floor(app_id, token, semver_tuple)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_combined_floor(
|
|
99
|
+
app_id: str, token: str, appstore_versions: list[dict],
|
|
100
|
+
) -> tuple[str | None, dict[str, str | None]]:
|
|
101
|
+
"""Wrapper around asc_version_fetch.get_combined_floor that forwards
|
|
102
|
+
both the floor AND the per-source breakdown so the cross-check error
|
|
103
|
+
message can name the real source contributing the binding floor (not
|
|
104
|
+
a synthesized one)."""
|
|
105
|
+
return asc_version_fetch.get_combined_floor(
|
|
106
|
+
app_id, token, semver_tuple, appstore_versions=appstore_versions,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resolve_floor(app_id: str, token: str, appstore_versions: list[dict]):
|
|
111
|
+
"""Return (floor, per_source). floor is None when every ASC source is
|
|
112
|
+
empty (true first-release case). Late-binds the seam wrappers via the
|
|
113
|
+
parent ``manage_marketing_version`` module so test patches at
|
|
114
|
+
``mmv.get_combined_floor`` / ``mmv.get_ground_truth_floor`` win."""
|
|
115
|
+
import manage_marketing_version as mmv
|
|
116
|
+
combined, per_source = mmv.get_combined_floor(
|
|
117
|
+
app_id, token, appstore_versions,
|
|
118
|
+
)
|
|
119
|
+
narrow = mmv.get_ground_truth_floor(app_id, token)
|
|
120
|
+
candidates = [c for c in (combined, narrow) if c]
|
|
121
|
+
floor = max(candidates, key=semver_tuple) if candidates else None
|
|
122
|
+
return floor, per_source
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _floor_error(target: str, floor: str, per_source: dict, *, strict: bool) -> None:
|
|
126
|
+
relation = "strictly greater than" if strict else "greater than or equal to"
|
|
127
|
+
print(
|
|
128
|
+
f"::error::MARKETING_VERSION {target} in your project's build "
|
|
129
|
+
f"settings (typically " + BUILD_SETTING_SOURCES + f") must be "
|
|
130
|
+
f"{relation} the App Store Connect floor {floor}. Bump it (e.g. "
|
|
131
|
+
f"to {bump_patch_for_message(floor)}), commit, and rerun. "
|
|
132
|
+
f"Sources contributing to floor: "
|
|
133
|
+
f"appStoreVersions={per_source.get('appStoreVersions') or '<none>'}, "
|
|
134
|
+
f"preReleaseVersions={per_source.get('preReleaseVersions') or '<none>'}, "
|
|
135
|
+
f"buildsViaPreRelease={per_source.get('buildsViaPreRelease') or '<none>'}. "
|
|
136
|
+
+ MIGRATION_HINT,
|
|
137
|
+
file=sys.stderr,
|
|
138
|
+
)
|
|
139
|
+
raise SystemExit(2)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _persist_context_ok() -> bool:
|
|
143
|
+
"""True when this run can persist a project-file bump back to the
|
|
144
|
+
default branch via the action's commit-back step. The commit-back
|
|
145
|
+
step is gated on ``event=push`` AND ``ref=refs/heads/<default>``;
|
|
146
|
+
any other context (workflow_dispatch / pull_request / push to a
|
|
147
|
+
feature branch) would upload an IPA at the bumped version while
|
|
148
|
+
git stays at the old value, drifting the project from ASC.
|
|
149
|
+
|
|
150
|
+
On refusal, emits the ``::warning::`` describing why this run
|
|
151
|
+
cannot persist the bump (event/ref/default_branch values from the
|
|
152
|
+
environment). Side-effecting the warning here -- rather than at
|
|
153
|
+
the caller -- keeps ``maybe_auto_bump`` under the per-function LOC
|
|
154
|
+
cap without splitting the env-read + warning into two helpers
|
|
155
|
+
(which would also push the file over the per-file functions cap)."""
|
|
156
|
+
event = (os.environ.get(_EVENT_ENV) or "").strip()
|
|
157
|
+
ref = (os.environ.get(_REF_ENV) or "").strip()
|
|
158
|
+
default_branch = (os.environ.get(_DEFAULT_BRANCH_ENV) or "").strip()
|
|
159
|
+
ok = (
|
|
160
|
+
event == "push"
|
|
161
|
+
and bool(default_branch)
|
|
162
|
+
and ref == f"refs/heads/{default_branch}"
|
|
163
|
+
)
|
|
164
|
+
if ok:
|
|
165
|
+
return True
|
|
166
|
+
print(
|
|
167
|
+
f"::warning::auto-bump=patch is enabled, but this run "
|
|
168
|
+
f"cannot persist the bump (event={event or '<unset>'}, "
|
|
169
|
+
f"ref={ref or '<unset>'}, "
|
|
170
|
+
f"default_branch={default_branch or '<unset>'}). The "
|
|
171
|
+
f"action's commit-back step only runs on push to "
|
|
172
|
+
f"refs/heads/{default_branch or '<unset>'}; auto-bumping on "
|
|
173
|
+
f"any other event would upload an IPA at the bumped version "
|
|
174
|
+
f"while git stays at the old value. Falling back to fail-on-"
|
|
175
|
+
f"floor; either push to the default branch with auto-bump "
|
|
176
|
+
f"enabled, or manually bump MARKETING_VERSION and rerun.",
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def maybe_auto_bump(target: str, floor: str) -> str | None:
|
|
183
|
+
"""Return the bumped version when auto-bump is enabled, the run
|
|
184
|
+
can persist the bump, and writes succeed. Returns None when policy
|
|
185
|
+
is 'none', the persistence context disallows commit-back, or the
|
|
186
|
+
project-file write failed -- the caller falls back to the historical
|
|
187
|
+
``_floor_error`` path.
|
|
188
|
+
|
|
189
|
+
Public seam so ``manage_marketing_version`` can fire the auto-roll
|
|
190
|
+
when target == a terminal-state floor row (READY_FOR_SALE auto-roll)
|
|
191
|
+
-- not just when target < floor (the historical floor-violation
|
|
192
|
+
case).
|
|
193
|
+
|
|
194
|
+
Side effects: writes the new version into the project file (pbxproj
|
|
195
|
+
/ xcconfig / Info.plist / project.yml depending on resolution) and
|
|
196
|
+
updates ``os.environ['MARKETING_VERSION']`` so downstream steps see
|
|
197
|
+
the bumped value. ``version_utils.write_marketing_version`` ALSO
|
|
198
|
+
runs ``git add -f`` on the touched path so the auto-bump is staged
|
|
199
|
+
in the index before any later step (e.g. prepare_signing) mutates
|
|
200
|
+
the same file."""
|
|
201
|
+
policy = (os.environ.get(_AUTO_BUMP_ENV) or "patch").strip().lower()
|
|
202
|
+
if policy == "none":
|
|
203
|
+
return None
|
|
204
|
+
if policy not in ("patch", "minor"):
|
|
205
|
+
print(
|
|
206
|
+
f"::warning::Unknown {_AUTO_BUMP_ENV}={policy!r}; "
|
|
207
|
+
f"falling back to fail-on-floor behavior",
|
|
208
|
+
file=sys.stderr,
|
|
209
|
+
)
|
|
210
|
+
return None
|
|
211
|
+
if not _persist_context_ok():
|
|
212
|
+
return None
|
|
213
|
+
new_version = version_utils.compute_next_version(target, floor, policy)
|
|
214
|
+
if not version_utils.write_marketing_version(new_version):
|
|
215
|
+
print(
|
|
216
|
+
f"::warning::auto-bump could not locate a writable "
|
|
217
|
+
f"MARKETING_VERSION source (pbxproj or Info.plist); "
|
|
218
|
+
f"falling back to fail-on-floor behavior",
|
|
219
|
+
file=sys.stderr,
|
|
220
|
+
)
|
|
221
|
+
return None
|
|
222
|
+
os.environ["MARKETING_VERSION"] = new_version
|
|
223
|
+
print(
|
|
224
|
+
f"::notice::mmv: auto-bumped {target} -> {new_version} "
|
|
225
|
+
f"(policy={policy}, ASC floor was {floor})",
|
|
226
|
+
file=sys.stderr,
|
|
227
|
+
)
|
|
228
|
+
return new_version
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def assert_target_meets_floor(
|
|
232
|
+
target: str, app_id: str, token: str,
|
|
233
|
+
*, appstore_versions: list[dict], strict: bool,
|
|
234
|
+
) -> str:
|
|
235
|
+
"""Reject when the floor outranks `target`. ``strict=True`` requires
|
|
236
|
+
target > floor (CREATE: a new row must not collide with anything
|
|
237
|
+
shipped/uploaded). ``strict=False`` allows target == floor (REUSE:
|
|
238
|
+
the editable matching target IS a floor contributor, so equality is
|
|
239
|
+
expected and fine).
|
|
240
|
+
|
|
241
|
+
Returns the effective target (which may differ from the input when
|
|
242
|
+
auto-bump fires on the non-strict pre-check). Callers MUST use the
|
|
243
|
+
returned value for any subsequent floor-relative reasoning, otherwise
|
|
244
|
+
they'd compare a stale target against the post-bump state.
|
|
245
|
+
"""
|
|
246
|
+
floor, per_source = _resolve_floor(app_id, token, appstore_versions)
|
|
247
|
+
if floor is None:
|
|
248
|
+
return target
|
|
249
|
+
target_t, floor_t = semver_tuple(target), semver_tuple(floor)
|
|
250
|
+
ok = target_t > floor_t if strict else target_t >= floor_t
|
|
251
|
+
if ok:
|
|
252
|
+
relation = ">" if strict else ">="
|
|
253
|
+
print(f"[decision] floor_check OK: target={target} {relation} "
|
|
254
|
+
f"floor={floor}", file=sys.stderr)
|
|
255
|
+
return target
|
|
256
|
+
bumped = maybe_auto_bump(target, floor)
|
|
257
|
+
if bumped is not None:
|
|
258
|
+
return bumped
|
|
259
|
+
_floor_error(target, floor, per_source, strict=strict)
|
|
260
|
+
raise AssertionError("unreachable") # _floor_error raises SystemExit
|
|
@@ -78,29 +78,24 @@ def _log(msg: str) -> None:
|
|
|
78
78
|
print(msg, file=sys.stderr)
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
# Apple's 409 STATE_ERROR detail string for the known-benign case where
|
|
82
|
+
# the per-localization slot is locked because the parent version is
|
|
83
|
+
# transitioning between states. Substring match (not regex / equality) so
|
|
84
|
+
# we don't break if Apple appends or reformats surrounding context.
|
|
85
|
+
_WHATSNEW_LOCKED_DETAIL = "cannot be edited at this time"
|
|
86
|
+
|
|
87
|
+
|
|
81
88
|
def _patch_localization(
|
|
82
89
|
token: str, localization_id: str, whats_new: str
|
|
83
90
|
) -> bool:
|
|
84
91
|
"""PATCH a single localization's whatsNew. Returns True on success,
|
|
85
92
|
False when Apple reports the localization is locked (409 STATE_ERROR).
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
or transitioning) -- a race the version-level state check at the top
|
|
93
|
-
of main() cannot see.
|
|
94
|
-
|
|
95
|
-
Without this allow-list, asc_common.request() raises SystemExit on
|
|
96
|
-
the 409, which the script's top-level try/except cannot swallow
|
|
97
|
-
(SystemExit is explicitly re-raised). The whole CI run then fails
|
|
98
|
-
at exit code 1 even though the IPA upload already succeeded.
|
|
99
|
-
|
|
100
|
-
Other non-2xx statuses (auth, 5xx, malformed payloads, real ASC
|
|
101
|
-
outages) are NOT in the allow-list and continue to fail loud --
|
|
102
|
-
asc_common.request() retries 5xx automatically and SystemExits on
|
|
103
|
-
everything else.
|
|
94
|
+
See _handle_localization_409 for the rationale behind allow_status={409}
|
|
95
|
+
and the benign-lock vs unexpected-409 routing. Other non-2xx statuses
|
|
96
|
+
(auth, 5xx, malformed payloads, real ASC outages) are NOT in the
|
|
97
|
+
allow-list and continue to fail loud -- asc_common.request() retries
|
|
98
|
+
5xx automatically and SystemExits on everything else.
|
|
104
99
|
"""
|
|
105
100
|
resp = request(
|
|
106
101
|
"PATCH",
|
|
@@ -116,16 +111,61 @@ def _patch_localization(
|
|
|
116
111
|
allow_status={409},
|
|
117
112
|
)
|
|
118
113
|
if resp.status_code == 409:
|
|
119
|
-
|
|
120
|
-
f"appStoreVersionLocalization {localization_id} returned 409 "
|
|
121
|
-
f"STATE_ERROR (locked); whatsNew not patched for this "
|
|
122
|
-
f"localization. Other localizations and the rest of the run "
|
|
123
|
-
f"continue."
|
|
124
|
-
)
|
|
114
|
+
_handle_localization_409(localization_id, resp)
|
|
125
115
|
return False
|
|
126
116
|
return True
|
|
127
117
|
|
|
128
118
|
|
|
119
|
+
def _handle_localization_409(localization_id: str, resp) -> None:
|
|
120
|
+
"""Route an ASC 409 STATE_ERROR on a localization PATCH to the right
|
|
121
|
+
log channel based on the response detail.
|
|
122
|
+
|
|
123
|
+
Apple returns 409 STATE_ERROR ("Attribute 'whatsNew' cannot be edited
|
|
124
|
+
at this time") on individual localizations even when the parent
|
|
125
|
+
appStoreVersion's appStoreState is in the editable allow-list checked
|
|
126
|
+
upstream. This happens when the per-localization state is locked
|
|
127
|
+
independently (e.g. submitted, in-review at the localization level,
|
|
128
|
+
or transitioning) -- a race the version-level state check at the top
|
|
129
|
+
of main() cannot see.
|
|
130
|
+
|
|
131
|
+
The benign-lock detail is logged plain -- it's the expected,
|
|
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).
|
|
142
|
+
"""
|
|
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
|
+
|
|
155
|
+
if _WHATSNEW_LOCKED_DETAIL in detail:
|
|
156
|
+
_log(
|
|
157
|
+
f"[whatsNew] localization {localization_id} is currently "
|
|
158
|
+
f"locked, skipping (detail: {detail!r})"
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
_warn(
|
|
162
|
+
f"appStoreVersionLocalization {localization_id} returned 409 "
|
|
163
|
+
f"with unexpected detail {detail!r}; whatsNew not patched for "
|
|
164
|
+
f"this localization. Other localizations and the rest of the "
|
|
165
|
+
f"run continue."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
129
169
|
def _create_localization(
|
|
130
170
|
token: str, version_id: str, locale: str, whats_new: str
|
|
131
171
|
) -> str:
|
|
@@ -178,7 +218,6 @@ def _update_all_localizations(
|
|
|
178
218
|
f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
|
|
179
219
|
)
|
|
180
220
|
entries = locs.get("data") or []
|
|
181
|
-
|
|
182
221
|
if not entries:
|
|
183
222
|
new_id = _create_localization(token, version_id, seed_locale, whats_new)
|
|
184
223
|
_log(
|
|
@@ -186,7 +225,6 @@ def _update_all_localizations(
|
|
|
186
225
|
f"{version} ({seed_locale}) -- no prior localizations"
|
|
187
226
|
)
|
|
188
227
|
return 1
|
|
189
|
-
|
|
190
228
|
count = 0
|
|
191
229
|
skipped = 0
|
|
192
230
|
for item in entries:
|
|
@@ -204,10 +242,7 @@ def _update_all_localizations(
|
|
|
204
242
|
else:
|
|
205
243
|
skipped += 1
|
|
206
244
|
if skipped:
|
|
207
|
-
_log(
|
|
208
|
-
f"whatsNew skipped for {skipped} locked localization(s) "
|
|
209
|
-
f"(see warnings above)"
|
|
210
|
-
)
|
|
245
|
+
_log(f"whatsNew skipped for {skipped} locked localization(s)")
|
|
211
246
|
return count
|
|
212
247
|
|
|
213
248
|
|