@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.
@@ -2,257 +2,322 @@
2
2
  """
3
3
  Resolve the App Store Connect version slot for this run.
4
4
 
5
- CI -- not the Xcode project -- decides the marketing version. The archive
6
- step overrides the pbxproj MARKETING_VERSION at build time via
7
- `MARKETING_VERSION=...` on the xcodebuild command line.
8
-
9
- Algorithm (improvement over gowalk-step's "latest by createdDate"):
10
- 1. Fetch all appStoreVersions for the app.
11
- 2. Partition by state:
12
- editable (PREPARE_FOR_SUBMISSION, REJECTED, METADATA_REJECTED,
13
- DEVELOPER_REJECTED, INVALID_BINARY, WAITING_FOR_REVIEW,
14
- IN_REVIEW)
15
- blocking (PENDING_DEVELOPER_RELEASE)
16
- terminal (READY_FOR_SALE, PROCESSING_FOR_APP_STORE,
17
- PENDING_APPLE_RELEASE, REPLACED_WITH_NEW_VERSION,
18
- REMOVED_FROM_SALE, NOT_APPLICABLE)
19
- 3. If editable: REUSE the HIGHEST SEMVER among editable. Picking the
20
- highest semver (not the latest createdDate) matches how Apple's UI
21
- presents the "next" version and avoids the "later version closed"
22
- error Apple returns on uploads to a lower editable version when a
23
- higher one already exists.
24
- 4. Elif blocking: fail with a clear error -- a human must release the
25
- pending version manually before CI can proceed.
26
- 5. Elif terminal: pick the highest-semver terminal version.
27
- - READY_FOR_SALE -> CREATE the next version via rollover bump.
28
- - anything else -> fail (manual intervention required; state is
29
- neither editable nor live).
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
- EDITABLE_STATES,
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
- SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
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
- return {
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
- print(
75
- "::error::APP_STORE_APPLE_ID is empty. This usually means "
76
- "auto-detect could not resolve PRODUCT_BUNDLE_IDENTIFIER from "
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
- print(f"::error::Missing required env var: {name}", file=sys.stderr)
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 semver_tuple(version_string: str) -> tuple[int, int, int]:
90
- """Parse 'M.m[.p]' -> (M, m, p). Non-semver returns (-1,-1,-1) so it
91
- sorts below any real version."""
92
- m = SEM_RE.match(version_string or "")
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"POST /appStoreVersions returned 409 for {version}; "
169
- "re-fetching to locate the existing slot.",
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
- for v in fetch_versions(app_id, token):
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
- if blocking:
220
- target = _pick_highest_semver(blocking)
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::App Store version {target['versionString']} is "
223
- f"{target['state']}. Release it in App Store Connect, then retry.",
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(1)
227
-
228
- if terminal:
229
- target = _pick_highest_semver(terminal)
230
- if target["state"] != "READY_FOR_SALE":
231
- print(
232
- f"::error::Highest App Store version {target['versionString']} "
233
- f"is {target['state']}; neither editable nor live. Manual "
234
- f"intervention required in App Store Connect.",
235
- file=sys.stderr,
236
- )
237
- raise SystemExit(1)
238
- new_version = calculate_next_version(target["versionString"])
239
- new_id = create_or_reuse(app_id, new_version, token)
240
- _log(
241
- f"CREATE {new_version} (previous={target['versionString']}, "
242
- f"previous_state={target['state']}) -> appStoreVersion id={new_id}"
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::No editable/blocking/terminal App Store versions found; "
251
- f"all versions have unrecognized states: {unknown}. Update "
252
- f"asc_common.EDITABLE_STATES / TERMINAL_STATES / BLOCKING_STATES.",
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(1)
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 = decide(versions, app_id, token)
332
+ result = decide_for_version(target_version, versions, app_id, token)
267
333
  print(json.dumps(result))
268
334
 
269
335