@daemux/store-automator 0.10.69 → 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.69"
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.69",
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.69",
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.69",
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
 
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Set TestFlight "What to Test" (betaBuildLocalization.whatsNew) for the build
4
+ just uploaded by this workflow.
5
+
6
+ Runs after the altool upload step. Polls ASC until the build record appears
7
+ (altool upload returns before Apple has fully ingested the build), then either
8
+ PATCHes an existing betaBuildLocalization for the requested locale or POSTs a
9
+ new one.
10
+
11
+ Environment:
12
+ ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
13
+ APP_STORE_APPLE_ID -- numeric ASC app id
14
+ MARKETING_VERSION -- e.g. "1.2.3"
15
+ BUILD_NUMBER -- e.g. "42"
16
+ TESTFLIGHT_WHATS_NEW -- release notes text (empty = skip)
17
+ TESTFLIGHT_LOCALE -- locale code, default "en-US"
18
+
19
+ Non-fatal by design: if the build can't be found within the polling window we
20
+ emit a ::warning:: and exit 0, since the TestFlight upload itself may still
21
+ have succeeded.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import sys
28
+ import time
29
+
30
+ from asc_common import get_json, make_jwt, request
31
+
32
+
33
+ POLL_ATTEMPTS = 20
34
+ POLL_FIRST_DELAY = 10
35
+ POLL_DELAY = 30
36
+
37
+
38
+ def _require_env(name: str) -> str:
39
+ value = os.environ.get(name, "").strip()
40
+ if not value:
41
+ print(f"::error::{name} env var is required", file=sys.stderr)
42
+ raise SystemExit(1)
43
+ return value
44
+
45
+
46
+ def _find_build(token: str, app_id: str, marketing_version: str, build_number: str) -> str | None:
47
+ """Return the build id matching (marketing_version, build_number), or None."""
48
+ params = {
49
+ "filter[app]": app_id,
50
+ "filter[preReleaseVersion.version]": marketing_version,
51
+ "filter[version]": build_number,
52
+ "limit": "5",
53
+ }
54
+ data = get_json("/builds", token, params=params)
55
+ items = data.get("data") or []
56
+ return items[0].get("id") if items else None
57
+
58
+
59
+ def _poll_for_build(token: str, app_id: str, marketing_version: str, build_number: str) -> str | None:
60
+ """Poll ASC for the uploaded build. Returns build id or None on timeout."""
61
+ for attempt in range(POLL_ATTEMPTS):
62
+ delay = POLL_FIRST_DELAY if attempt == 0 else POLL_DELAY
63
+ print(
64
+ f"Waiting {delay}s for build {marketing_version} ({build_number}) to appear "
65
+ f"(attempt {attempt + 1}/{POLL_ATTEMPTS})",
66
+ file=sys.stderr,
67
+ )
68
+ time.sleep(delay)
69
+ build_id = _find_build(token, app_id, marketing_version, build_number)
70
+ if build_id:
71
+ print(f"Found build id {build_id}", file=sys.stderr)
72
+ return build_id
73
+ return None
74
+
75
+
76
+ def _existing_localization(token: str, build_id: str, locale: str) -> str | None:
77
+ """Return the betaBuildLocalization id for `locale` on this build, or None."""
78
+ data = get_json(f"/builds/{build_id}/betaBuildLocalizations", token)
79
+ for item in data.get("data") or []:
80
+ if (item.get("attributes") or {}).get("locale") == locale:
81
+ return item.get("id")
82
+ return None
83
+
84
+
85
+ def _patch_localization(token: str, localization_id: str, whats_new: str) -> None:
86
+ body = {
87
+ "data": {
88
+ "type": "betaBuildLocalizations",
89
+ "id": localization_id,
90
+ "attributes": {"whatsNew": whats_new},
91
+ }
92
+ }
93
+ request("PATCH", f"/betaBuildLocalizations/{localization_id}", token, json_body=body)
94
+
95
+
96
+ def _create_localization(token: str, build_id: str, locale: str, whats_new: str) -> None:
97
+ body = {
98
+ "data": {
99
+ "type": "betaBuildLocalizations",
100
+ "attributes": {"locale": locale, "whatsNew": whats_new},
101
+ "relationships": {
102
+ "build": {"data": {"type": "builds", "id": build_id}},
103
+ },
104
+ }
105
+ }
106
+ request("POST", "/betaBuildLocalizations", token, json_body=body)
107
+
108
+
109
+ def main() -> int:
110
+ whats_new = os.environ.get("TESTFLIGHT_WHATS_NEW", "")
111
+ if not whats_new.strip():
112
+ print("empty whatsNew; skipping", file=sys.stderr)
113
+ return 0
114
+
115
+ key_id = _require_env("ASC_KEY_ID")
116
+ issuer_id = _require_env("ASC_ISSUER_ID")
117
+ key_path = _require_env("ASC_KEY_PATH")
118
+ app_id = _require_env("APP_STORE_APPLE_ID")
119
+ marketing_version = _require_env("MARKETING_VERSION")
120
+ build_number = _require_env("BUILD_NUMBER")
121
+ locale = os.environ.get("TESTFLIGHT_LOCALE", "").strip() or "en-US"
122
+
123
+ token = make_jwt(key_id, issuer_id, key_path)
124
+
125
+ build_id = _poll_for_build(token, app_id, marketing_version, build_number)
126
+ if not build_id:
127
+ print(
128
+ f"::warning::build {marketing_version} ({build_number}) not found in ASC "
129
+ f"after {POLL_ATTEMPTS} polls; skipping whatsNew update",
130
+ file=sys.stderr,
131
+ )
132
+ return 0
133
+
134
+ existing = _existing_localization(token, build_id, locale)
135
+ if existing:
136
+ _patch_localization(token, existing, whats_new)
137
+ action = "updated"
138
+ else:
139
+ _create_localization(token, build_id, locale, whats_new)
140
+ action = "created"
141
+ print(f"Set betaBuildLocalization whatsNew for build {build_id} locale {locale} ({action})")
142
+ return 0
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())