@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.
|
|
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.
|
|
15
|
+
"version": "0.10.71",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Resolve the App Store Connect version slot for this run.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return 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")
|
|
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
|
|
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
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
150
|
-
"POST",
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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"
|
|
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"
|
|
219
|
+
f"{label} returned 409; re-fetching and redeciding "
|
|
220
|
+
f"(depth={depth + 1})",
|
|
180
221
|
file=sys.stderr,
|
|
181
222
|
)
|
|
182
|
-
return
|
|
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
|
-
|
|
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
|
|