@daemux/store-automator 0.10.71 → 0.10.72

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.71"
8
+ "version": "0.10.72"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "store-automator",
13
13
  "source": "./plugins/store-automator",
14
14
  "description": "3 agents for app store publishing: reviewer, meta-creator, media-designer",
15
- "version": "0.10.71",
15
+ "version": "0.10.72",
16
16
  "keywords": [
17
17
  "flutter",
18
18
  "app-store",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.71",
3
+ "version": "0.10.72",
4
4
  "description": "Full App Store & Google Play automation for Flutter apps with Claude Code agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "store-automator",
3
- "version": "0.10.71",
3
+ "version": "0.10.72",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -2,19 +2,26 @@
2
2
  """
3
3
  Resolve the App Store Connect version slot for this run.
4
4
 
5
- Apple permits at most ONE editable appStoreVersion per app at a time. If one
6
- already exists under a different versionString, creating another returns 409
7
- ENTITY_ERROR.RELATIONSHIP.INVALID. We therefore PATCH the existing editable
8
- slot to the desired versionString rather than attempt a second CREATE.
9
-
10
- Reads PROJECT_MARKETING_VERSION from env (pinned in Xcode) and decides whether
11
- to REUSE an editable draft (directly or via PATCH) or CREATE a new one. Never
12
- auto-bumps the version.
13
-
14
- Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID,
15
- PROJECT_MARKETING_VERSION (e.g. "1.2.3" or "1.2").
5
+ Faithful port of gowalk-step/manage_version.py. The CI -- not the Xcode
6
+ project -- decides the marketing version. The archive step overrides the
7
+ pbxproj MARKETING_VERSION at build time via `MARKETING_VERSION=...` on the
8
+ xcodebuild command line.
9
+
10
+ Algorithm:
11
+ 1. Fetch all appStoreVersions for the app (no sort, no filter).
12
+ 2. No versions at all -> first release: CREATE "1.0.0".
13
+ 3. Pick latest by createdDate.
14
+ - state == PENDING_DEVELOPER_RELEASE -> fail (must be released first).
15
+ - state == READY_FOR_SALE -> auto-bump and CREATE the next
16
+ version with rollover (patch<10 -> +1; patch>=10 -> reset, minor+1;
17
+ minor>=10 -> reset, major+1).
18
+ - anything else (PREPARE_FOR_SUBMISSION, WAITING_FOR_REVIEW, IN_REVIEW,
19
+ REJECTED, ...) -> REUSE it.
20
+ 4. 409 on CREATE (race) -> re-fetch, match by versionString, REUSE that id.
21
+
22
+ Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID.
16
23
  Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
17
- Stderr: human log + ::error:: lines for GitHub Actions.
24
+ Stderr: [decision] log lines + ::error:: on fatal conditions.
18
25
  """
19
26
 
20
27
  from __future__ import annotations
@@ -24,18 +31,10 @@ import os
24
31
  import re
25
32
  import sys
26
33
 
27
- from asc_common import (
28
- BLOCKING_STATES,
29
- EDITABLE_STATES,
30
- TERMINAL_STATES,
31
- get_json,
32
- make_jwt,
33
- request,
34
- )
34
+ from asc_common import get_json, make_jwt, request
35
35
 
36
36
 
37
37
  SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
38
- MAX_RECURSIONS = 2
39
38
 
40
39
 
41
40
  def _log(msg: str) -> None:
@@ -58,136 +57,50 @@ def env(name: str) -> str:
58
57
  return val
59
58
 
60
59
 
61
- def normalize(v: str) -> str:
62
- m = SEM_RE.match(v)
60
+ def calculate_next_version(current_version: str) -> str:
61
+ """Rollover bump: patch+1; patch>=10 resets to 0 and minor+1;
62
+ minor>=10 resets both to 0 and major+1. Accepts "M.m" or "M.m.p"."""
63
+ m = SEM_RE.match(current_version or "")
63
64
  if not m:
64
- print(
65
- f"::error::PROJECT_MARKETING_VERSION ({v!r}) is not a valid "
66
- f"semantic version (expected MAJOR.MINOR[.PATCH]).",
67
- file=sys.stderr,
65
+ raise SystemExit(
66
+ f"::error::Latest App Store versionString {current_version!r} is "
67
+ f"not a valid semantic version (expected MAJOR.MINOR[.PATCH])."
68
68
  )
69
- raise SystemExit(1)
70
- return f"{m.group(1)}.{m.group(2)}.{m.group(3) or '0'}"
71
-
69
+ major = int(m.group(1))
70
+ minor = int(m.group(2))
71
+ patch = int(m.group(3) or "0")
72
72
 
73
- def to_tuple(v: str) -> tuple[int, int, int]:
74
- m = SEM_RE.match(v or "")
75
- if not m:
76
- return (0, 0, 0)
77
- return int(m.group(1)), int(m.group(2)), int(m.group(3) or "0")
73
+ new_patch = patch + 1
74
+ new_minor = minor
75
+ new_major = major
78
76
 
77
+ if new_patch >= 10:
78
+ new_patch = 0
79
+ new_minor = minor + 1
80
+ if new_minor >= 10:
81
+ new_minor = 0
82
+ new_patch = 0
83
+ new_major = major + 1
79
84
 
80
- def same_version(a: str, b: str) -> bool:
81
- """1.2 == 1.2.0 via normalized tuple comparison."""
82
- return to_tuple(a) == to_tuple(b)
85
+ return f"{new_major}.{new_minor}.{new_patch}"
83
86
 
84
87
 
85
88
  def fetch_versions(app_id: str, token: str) -> list[dict]:
86
- """Return list of {'versionString','state','id','createdDate'} for IOS."""
87
- data = get_json(
88
- f"/apps/{app_id}/appStoreVersions",
89
- token,
90
- params={"limit": 200, "filter[platform]": "IOS"},
91
- )
89
+ """All appStoreVersions for the app. Mirrors gowalk-step: no sort, no
90
+ limit, no platform filter -- we do client-side selection."""
91
+ data = get_json(f"/apps/{app_id}/appStoreVersions", token)
92
92
  out: list[dict] = []
93
93
  for item in data.get("data", []):
94
94
  attrs = item.get("attributes") or {}
95
- out.append(
96
- {
97
- "versionString": attrs.get("versionString") or "",
98
- "state": attrs.get("appStoreState") or "",
99
- "id": item.get("id") or "",
100
- "createdDate": attrs.get("createdDate") or "",
101
- }
102
- )
95
+ out.append({
96
+ "versionString": attrs.get("versionString") or "",
97
+ "state": attrs.get("appStoreState") or "",
98
+ "id": item.get("id") or "",
99
+ "createdDate": attrs.get("createdDate") or "",
100
+ })
103
101
  return out
104
102
 
105
103
 
106
- def partition(versions: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
107
- """Split versions into (editable, blocking, terminal) buckets."""
108
- ed, bl, te = [], [], []
109
- for v in versions:
110
- st = v["state"]
111
- if st in EDITABLE_STATES:
112
- ed.append(v)
113
- elif st in BLOCKING_STATES:
114
- bl.append(v)
115
- elif st in TERMINAL_STATES:
116
- te.append(v)
117
- return ed, bl, te
118
-
119
-
120
- def pick_latest_editable(editable: list[dict]) -> dict:
121
- """Latest by createdDate; ties broken by id desc."""
122
- return sorted(
123
- editable,
124
- key=lambda v: (v.get("createdDate", ""), v.get("id", "")),
125
- reverse=True,
126
- )[0]
127
-
128
-
129
- def highest_terminal_tuple(te: list[dict]) -> tuple[int, int, int] | None:
130
- best: tuple[int, int, int] | None = None
131
- for v in te:
132
- if not SEM_RE.match(v["versionString"] or ""):
133
- continue
134
- t = to_tuple(v["versionString"])
135
- if best is None or t > best:
136
- best = t
137
- return best
138
-
139
-
140
- def fail_terminal(v: str, state: str) -> None:
141
- print(
142
- f"::error::Version {v} is already {state} on App Store Connect. "
143
- f"Bump MARKETING_VERSION in the Xcode project to publish a new "
144
- f"release.",
145
- file=sys.stderr,
146
- )
147
- raise SystemExit(1)
148
-
149
-
150
- def fail_blocking(v: str) -> None:
151
- print(
152
- f"::error::Version {v} is PENDING_DEVELOPER_RELEASE on App Store "
153
- f"Connect. Release it manually (or bump MARKETING_VERSION in the "
154
- f"Xcode project), then retry.",
155
- file=sys.stderr,
156
- )
157
- raise SystemExit(1)
158
-
159
-
160
- def fail_not_higher(v: str, published: tuple[int, int, int]) -> None:
161
- p = ".".join(str(x) for x in published)
162
- print(
163
- f"::error::MARKETING_VERSION ({v}) is not higher than the published "
164
- f"version ({p}) on App Store Connect. Bump MARKETING_VERSION in the "
165
- f"Xcode project.",
166
- file=sys.stderr,
167
- )
168
- raise SystemExit(1)
169
-
170
-
171
- def guard_not_downgrade(v: str, te: list[dict]) -> None:
172
- top = highest_terminal_tuple(te)
173
- if top is not None and to_tuple(v) <= top:
174
- fail_not_higher(v, top)
175
-
176
-
177
- def patch_version_string(slot_id: str, version: str, token: str):
178
- body = {
179
- "data": {
180
- "type": "appStoreVersions",
181
- "id": slot_id,
182
- "attributes": {"versionString": version},
183
- }
184
- }
185
- return request(
186
- "PATCH", f"/appStoreVersions/{slot_id}", token,
187
- json_body=body, allow_status={409},
188
- )
189
-
190
-
191
104
  def create_version(app_id: str, version: str, token: str):
192
105
  body = {
193
106
  "data": {
@@ -208,68 +121,26 @@ def create_version(app_id: str, version: str, token: str):
208
121
  )
209
122
 
210
123
 
211
- def _reenter_on_409(
212
- app_id: str, version: str, token: str, depth: int, label: str,
213
- ) -> dict:
214
- if depth >= MAX_RECURSIONS:
215
- raise SystemExit(
216
- f"{label} returned 409 after {depth} recursion(s); giving up."
217
- )
124
+ def create_or_reuse(app_id: str, version: str, token: str) -> str:
125
+ """POST a new version; on 409 race, re-fetch and return existing id.
126
+ Returns the appStoreVersion id."""
127
+ resp = create_version(app_id, version, token)
128
+ if resp.status_code != 409:
129
+ return resp.json()["data"]["id"]
130
+
131
+ # Race: someone else created it between our GET and POST. Find by name.
218
132
  print(
219
- f"{label} returned 409; re-fetching and redeciding "
220
- f"(depth={depth + 1})",
133
+ f"POST /appStoreVersions returned 409 for {version}; "
134
+ "re-fetching to locate the existing slot.",
221
135
  file=sys.stderr,
222
136
  )
223
- return decide(app_id, version, token, depth + 1)
224
-
225
-
226
- def decide(
227
- app_id: str, project_version: str, token: str, depth: int = 0,
228
- ) -> dict:
229
- """Decision tree; re-enters up to MAX_RECURSIONS on 409 races."""
230
- versions = fetch_versions(app_id, token)
231
- editable, blocking, terminal = partition(versions)
232
-
233
- # 1. Blocking match on the target versionString (awaiting dev release).
234
- for v in blocking:
235
- if same_version(v["versionString"], project_version):
236
- fail_blocking(project_version)
237
-
238
- # 2. Editable slot exists — reuse (directly or via PATCH rename).
239
- if editable:
240
- latest = pick_latest_editable(editable)
241
- if same_version(latest["versionString"], project_version):
242
- _log(f"REUSE {project_version} (state={latest['state']}, "
243
- f"id={latest['id']})")
244
- return _result("REUSE", project_version, latest["id"])
245
-
246
- guard_not_downgrade(project_version, terminal)
247
- old_v, prior = latest["versionString"], latest["state"]
248
- resp = patch_version_string(latest["id"], project_version, token)
249
- if resp.status_code == 409:
250
- return _reenter_on_409(
251
- app_id, project_version, token, depth,
252
- f"PATCH /appStoreVersions/{latest['id']}",
253
- )
254
- new_id = resp.json()["data"]["id"]
255
- _log(f"REUSE {project_version} (patched from {old_v}, id={new_id}, "
256
- f"prior_state={prior})")
257
- return _result("REUSE", project_version, new_id)
258
-
259
- # 3. No editable slots — check terminal collision, then CREATE.
260
- for v in terminal:
261
- if same_version(v["versionString"], project_version):
262
- fail_terminal(project_version, v["state"])
263
- guard_not_downgrade(project_version, terminal)
264
-
265
- resp = create_version(app_id, project_version, token)
266
- if resp.status_code == 409:
267
- return _reenter_on_409(
268
- app_id, project_version, token, depth, "POST /appStoreVersions",
269
- )
270
- new_id = resp.json()["data"]["id"]
271
- _log(f"CREATE {project_version} -> appStoreVersion id={new_id}")
272
- return _result("CREATE", project_version, new_id)
137
+ for v in fetch_versions(app_id, token):
138
+ if v["versionString"] == version:
139
+ return v["id"]
140
+ raise SystemExit(
141
+ f"::error::POST /appStoreVersions returned 409 for {version} but "
142
+ f"no matching versionString was found on re-fetch."
143
+ )
273
144
 
274
145
 
275
146
  def main() -> None:
@@ -277,11 +148,48 @@ def main() -> None:
277
148
  issuer_id = env("ASC_ISSUER_ID")
278
149
  key_path = env("ASC_KEY_PATH")
279
150
  app_id = env("APP_STORE_APPLE_ID")
280
- project_version = normalize(env("PROJECT_MARKETING_VERSION"))
281
151
 
282
152
  token = make_jwt(key_id, issuer_id, key_path)
283
- result = decide(app_id, project_version, token)
284
- print(json.dumps(result))
153
+ versions = fetch_versions(app_id, token)
154
+
155
+ # 1. First ever release.
156
+ if not versions:
157
+ new_version = "1.0.0"
158
+ new_id = create_or_reuse(app_id, new_version, token)
159
+ _log(f"CREATE {new_version} (first release) -> appStoreVersion id={new_id}")
160
+ print(json.dumps(_result("CREATE", new_version, new_id)))
161
+ return
162
+
163
+ # 2. Pick the latest by Apple's createdDate.
164
+ latest = max(versions, key=lambda v: v.get("createdDate", ""))
165
+ state = latest["state"]
166
+ version_string = latest["versionString"]
167
+ version_id = latest["id"]
168
+
169
+ # 3. Blocking: pending developer release.
170
+ if state == "PENDING_DEVELOPER_RELEASE":
171
+ print(
172
+ f"::error::Latest App Store version {version_string} is "
173
+ f"PENDING_DEVELOPER_RELEASE. Release it in App Store Connect, "
174
+ f"then retry.",
175
+ file=sys.stderr,
176
+ )
177
+ raise SystemExit(1)
178
+
179
+ # 4. Live: auto-bump to next version.
180
+ if state == "READY_FOR_SALE":
181
+ new_version = calculate_next_version(version_string)
182
+ new_id = create_or_reuse(app_id, new_version, token)
183
+ _log(
184
+ f"CREATE {new_version} (previous={version_string}, "
185
+ f"previous_state={state}) -> appStoreVersion id={new_id}"
186
+ )
187
+ print(json.dumps(_result("CREATE", new_version, new_id)))
188
+ return
189
+
190
+ # 5. Editable/in-review/rejected/etc: reuse as-is.
191
+ _log(f"REUSE {version_string} (state={state}, id={version_id})")
192
+ print(json.dumps(_result("REUSE", version_string, version_id)))
285
193
 
286
194
 
287
195
  if __name__ == "__main__":