@daemux/store-automator 0.10.70 → 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.70"
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.70",
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.70",
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.70",
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,18 +2,26 @@
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
+ 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.
23
+ Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
24
+ Stderr: [decision] log lines + ::error:: on fatal conditions.
17
25
  """
18
26
 
19
27
  from __future__ import annotations
@@ -23,19 +31,24 @@ import os
23
31
  import re
24
32
  import sys
25
33
 
26
- from asc_common import (
27
- BLOCKING_STATES,
28
- EDITABLE_STATES,
29
- TERMINAL_STATES,
30
- get_json,
31
- make_jwt,
32
- request,
33
- )
34
+ from asc_common import get_json, make_jwt, request
34
35
 
35
36
 
36
37
  SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
37
38
 
38
39
 
40
+ def _log(msg: str) -> None:
41
+ print(f"[decision] {msg}", file=sys.stderr)
42
+
43
+
44
+ def _result(decision: str, version: str, vid: str) -> dict:
45
+ return {
46
+ "decision": decision,
47
+ "versionString": version,
48
+ "appStoreVersionId": vid,
49
+ }
50
+
51
+
39
52
  def env(name: str) -> str:
40
53
  val = os.environ.get(name)
41
54
  if not val:
@@ -44,95 +57,51 @@ def env(name: str) -> str:
44
57
  return val
45
58
 
46
59
 
47
- def normalize(v: str) -> str:
48
- 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 "")
49
64
  if not m:
50
- print(
51
- f"::error::PROJECT_MARKETING_VERSION ({v!r}) is not a valid "
52
- f"semantic version (expected MAJOR.MINOR[.PATCH]).",
53
- 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])."
54
68
  )
55
- raise SystemExit(1)
56
- major, minor, patch = m.group(1), m.group(2), m.group(3) or "0"
57
- return f"{major}.{minor}.{patch}"
69
+ major = int(m.group(1))
70
+ minor = int(m.group(2))
71
+ patch = int(m.group(3) or "0")
58
72
 
73
+ new_patch = patch + 1
74
+ new_minor = minor
75
+ new_major = major
59
76
 
60
- 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])
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
84
+
85
+ return f"{new_major}.{new_minor}.{new_patch}"
65
86
 
66
87
 
67
88
  def fetch_versions(app_id: str, token: str) -> list[dict]:
68
- """Return list of {'versionString','state','id'} for IOS versions."""
69
- data = get_json(
70
- f"/apps/{app_id}/appStoreVersions",
71
- token,
72
- params={
73
- "limit": 200,
74
- "filter[platform]": "IOS",
75
- },
76
- )
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)
77
92
  out: list[dict] = []
78
93
  for item in data.get("data", []):
79
94
  attrs = item.get("attributes") or {}
80
- out.append(
81
- {
82
- "versionString": attrs.get("versionString") or "",
83
- "state": attrs.get("appStoreState") or "",
84
- "id": item.get("id") or "",
85
- }
86
- )
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
+ })
87
101
  return out
88
102
 
89
103
 
90
- def highest_terminal(versions: list[dict]) -> tuple[int, int, int] | None:
91
- best: tuple[int, int, int] | None = None
92
- 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:
97
- continue
98
- t = to_tuple(v["versionString"])
99
- if best is None or t > best:
100
- best = t
101
- return best
102
-
103
-
104
- def fail_terminal(v: str, state: str) -> None:
105
- print(
106
- f"::error::Version {v} is already {state} on App Store Connect. "
107
- f"Bump MARKETING_VERSION in the Xcode project to publish a new "
108
- f"release.",
109
- file=sys.stderr,
110
- )
111
- raise SystemExit(1)
112
-
113
-
114
- def fail_blocking(v: str) -> None:
115
- print(
116
- f"::error::Version {v} is PENDING_DEVELOPER_RELEASE on App Store "
117
- f"Connect. Release it manually (or bump MARKETING_VERSION in the "
118
- f"Xcode project), then retry.",
119
- file=sys.stderr,
120
- )
121
- raise SystemExit(1)
122
-
123
-
124
- def fail_not_higher(v: str, published: tuple[int, int, int]) -> None:
125
- p = ".".join(str(x) for x in published)
126
- print(
127
- f"::error::MARKETING_VERSION ({v}) is not higher than the published "
128
- f"version ({p}) on App Store Connect. Bump MARKETING_VERSION in the "
129
- f"Xcode project.",
130
- file=sys.stderr,
131
- )
132
- raise SystemExit(1)
133
-
134
-
135
- def create_version(app_id: str, version: str, token: str) -> str:
104
+ def create_version(app_id: str, version: str, token: str):
136
105
  body = {
137
106
  "data": {
138
107
  "type": "appStoreVersions",
@@ -146,40 +115,32 @@ def create_version(app_id: str, version: str, token: str) -> str:
146
115
  },
147
116
  }
148
117
  }
149
- resp = request(
150
- "POST",
151
- "/appStoreVersions",
152
- token,
153
- json_body=body,
154
- allow_status={409},
118
+ return request(
119
+ "POST", "/appStoreVersions", token,
120
+ json_body=body, allow_status={409},
155
121
  )
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"])
173
- raise SystemExit(
174
- f"POST /appStoreVersions returned 409 but no matching version "
175
- f"{version} was found on re-query:\n{resp.text[:500]}"
176
- )
177
- new_id = resp.json()["data"]["id"]
122
+
123
+
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.
178
132
  print(
179
- f"[decision] CREATE {version} -> appStoreVersion id={new_id}",
133
+ f"POST /appStoreVersions returned 409 for {version}; "
134
+ "re-fetching to locate the existing slot.",
180
135
  file=sys.stderr,
181
136
  )
182
- return 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
+ )
183
144
 
184
145
 
185
146
  def main() -> None:
@@ -187,43 +148,48 @@ def main() -> None:
187
148
  issuer_id = env("ASC_ISSUER_ID")
188
149
  key_path = env("ASC_KEY_PATH")
189
150
  app_id = env("APP_STORE_APPLE_ID")
190
- project_version = normalize(env("PROJECT_MARKETING_VERSION"))
191
151
 
192
152
  token = make_jwt(key_id, issuer_id, key_path)
193
153
  versions = fetch_versions(app_id, token)
194
- match = next(
195
- (v for v in versions if v["versionString"] == project_version), None
196
- )
197
154
 
198
- if match and match["state"] in EDITABLE_STATES:
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":
199
171
  print(
200
- f"[decision] REUSE {project_version} (state={match['state']}, "
201
- f"id={match['id']})",
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.",
202
175
  file=sys.stderr,
203
176
  )
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
- }
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
225
189
 
226
- print(json.dumps(result))
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)))
227
193
 
228
194
 
229
195
  if __name__ == "__main__":