@daemux/store-automator 0.10.70 → 0.10.71

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.70"
8
+ "version": "0.10.71"
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.70",
15
+ "version": "0.10.71",
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.70",
3
+ "version": "0.10.71",
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.70",
3
+ "version": "0.10.71",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -2,18 +2,19 @@
2
2
  """
3
3
  Resolve the App Store Connect version slot for this run.
4
4
 
5
- Reads MARKETING_VERSION from the Xcode project (via env) and decides whether
6
- to REUSE an existing editable draft on ASC or CREATE a new one. Never
7
- auto-bumps the version the developer pins intent in Xcode, CI honours it.
8
-
9
- Environment:
10
- ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID
11
- PROJECT_MARKETING_VERSION — e.g. "1.2.3" or "1.2" (normalized to 3-part)
12
-
13
- Stdout: single-line JSON
14
- {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
15
-
16
- Stderr: human-readable log + ::error:: lines for GitHub Actions.
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").
16
+ Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
17
+ Stderr: human log + ::error:: lines for GitHub Actions.
17
18
  """
18
19
 
19
20
  from __future__ import annotations
@@ -34,6 +35,19 @@ from asc_common import (
34
35
 
35
36
 
36
37
  SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
38
+ MAX_RECURSIONS = 2
39
+
40
+
41
+ def _log(msg: str) -> None:
42
+ print(f"[decision] {msg}", file=sys.stderr)
43
+
44
+
45
+ def _result(decision: str, version: str, vid: str) -> dict:
46
+ return {
47
+ "decision": decision,
48
+ "versionString": version,
49
+ "appStoreVersionId": vid,
50
+ }
37
51
 
38
52
 
39
53
  def env(name: str) -> str:
@@ -53,26 +67,27 @@ def normalize(v: str) -> str:
53
67
  file=sys.stderr,
54
68
  )
55
69
  raise SystemExit(1)
56
- major, minor, patch = m.group(1), m.group(2), m.group(3) or "0"
57
- return f"{major}.{minor}.{patch}"
70
+ return f"{m.group(1)}.{m.group(2)}.{m.group(3) or '0'}"
58
71
 
59
72
 
60
73
  def to_tuple(v: str) -> tuple[int, int, int]:
61
- parts = v.split(".")
62
- while len(parts) < 3:
63
- parts.append("0")
64
- return int(parts[0]), int(parts[1]), int(parts[2])
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")
78
+
79
+
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)
65
83
 
66
84
 
67
85
  def fetch_versions(app_id: str, token: str) -> list[dict]:
68
- """Return list of {'versionString','state','id'} for IOS versions."""
86
+ """Return list of {'versionString','state','id','createdDate'} for IOS."""
69
87
  data = get_json(
70
88
  f"/apps/{app_id}/appStoreVersions",
71
89
  token,
72
- params={
73
- "limit": 200,
74
- "filter[platform]": "IOS",
75
- },
90
+ params={"limit": 200, "filter[platform]": "IOS"},
76
91
  )
77
92
  out: list[dict] = []
78
93
  for item in data.get("data", []):
@@ -82,18 +97,39 @@ def fetch_versions(app_id: str, token: str) -> list[dict]:
82
97
  "versionString": attrs.get("versionString") or "",
83
98
  "state": attrs.get("appStoreState") or "",
84
99
  "id": item.get("id") or "",
100
+ "createdDate": attrs.get("createdDate") or "",
85
101
  }
86
102
  )
87
103
  return out
88
104
 
89
105
 
90
- def highest_terminal(versions: list[dict]) -> tuple[int, int, int] | None:
91
- best: tuple[int, int, int] | None = None
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 = [], [], []
92
109
  for v in versions:
93
- if v["state"] not in TERMINAL_STATES:
94
- continue
95
- parsed = SEM_RE.match(v["versionString"])
96
- if not parsed:
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 ""):
97
133
  continue
98
134
  t = to_tuple(v["versionString"])
99
135
  if best is None or t > best:
@@ -132,7 +168,27 @@ def fail_not_higher(v: str, published: tuple[int, int, int]) -> None:
132
168
  raise SystemExit(1)
133
169
 
134
170
 
135
- def create_version(app_id: str, version: str, token: str) -> str:
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
+ def create_version(app_id: str, version: str, token: str):
136
192
  body = {
137
193
  "data": {
138
194
  "type": "appStoreVersions",
@@ -146,40 +202,74 @@ def create_version(app_id: str, version: str, token: str) -> str:
146
202
  },
147
203
  }
148
204
  }
149
- resp = request(
150
- "POST",
151
- "/appStoreVersions",
152
- token,
153
- json_body=body,
154
- allow_status={409},
205
+ return request(
206
+ "POST", "/appStoreVersions", token,
207
+ json_body=body, allow_status={409},
155
208
  )
156
- if resp.status_code == 409:
157
- # Race / eventual consistency: re-query and treat as REUSE if editable.
158
- versions = fetch_versions(app_id, token)
159
- match = next(
160
- (v for v in versions if v["versionString"] == version), None
161
- )
162
- if match and match["state"] in EDITABLE_STATES:
163
- print(
164
- f"[decision] REUSE {version} (state={match['state']}, "
165
- f"id={match['id']}) — recovered from 409 on create",
166
- file=sys.stderr,
167
- )
168
- return match["id"]
169
- if match and match["state"] in BLOCKING_STATES:
170
- fail_blocking(version)
171
- if match:
172
- fail_terminal(version, match["state"])
209
+
210
+
211
+ def _reenter_on_409(
212
+ app_id: str, version: str, token: str, depth: int, label: str,
213
+ ) -> dict:
214
+ if depth >= MAX_RECURSIONS:
173
215
  raise SystemExit(
174
- f"POST /appStoreVersions returned 409 but no matching version "
175
- f"{version} was found on re-query:\n{resp.text[:500]}"
216
+ f"{label} returned 409 after {depth} recursion(s); giving up."
176
217
  )
177
- new_id = resp.json()["data"]["id"]
178
218
  print(
179
- f"[decision] CREATE {version} -> appStoreVersion id={new_id}",
219
+ f"{label} returned 409; re-fetching and redeciding "
220
+ f"(depth={depth + 1})",
180
221
  file=sys.stderr,
181
222
  )
182
- return new_id
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)
183
273
 
184
274
 
185
275
  def main() -> None:
@@ -190,39 +280,7 @@ def main() -> None:
190
280
  project_version = normalize(env("PROJECT_MARKETING_VERSION"))
191
281
 
192
282
  token = make_jwt(key_id, issuer_id, key_path)
193
- versions = fetch_versions(app_id, token)
194
- match = next(
195
- (v for v in versions if v["versionString"] == project_version), None
196
- )
197
-
198
- if match and match["state"] in EDITABLE_STATES:
199
- print(
200
- f"[decision] REUSE {project_version} (state={match['state']}, "
201
- f"id={match['id']})",
202
- file=sys.stderr,
203
- )
204
- result = {
205
- "decision": "REUSE",
206
- "versionString": project_version,
207
- "appStoreVersionId": match["id"],
208
- }
209
- elif match and match["state"] in BLOCKING_STATES:
210
- fail_blocking(project_version)
211
- return # unreachable
212
- elif match and match["state"] in TERMINAL_STATES:
213
- fail_terminal(project_version, match["state"])
214
- return # unreachable
215
- else:
216
- top = highest_terminal(versions)
217
- if top is not None and to_tuple(project_version) <= top:
218
- fail_not_higher(project_version, top)
219
- new_id = create_version(app_id, project_version, token)
220
- result = {
221
- "decision": "CREATE",
222
- "versionString": project_version,
223
- "appStoreVersionId": new_id,
224
- }
225
-
283
+ result = decide(app_id, project_version, token)
226
284
  print(json.dumps(result))
227
285
 
228
286