@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.
- 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 +85 -4
- 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 +30 -35
- 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 +265 -0
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +22 -44
- package/templates/scripts/ci/ios-native/version_utils.py +373 -0
|
@@ -2,257 +2,322 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Resolve the App Store Connect version slot for this run.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
terminal
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
6. No versions at all -> first release, CREATE "1.0.0".
|
|
31
|
-
7. 409 on CREATE (race) -> re-fetch, match by versionString, REUSE.
|
|
32
|
-
|
|
33
|
-
Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID.
|
|
5
|
+
The project's MARKETING_VERSION build setting is the single source of truth
|
|
6
|
+
for the marketing version. CI does not bump it. This script finds-or-creates
|
|
7
|
+
the App Store Connect (ASC) row matching that exact version, and only the
|
|
8
|
+
build number is bumped automatically (see next_build_number.py).
|
|
9
|
+
|
|
10
|
+
Algorithm:
|
|
11
|
+
1. Read MARKETING_VERSION from env (set by action.yml from
|
|
12
|
+
xcodebuild -showBuildSettings or PlistBuddy).
|
|
13
|
+
2. Floor-check (non-strict): target >= combined floor across
|
|
14
|
+
appStoreVersions + preReleaseVersions + builds->preReleaseVersion.
|
|
15
|
+
3. If the target row already exists in /appStoreVersions:
|
|
16
|
+
- any non-editable match (terminal/blocking) -> SystemExit(2);
|
|
17
|
+
the slot is taken by a shipped or in-review row.
|
|
18
|
+
- editable-only match -> REUSE its id (steady-state TestFlight
|
|
19
|
+
iteration at unchanged MARKETING_VERSION).
|
|
20
|
+
4. CREATE path: floor-check (strict, target > floor); PATCH-rename any
|
|
21
|
+
stale editable to target; or POST a new row.
|
|
22
|
+
|
|
23
|
+
This module is intentionally thin: floor logic lives in
|
|
24
|
+
``mmv_floor_check``; CREATE wiring (POST/PATCH-rename, stale-editable
|
|
25
|
+
detection) lives in ``mmv_decide_create``. Both submodules re-bind back
|
|
26
|
+
through this one so test patches at ``mmv.<seam>`` keep winning.
|
|
27
|
+
|
|
28
|
+
Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID,
|
|
29
|
+
MARKETING_VERSION (project source of truth, set by action.yml).
|
|
34
30
|
Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
|
|
35
|
-
Stderr: [decision] log lines + ::error:: on fatal conditions.
|
|
36
31
|
"""
|
|
37
32
|
|
|
38
33
|
from __future__ import annotations
|
|
39
34
|
|
|
40
35
|
import json
|
|
41
36
|
import os
|
|
42
|
-
import re
|
|
43
37
|
import sys
|
|
44
38
|
|
|
45
39
|
from asc_common import (
|
|
46
40
|
BLOCKING_STATES,
|
|
47
|
-
|
|
41
|
+
IN_REVIEW_STATES,
|
|
42
|
+
REUSABLE_STATES,
|
|
48
43
|
TERMINAL_STATES,
|
|
49
|
-
get_json,
|
|
50
44
|
make_jwt,
|
|
51
|
-
request,
|
|
52
45
|
)
|
|
53
46
|
|
|
47
|
+
from mmv_floor_check import (
|
|
48
|
+
BUILD_SETTING_SOURCES,
|
|
49
|
+
MIGRATION_HINT,
|
|
50
|
+
SEM_RE,
|
|
51
|
+
assert_target_meets_floor,
|
|
52
|
+
bump_patch_for_message,
|
|
53
|
+
fetch_versions,
|
|
54
|
+
get_combined_floor,
|
|
55
|
+
get_ground_truth_floor,
|
|
56
|
+
maybe_auto_bump,
|
|
57
|
+
semver_tuple,
|
|
58
|
+
)
|
|
59
|
+
from mmv_decide_create import (
|
|
60
|
+
create_or_reuse,
|
|
61
|
+
create_or_reuse_with_stale,
|
|
62
|
+
create_version,
|
|
63
|
+
decide_create,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Re-export the public seams so tests that patch ``mmv.<name>`` keep
|
|
67
|
+
# working. The submodules look these up through this module at call
|
|
68
|
+
# time, so a patch on ``mmv.fetch_versions`` propagates correctly.
|
|
69
|
+
__all__ = (
|
|
70
|
+
"SEM_RE",
|
|
71
|
+
"create_or_reuse",
|
|
72
|
+
"create_version",
|
|
73
|
+
"decide_for_version",
|
|
74
|
+
"env",
|
|
75
|
+
"fetch_versions",
|
|
76
|
+
"get_combined_floor",
|
|
77
|
+
"get_ground_truth_floor",
|
|
78
|
+
"main",
|
|
79
|
+
"make_jwt",
|
|
80
|
+
"semver_tuple",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_APP_ID_MISSING_HINT = (
|
|
85
|
+
"::error::APP_STORE_APPLE_ID is empty. This usually means auto-detect "
|
|
86
|
+
"could not resolve PRODUCT_BUNDLE_IDENTIFIER from your Xcode project "
|
|
87
|
+
"(check the auto-detect: ... log lines in the 'Resolve credentials + "
|
|
88
|
+
"auto-detect' step above). Fix options: (1) commit your .xcodeproj or "
|
|
89
|
+
"add an xcodegen project.yml; (2) pass `app-store-apple-id:` explicitly "
|
|
90
|
+
"via the action's `with:` block."
|
|
91
|
+
)
|
|
54
92
|
|
|
55
|
-
|
|
93
|
+
_MARKETING_VERSION_MISSING_HINT = (
|
|
94
|
+
"::error::MARKETING_VERSION env var is missing or invalid. The "
|
|
95
|
+
"action.yml step that reads it from xcodebuild -showBuildSettings "
|
|
96
|
+
"(or PlistBuddy fallback) must set it before this script runs. "
|
|
97
|
+
+ MIGRATION_HINT
|
|
98
|
+
)
|
|
56
99
|
|
|
57
100
|
|
|
58
101
|
def _log(msg: str) -> None:
|
|
59
102
|
print(f"[decision] {msg}", file=sys.stderr)
|
|
60
103
|
|
|
61
104
|
|
|
62
|
-
def _result(decision: str, version: str, vid: str) -> dict:
|
|
63
|
-
|
|
105
|
+
def _result(decision: str, version: str, vid: str, state: str = "") -> dict:
|
|
106
|
+
out = {
|
|
64
107
|
"decision": decision,
|
|
65
108
|
"versionString": version,
|
|
66
109
|
"appStoreVersionId": vid,
|
|
67
110
|
}
|
|
111
|
+
if state:
|
|
112
|
+
out["state"] = state
|
|
113
|
+
return out
|
|
68
114
|
|
|
69
115
|
|
|
70
116
|
def env(name: str) -> str:
|
|
71
117
|
val = os.environ.get(name)
|
|
72
118
|
if not val:
|
|
73
119
|
if name == "APP_STORE_APPLE_ID":
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"your Xcode project (check the auto-detect: ... log lines in the "
|
|
78
|
-
"'Resolve credentials + auto-detect' step above). Fix options: "
|
|
79
|
-
"(1) commit your .xcodeproj or add an xcodegen project.yml; "
|
|
80
|
-
"(2) pass `app-store-apple-id:` explicitly via the action's `with:` block.",
|
|
81
|
-
file=sys.stderr,
|
|
82
|
-
)
|
|
120
|
+
msg = _APP_ID_MISSING_HINT
|
|
121
|
+
elif name == "MARKETING_VERSION":
|
|
122
|
+
msg = _MARKETING_VERSION_MISSING_HINT
|
|
83
123
|
else:
|
|
84
|
-
|
|
124
|
+
msg = f"::error::Missing required env var: {name}"
|
|
125
|
+
print(msg, file=sys.stderr)
|
|
85
126
|
raise SystemExit(1)
|
|
86
127
|
return val
|
|
87
128
|
|
|
88
129
|
|
|
89
|
-
def
|
|
90
|
-
"""
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if not m:
|
|
94
|
-
return (-1, -1, -1)
|
|
95
|
-
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or "0"))
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def calculate_next_version(current_version: str) -> str:
|
|
99
|
-
"""Rollover bump: patch+1; patch>=10 resets to 0 and minor+1;
|
|
100
|
-
minor>=10 resets both to 0 and major+1. Accepts 'M.m' or 'M.m.p'."""
|
|
101
|
-
m = SEM_RE.match(current_version or "")
|
|
102
|
-
if not m:
|
|
103
|
-
raise SystemExit(
|
|
104
|
-
f"::error::App Store versionString {current_version!r} is not a "
|
|
105
|
-
f"valid semantic version (expected MAJOR.MINOR[.PATCH])."
|
|
106
|
-
)
|
|
107
|
-
major = int(m.group(1))
|
|
108
|
-
minor = int(m.group(2))
|
|
109
|
-
patch = int(m.group(3) or "0")
|
|
110
|
-
|
|
111
|
-
new_patch = patch + 1
|
|
112
|
-
new_minor = minor
|
|
113
|
-
new_major = major
|
|
114
|
-
|
|
115
|
-
if new_patch >= 10:
|
|
116
|
-
new_patch = 0
|
|
117
|
-
new_minor = minor + 1
|
|
118
|
-
if new_minor >= 10:
|
|
119
|
-
new_minor = 0
|
|
120
|
-
new_patch = 0
|
|
121
|
-
new_major = major + 1
|
|
122
|
-
|
|
123
|
-
return f"{new_major}.{new_minor}.{new_patch}"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def fetch_versions(app_id: str, token: str) -> list[dict]:
|
|
127
|
-
"""All appStoreVersions for the app (no sort/limit/filter)."""
|
|
128
|
-
data = get_json(f"/apps/{app_id}/appStoreVersions", token)
|
|
129
|
-
out: list[dict] = []
|
|
130
|
-
for item in data.get("data", []):
|
|
131
|
-
attrs = item.get("attributes") or {}
|
|
132
|
-
out.append({
|
|
133
|
-
"versionString": attrs.get("versionString") or "",
|
|
134
|
-
"state": attrs.get("appStoreState") or "",
|
|
135
|
-
"id": item.get("id") or "",
|
|
136
|
-
"createdDate": attrs.get("createdDate") or "",
|
|
137
|
-
})
|
|
138
|
-
return out
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def create_version(app_id: str, version: str, token: str):
|
|
142
|
-
body = {
|
|
143
|
-
"data": {
|
|
144
|
-
"type": "appStoreVersions",
|
|
145
|
-
"attributes": {
|
|
146
|
-
"platform": "IOS",
|
|
147
|
-
"versionString": version,
|
|
148
|
-
"releaseType": "AFTER_APPROVAL",
|
|
149
|
-
},
|
|
150
|
-
"relationships": {
|
|
151
|
-
"app": {"data": {"type": "apps", "id": app_id}},
|
|
152
|
-
},
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return request(
|
|
156
|
-
"POST", "/appStoreVersions", token,
|
|
157
|
-
json_body=body, allow_status={409},
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def create_or_reuse(app_id: str, version: str, token: str) -> str:
|
|
162
|
-
"""POST a new version; on 409 race, re-fetch and return existing id."""
|
|
163
|
-
resp = create_version(app_id, version, token)
|
|
164
|
-
if resp.status_code != 409:
|
|
165
|
-
return resp.json()["data"]["id"]
|
|
166
|
-
|
|
130
|
+
def _validate_target(target_version: str) -> None:
|
|
131
|
+
"""SystemExit(1) when target is not a parseable semver."""
|
|
132
|
+
if SEM_RE.match(target_version or ""):
|
|
133
|
+
return
|
|
167
134
|
print(
|
|
168
|
-
f"
|
|
169
|
-
"
|
|
135
|
+
f"::error::MARKETING_VERSION {target_version!r} is not a "
|
|
136
|
+
f"valid semantic version (expected MAJOR.MINOR[.PATCH]). "
|
|
137
|
+
f"Set MARKETING_VERSION in " + BUILD_SETTING_SOURCES + ". "
|
|
138
|
+
+ MIGRATION_HINT,
|
|
170
139
|
file=sys.stderr,
|
|
171
140
|
)
|
|
172
|
-
|
|
173
|
-
if v["versionString"] == version:
|
|
174
|
-
return v["id"]
|
|
175
|
-
raise SystemExit(
|
|
176
|
-
f"::error::POST /appStoreVersions returned 409 for {version} but "
|
|
177
|
-
f"no matching versionString was found on re-fetch."
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def _summarize(versions: list[dict]) -> str:
|
|
182
|
-
return ", ".join(
|
|
183
|
-
f"{v['versionString']}({v['state']})" for v in versions
|
|
184
|
-
) or "<none>"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def _pick_highest_semver(versions: list[dict]) -> dict:
|
|
188
|
-
"""Tiebreak by createdDate (newer wins) when semvers collide."""
|
|
189
|
-
return max(
|
|
190
|
-
versions,
|
|
191
|
-
key=lambda v: (semver_tuple(v["versionString"]), v.get("createdDate", "")),
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def decide(versions: list[dict], app_id: str, token: str) -> dict:
|
|
196
|
-
if not versions:
|
|
197
|
-
new_version = "1.0.0"
|
|
198
|
-
new_id = create_or_reuse(app_id, new_version, token)
|
|
199
|
-
_log(f"CREATE {new_version} (first release) -> appStoreVersion id={new_id}")
|
|
200
|
-
return _result("CREATE", new_version, new_id)
|
|
201
|
-
|
|
202
|
-
editable = [v for v in versions if v["state"] in EDITABLE_STATES]
|
|
203
|
-
blocking = [v for v in versions if v["state"] in BLOCKING_STATES]
|
|
204
|
-
terminal = [v for v in versions if v["state"] in TERMINAL_STATES]
|
|
205
|
-
_log(
|
|
206
|
-
f"editable=[{_summarize(editable)}] "
|
|
207
|
-
f"blocking=[{_summarize(blocking)}] "
|
|
208
|
-
f"terminal=[{_summarize(terminal)}]"
|
|
209
|
-
)
|
|
141
|
+
raise SystemExit(1)
|
|
210
142
|
|
|
211
|
-
if editable:
|
|
212
|
-
target = _pick_highest_semver(editable)
|
|
213
|
-
_log(
|
|
214
|
-
f"REUSE {target['versionString']} (highest-semver editable, "
|
|
215
|
-
f"state={target['state']}, id={target['id']})"
|
|
216
|
-
)
|
|
217
|
-
return _result("REUSE", target["versionString"], target["id"])
|
|
218
143
|
|
|
219
|
-
|
|
220
|
-
|
|
144
|
+
def _classify_match_at_target(
|
|
145
|
+
versions: list[dict], target: str,
|
|
146
|
+
) -> tuple[str, dict | None, bool]:
|
|
147
|
+
"""Classify rows at ``target`` -> ``(status, match, also_editable)``.
|
|
148
|
+
|
|
149
|
+
Status values: ``"none"`` (no match -> CREATE), ``"reuse"`` (editable
|
|
150
|
+
-> REUSE its id), ``"terminal"`` (settled, slot locked -- caller may
|
|
151
|
+
auto-roll), ``"in_review"`` (under Apple Review or awaiting dev
|
|
152
|
+
release click -- caller MUST reject), ``"unknown"`` (matched but
|
|
153
|
+
state is in NO allowlist -- caller MUST reject; round 12 fail-closed
|
|
154
|
+
so an unfamiliar ASC state can't silently 409 on CREATE). The state
|
|
155
|
+
taxonomy lives in ``asc_common.py`` next to the constant set
|
|
156
|
+
definitions; positive allowlists (round 11) replaced the historical
|
|
157
|
+
``not in EDITABLE_STATES`` test that misclassified
|
|
158
|
+
PENDING_DEVELOPER_RELEASE as terminal."""
|
|
159
|
+
matches = [v for v in versions if v.get("versionString") == target]
|
|
160
|
+
if not matches:
|
|
161
|
+
return "none", None, False
|
|
162
|
+
in_review = [
|
|
163
|
+
v for v in matches
|
|
164
|
+
if v.get("state") in IN_REVIEW_STATES
|
|
165
|
+
or v.get("state") in BLOCKING_STATES
|
|
166
|
+
]
|
|
167
|
+
if in_review:
|
|
168
|
+
return "in_review", in_review[0], False
|
|
169
|
+
terminal = [v for v in matches if v.get("state") in TERMINAL_STATES]
|
|
170
|
+
if terminal:
|
|
171
|
+
return "terminal", terminal[0], len(matches) > 1
|
|
172
|
+
reusable = [v for v in matches if v.get("state") in REUSABLE_STATES]
|
|
173
|
+
if reusable:
|
|
174
|
+
return "reuse", reusable[0], False
|
|
175
|
+
# Round 12: matches exist but no state is recognized -- fail closed.
|
|
176
|
+
return "unknown", matches[0], len(matches) > 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _reject_match(
|
|
180
|
+
target: str, match: dict, *,
|
|
181
|
+
also_editable: bool = False, unknown: bool = False,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Reject a non-reusable match at ``target``. When ``unknown`` is
|
|
184
|
+
True the matched row's ASC state is in none of our allowlists --
|
|
185
|
+
bail rather than guess (round 12: silently CREATEing would 409;
|
|
186
|
+
silently REUSEing could interfere with whatever Apple is doing
|
|
187
|
+
with the row). Otherwise this is the historical terminal-collision
|
|
188
|
+
rejection path."""
|
|
189
|
+
state, vid = match.get("state", ""), match.get("id", "")
|
|
190
|
+
if unknown:
|
|
221
191
|
print(
|
|
222
|
-
f"::error::
|
|
223
|
-
f"
|
|
192
|
+
f"::error::MARKETING_VERSION {target} exists in App Store "
|
|
193
|
+
f"Connect in unrecognized state {state!r} (id={vid}); "
|
|
194
|
+
f"bailing rather than guessing whether to REUSE or CREATE. "
|
|
195
|
+
f"Update mmv classifier (asc_common.TERMINAL_STATES / "
|
|
196
|
+
f"IN_REVIEW_STATES / BLOCKING_STATES / REUSABLE_STATES) to "
|
|
197
|
+
f"cover {state!r}, or bump MARKETING_VERSION manually to a "
|
|
198
|
+
f"fresh value in your project's build settings ("
|
|
199
|
+
+ BUILD_SETTING_SOURCES + "). " + MIGRATION_HINT,
|
|
224
200
|
file=sys.stderr,
|
|
225
201
|
)
|
|
226
|
-
raise SystemExit(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
202
|
+
raise SystemExit(2)
|
|
203
|
+
suffix = (
|
|
204
|
+
" (an editable row at the same versionString also exists)"
|
|
205
|
+
if also_editable else ""
|
|
206
|
+
)
|
|
207
|
+
print(
|
|
208
|
+
f"::error::MARKETING_VERSION {target} already exists in App "
|
|
209
|
+
f"Store Connect in state {state} (id={vid}){suffix}. REUSE/CREATE "
|
|
210
|
+
f"both require a fresh marketing version. Bump MARKETING_VERSION "
|
|
211
|
+
f"in your project's build settings (" + BUILD_SETTING_SOURCES +
|
|
212
|
+
"). " + MIGRATION_HINT,
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
raise SystemExit(2)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _reject_in_review_match(target: str, match: dict) -> None:
|
|
219
|
+
"""Issue 13: REUSE/CREATE must NOT touch a row Apple is reviewing
|
|
220
|
+
OR a row awaiting the developer's manual release click
|
|
221
|
+
(PENDING_DEVELOPER_RELEASE). Both states require human action;
|
|
222
|
+
auto-rolling past them would either race App Review or bypass the
|
|
223
|
+
developer's release decision."""
|
|
224
|
+
state, vid = match.get("state", ""), match.get("id", "")
|
|
225
|
+
if state == "PENDING_DEVELOPER_RELEASE":
|
|
226
|
+
situation = (
|
|
227
|
+
"is approved by App Review and awaiting your manual release. "
|
|
228
|
+
"Either release the existing build via App Store Connect, or"
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
situation = (
|
|
232
|
+
"is currently in App Review and cannot be modified. Either "
|
|
233
|
+
"wait for review to complete, or"
|
|
243
234
|
)
|
|
244
|
-
return _result("CREATE", new_version, new_id)
|
|
245
|
-
|
|
246
|
-
# Unknown state (not editable, not blocking, not terminal) -- refuse to
|
|
247
|
-
# guess. Lists both states in the error so ops can update the classifier.
|
|
248
|
-
unknown = ", ".join(f"{v['versionString']}({v['state']})" for v in versions)
|
|
249
235
|
print(
|
|
250
|
-
f"::error::
|
|
251
|
-
f"
|
|
252
|
-
f"
|
|
236
|
+
f"::error::MARKETING_VERSION {target} exists in App Store Connect "
|
|
237
|
+
f"in state {state} (id={vid}). The version {situation} "
|
|
238
|
+
f"bump MARKETING_VERSION to a fresh value in your project's "
|
|
239
|
+
f"build settings (" + BUILD_SETTING_SOURCES + "). "
|
|
240
|
+
+ MIGRATION_HINT,
|
|
253
241
|
file=sys.stderr,
|
|
254
242
|
)
|
|
255
|
-
raise SystemExit(
|
|
243
|
+
raise SystemExit(2)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _auto_roll_past_locked(
|
|
247
|
+
target_version: str, versions: list[dict],
|
|
248
|
+
) -> tuple[str, str, dict | None, bool]:
|
|
249
|
+
"""Try to auto-roll past a TERMINAL row at target.
|
|
250
|
+
|
|
251
|
+
Classifies the row(s) at target. When the match is terminal
|
|
252
|
+
(READY_FOR_SALE / PROCESSING_FOR_APP_STORE / PENDING_APPLE_RELEASE
|
|
253
|
+
etc.), fires ``maybe_auto_bump`` to advance the project's
|
|
254
|
+
MARKETING_VERSION to the next patch and re-classifies against the
|
|
255
|
+
rolled value. When auto-bump is disabled (policy='none') or the
|
|
256
|
+
write target is unreachable, returns the original locked
|
|
257
|
+
classification so the caller can surface the historical
|
|
258
|
+
SystemExit(2) rejection.
|
|
259
|
+
|
|
260
|
+
DESIGN DECISION: in-review collisions (WAITING_FOR_REVIEW /
|
|
261
|
+
IN_REVIEW / PENDING_DEVELOPER_RELEASE) are deliberately NOT
|
|
262
|
+
auto-rolled. Apple is either actively reviewing the row at
|
|
263
|
+
``target`` or has approved it and is waiting on the developer's
|
|
264
|
+
manual release click; advancing the project to a new version would
|
|
265
|
+
either submit a competing build while review is in flight or
|
|
266
|
+
bypass the developer's release decision -- both states require
|
|
267
|
+
human action. The historical SystemExit(2) rejection at these
|
|
268
|
+
states surfaces the conflict loudly so a human can resolve it.
|
|
269
|
+
|
|
270
|
+
Returns ``(effective_target, status, match, also_editable)``."""
|
|
271
|
+
status, match, also_editable = _classify_match_at_target(
|
|
272
|
+
versions, target_version,
|
|
273
|
+
)
|
|
274
|
+
if status != "terminal":
|
|
275
|
+
return target_version, status, match, also_editable
|
|
276
|
+
rolled = maybe_auto_bump(target_version, target_version)
|
|
277
|
+
if rolled is None or rolled == target_version:
|
|
278
|
+
return target_version, status, match, also_editable
|
|
279
|
+
_log(f"auto-roll: {target_version} is {match.get('state', '')}, "
|
|
280
|
+
f"advancing project to {rolled}")
|
|
281
|
+
new_status, new_match, new_also = _classify_match_at_target(
|
|
282
|
+
versions, rolled,
|
|
283
|
+
)
|
|
284
|
+
return rolled, new_status, new_match, new_also
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def decide_for_version(
|
|
288
|
+
target_version: str, versions: list[dict],
|
|
289
|
+
app_id: str, token: str,
|
|
290
|
+
) -> dict:
|
|
291
|
+
"""Find-or-create the ASC row at exactly ``target_version``.
|
|
292
|
+
|
|
293
|
+
Validates semver, runs the non-strict floor check (auto-bump
|
|
294
|
+
rewrites MARKETING_VERSION when below the ASC floor; the returned
|
|
295
|
+
effective target is what downstream uses), then classifies any
|
|
296
|
+
row(s) at target. Terminal collisions auto-roll to the next patch
|
|
297
|
+
(or reject when policy='none'); in-review and unknown-state
|
|
298
|
+
collisions reject loudly (see ``_classify_match_at_target`` for
|
|
299
|
+
the full taxonomy). No match -> CREATE; reusable editable -> REUSE."""
|
|
300
|
+
_validate_target(target_version)
|
|
301
|
+
target_version = assert_target_meets_floor(
|
|
302
|
+
target_version, app_id, token,
|
|
303
|
+
appstore_versions=versions, strict=False,
|
|
304
|
+
)
|
|
305
|
+
target_version, status, match, also_editable = _auto_roll_past_locked(
|
|
306
|
+
target_version, versions,
|
|
307
|
+
)
|
|
308
|
+
if status == "terminal":
|
|
309
|
+
_reject_match(target_version, match, also_editable=also_editable)
|
|
310
|
+
if status == "in_review":
|
|
311
|
+
_reject_in_review_match(target_version, match)
|
|
312
|
+
if status == "unknown":
|
|
313
|
+
_reject_match(target_version, match, unknown=True)
|
|
314
|
+
if status == "none":
|
|
315
|
+
return decide_create(target_version, versions, app_id, token)
|
|
316
|
+
vid = match.get("id", "")
|
|
317
|
+
state = match.get("state", "")
|
|
318
|
+
_log(f"decision=REUSE versionString={target_version} "
|
|
319
|
+
f"(matches MARKETING_VERSION; state={state}, id={vid})")
|
|
320
|
+
return _result("REUSE", target_version, vid, state)
|
|
256
321
|
|
|
257
322
|
|
|
258
323
|
def main() -> None:
|
|
@@ -260,10 +325,11 @@ def main() -> None:
|
|
|
260
325
|
issuer_id = env("ASC_ISSUER_ID")
|
|
261
326
|
key_path = env("ASC_KEY_PATH")
|
|
262
327
|
app_id = env("APP_STORE_APPLE_ID")
|
|
328
|
+
target_version = env("MARKETING_VERSION")
|
|
263
329
|
|
|
264
330
|
token = make_jwt(key_id, issuer_id, key_path)
|
|
265
331
|
versions = fetch_versions(app_id, token)
|
|
266
|
-
result =
|
|
332
|
+
result = decide_for_version(target_version, versions, app_id, token)
|
|
267
333
|
print(json.dumps(result))
|
|
268
334
|
|
|
269
335
|
|