@daemux/store-automator 0.10.83 → 0.10.84
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/scripts/ci/ios-native/manage_marketing_version.py +119 -55
- package/templates/scripts/ci/ios-native/read_config.py +23 -1
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +33 -3
|
@@ -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.84"
|
|
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.84",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -2,22 +2,33 @@
|
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
5
|
+
CI -- not the Xcode project -- decides the marketing version. The archive
|
|
6
|
+
step overrides the pbxproj MARKETING_VERSION at build time via
|
|
7
|
+
`MARKETING_VERSION=...` on the xcodebuild command line.
|
|
8
|
+
|
|
9
|
+
Algorithm (improvement over gowalk-step's "latest by createdDate"):
|
|
10
|
+
1. Fetch all appStoreVersions for the app.
|
|
11
|
+
2. Partition by state:
|
|
12
|
+
editable (PREPARE_FOR_SUBMISSION, REJECTED, METADATA_REJECTED,
|
|
13
|
+
DEVELOPER_REJECTED, INVALID_BINARY, WAITING_FOR_REVIEW,
|
|
14
|
+
IN_REVIEW)
|
|
15
|
+
blocking (PENDING_DEVELOPER_RELEASE)
|
|
16
|
+
terminal (READY_FOR_SALE, PROCESSING_FOR_APP_STORE,
|
|
17
|
+
PENDING_APPLE_RELEASE, REPLACED_WITH_NEW_VERSION,
|
|
18
|
+
REMOVED_FROM_SALE, NOT_APPLICABLE)
|
|
19
|
+
3. If editable: REUSE the HIGHEST SEMVER among editable. Picking the
|
|
20
|
+
highest semver (not the latest createdDate) matches how Apple's UI
|
|
21
|
+
presents the "next" version and avoids the "later version closed"
|
|
22
|
+
error Apple returns on uploads to a lower editable version when a
|
|
23
|
+
higher one already exists.
|
|
24
|
+
4. Elif blocking: fail with a clear error -- a human must release the
|
|
25
|
+
pending version manually before CI can proceed.
|
|
26
|
+
5. Elif terminal: pick the highest-semver terminal version.
|
|
27
|
+
- READY_FOR_SALE -> CREATE the next version via rollover bump.
|
|
28
|
+
- anything else -> fail (manual intervention required; state is
|
|
29
|
+
neither editable nor live).
|
|
30
|
+
6. No versions at all -> first release, CREATE "1.0.0".
|
|
31
|
+
7. 409 on CREATE (race) -> re-fetch, match by versionString, REUSE.
|
|
21
32
|
|
|
22
33
|
Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID.
|
|
23
34
|
Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
|
|
@@ -31,7 +42,14 @@ import os
|
|
|
31
42
|
import re
|
|
32
43
|
import sys
|
|
33
44
|
|
|
34
|
-
from asc_common import
|
|
45
|
+
from asc_common import (
|
|
46
|
+
BLOCKING_STATES,
|
|
47
|
+
EDITABLE_STATES,
|
|
48
|
+
TERMINAL_STATES,
|
|
49
|
+
get_json,
|
|
50
|
+
make_jwt,
|
|
51
|
+
request,
|
|
52
|
+
)
|
|
35
53
|
|
|
36
54
|
|
|
37
55
|
SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
|
|
@@ -57,14 +75,23 @@ def env(name: str) -> str:
|
|
|
57
75
|
return val
|
|
58
76
|
|
|
59
77
|
|
|
78
|
+
def semver_tuple(version_string: str) -> tuple[int, int, int]:
|
|
79
|
+
"""Parse 'M.m[.p]' -> (M, m, p). Non-semver returns (-1,-1,-1) so it
|
|
80
|
+
sorts below any real version."""
|
|
81
|
+
m = SEM_RE.match(version_string or "")
|
|
82
|
+
if not m:
|
|
83
|
+
return (-1, -1, -1)
|
|
84
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or "0"))
|
|
85
|
+
|
|
86
|
+
|
|
60
87
|
def calculate_next_version(current_version: str) -> str:
|
|
61
88
|
"""Rollover bump: patch+1; patch>=10 resets to 0 and minor+1;
|
|
62
|
-
minor>=10 resets both to 0 and major+1. Accepts
|
|
89
|
+
minor>=10 resets both to 0 and major+1. Accepts 'M.m' or 'M.m.p'."""
|
|
63
90
|
m = SEM_RE.match(current_version or "")
|
|
64
91
|
if not m:
|
|
65
92
|
raise SystemExit(
|
|
66
|
-
f"::error::
|
|
67
|
-
f"
|
|
93
|
+
f"::error::App Store versionString {current_version!r} is not a "
|
|
94
|
+
f"valid semantic version (expected MAJOR.MINOR[.PATCH])."
|
|
68
95
|
)
|
|
69
96
|
major = int(m.group(1))
|
|
70
97
|
minor = int(m.group(2))
|
|
@@ -86,8 +113,7 @@ def calculate_next_version(current_version: str) -> str:
|
|
|
86
113
|
|
|
87
114
|
|
|
88
115
|
def fetch_versions(app_id: str, token: str) -> list[dict]:
|
|
89
|
-
"""All appStoreVersions for the app
|
|
90
|
-
limit, no platform filter -- we do client-side selection."""
|
|
116
|
+
"""All appStoreVersions for the app (no sort/limit/filter)."""
|
|
91
117
|
data = get_json(f"/apps/{app_id}/appStoreVersions", token)
|
|
92
118
|
out: list[dict] = []
|
|
93
119
|
for item in data.get("data", []):
|
|
@@ -122,13 +148,11 @@ def create_version(app_id: str, version: str, token: str):
|
|
|
122
148
|
|
|
123
149
|
|
|
124
150
|
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."""
|
|
151
|
+
"""POST a new version; on 409 race, re-fetch and return existing id."""
|
|
127
152
|
resp = create_version(app_id, version, token)
|
|
128
153
|
if resp.status_code != 409:
|
|
129
154
|
return resp.json()["data"]["id"]
|
|
130
155
|
|
|
131
|
-
# Race: someone else created it between our GET and POST. Find by name.
|
|
132
156
|
print(
|
|
133
157
|
f"POST /appStoreVersions returned 409 for {version}; "
|
|
134
158
|
"re-fetching to locate the existing slot.",
|
|
@@ -143,53 +167,93 @@ def create_or_reuse(app_id: str, version: str, token: str) -> str:
|
|
|
143
167
|
)
|
|
144
168
|
|
|
145
169
|
|
|
146
|
-
def
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
app_id = env("APP_STORE_APPLE_ID")
|
|
170
|
+
def _summarize(versions: list[dict]) -> str:
|
|
171
|
+
return ", ".join(
|
|
172
|
+
f"{v['versionString']}({v['state']})" for v in versions
|
|
173
|
+
) or "<none>"
|
|
151
174
|
|
|
152
|
-
token = make_jwt(key_id, issuer_id, key_path)
|
|
153
|
-
versions = fetch_versions(app_id, token)
|
|
154
175
|
|
|
155
|
-
|
|
176
|
+
def _pick_highest_semver(versions: list[dict]) -> dict:
|
|
177
|
+
"""Tiebreak by createdDate (newer wins) when semvers collide."""
|
|
178
|
+
return max(
|
|
179
|
+
versions,
|
|
180
|
+
key=lambda v: (semver_tuple(v["versionString"]), v.get("createdDate", "")),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def decide(versions: list[dict], app_id: str, token: str) -> dict:
|
|
156
185
|
if not versions:
|
|
157
186
|
new_version = "1.0.0"
|
|
158
187
|
new_id = create_or_reuse(app_id, new_version, token)
|
|
159
188
|
_log(f"CREATE {new_version} (first release) -> appStoreVersion id={new_id}")
|
|
160
|
-
|
|
161
|
-
|
|
189
|
+
return _result("CREATE", new_version, new_id)
|
|
190
|
+
|
|
191
|
+
editable = [v for v in versions if v["state"] in EDITABLE_STATES]
|
|
192
|
+
blocking = [v for v in versions if v["state"] in BLOCKING_STATES]
|
|
193
|
+
terminal = [v for v in versions if v["state"] in TERMINAL_STATES]
|
|
194
|
+
_log(
|
|
195
|
+
f"editable=[{_summarize(editable)}] "
|
|
196
|
+
f"blocking=[{_summarize(blocking)}] "
|
|
197
|
+
f"terminal=[{_summarize(terminal)}]"
|
|
198
|
+
)
|
|
162
199
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
200
|
+
if editable:
|
|
201
|
+
target = _pick_highest_semver(editable)
|
|
202
|
+
_log(
|
|
203
|
+
f"REUSE {target['versionString']} (highest-semver editable, "
|
|
204
|
+
f"state={target['state']}, id={target['id']})"
|
|
205
|
+
)
|
|
206
|
+
return _result("REUSE", target["versionString"], target["id"])
|
|
168
207
|
|
|
169
|
-
|
|
170
|
-
|
|
208
|
+
if blocking:
|
|
209
|
+
target = _pick_highest_semver(blocking)
|
|
171
210
|
print(
|
|
172
|
-
f"::error::
|
|
173
|
-
f"
|
|
174
|
-
f"then retry.",
|
|
211
|
+
f"::error::App Store version {target['versionString']} is "
|
|
212
|
+
f"{target['state']}. Release it in App Store Connect, then retry.",
|
|
175
213
|
file=sys.stderr,
|
|
176
214
|
)
|
|
177
215
|
raise SystemExit(1)
|
|
178
216
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
217
|
+
if terminal:
|
|
218
|
+
target = _pick_highest_semver(terminal)
|
|
219
|
+
if target["state"] != "READY_FOR_SALE":
|
|
220
|
+
print(
|
|
221
|
+
f"::error::Highest App Store version {target['versionString']} "
|
|
222
|
+
f"is {target['state']}; neither editable nor live. Manual "
|
|
223
|
+
f"intervention required in App Store Connect.",
|
|
224
|
+
file=sys.stderr,
|
|
225
|
+
)
|
|
226
|
+
raise SystemExit(1)
|
|
227
|
+
new_version = calculate_next_version(target["versionString"])
|
|
182
228
|
new_id = create_or_reuse(app_id, new_version, token)
|
|
183
229
|
_log(
|
|
184
|
-
f"CREATE {new_version} (previous={
|
|
185
|
-
f"previous_state={state}) -> appStoreVersion id={new_id}"
|
|
230
|
+
f"CREATE {new_version} (previous={target['versionString']}, "
|
|
231
|
+
f"previous_state={target['state']}) -> appStoreVersion id={new_id}"
|
|
186
232
|
)
|
|
187
|
-
|
|
188
|
-
|
|
233
|
+
return _result("CREATE", new_version, new_id)
|
|
234
|
+
|
|
235
|
+
# Unknown state (not editable, not blocking, not terminal) -- refuse to
|
|
236
|
+
# guess. Lists both states in the error so ops can update the classifier.
|
|
237
|
+
unknown = ", ".join(f"{v['versionString']}({v['state']})" for v in versions)
|
|
238
|
+
print(
|
|
239
|
+
f"::error::No editable/blocking/terminal App Store versions found; "
|
|
240
|
+
f"all versions have unrecognized states: {unknown}. Update "
|
|
241
|
+
f"asc_common.EDITABLE_STATES / TERMINAL_STATES / BLOCKING_STATES.",
|
|
242
|
+
file=sys.stderr,
|
|
243
|
+
)
|
|
244
|
+
raise SystemExit(1)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main() -> None:
|
|
248
|
+
key_id = env("ASC_KEY_ID")
|
|
249
|
+
issuer_id = env("ASC_ISSUER_ID")
|
|
250
|
+
key_path = env("ASC_KEY_PATH")
|
|
251
|
+
app_id = env("APP_STORE_APPLE_ID")
|
|
189
252
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
253
|
+
token = make_jwt(key_id, issuer_id, key_path)
|
|
254
|
+
versions = fetch_versions(app_id, token)
|
|
255
|
+
result = decide(versions, app_id, token)
|
|
256
|
+
print(json.dumps(result))
|
|
193
257
|
|
|
194
258
|
|
|
195
259
|
if __name__ == "__main__":
|
|
@@ -38,8 +38,14 @@ Writes to $GITHUB_ENV:
|
|
|
38
38
|
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8_PATH, ASC_KEY_P8,
|
|
39
39
|
CFG_PROJECT, CFG_WORKSPACE, CFG_SCHEME, CFG_CONFIGURATION,
|
|
40
40
|
CFG_BUNDLE_ID, CFG_TEAM_ID, CFG_APP_STORE_APPLE_ID, CFG_PROFILE_NAME,
|
|
41
|
-
CFG_USES_NON_EXEMPT, CFG_WHATS_NEW, CFG_LOCALE,
|
|
41
|
+
CFG_USES_NON_EXEMPT, CFG_WHATS_NEW, CFG_WHATS_NEW_FILE, CFG_LOCALE,
|
|
42
42
|
CFG_RUN_TESTS, CFG_TEST_COMMAND, CFG_TEST_DESTINATION
|
|
43
|
+
|
|
44
|
+
CFG_WHATS_NEW_FILE points at a file under $RUNNER_TEMP holding the raw
|
|
45
|
+
multi-line release notes. Downstream steps MUST prefer the file over the
|
|
46
|
+
env-var copy: GitHub Actions YAML's `${{ }}` substitution serializes
|
|
47
|
+
multi-line strings in ways that can mangle embedded newlines, while a file
|
|
48
|
+
path is a single-line string that survives interpolation untouched.
|
|
43
49
|
"""
|
|
44
50
|
|
|
45
51
|
from __future__ import annotations
|
|
@@ -284,6 +290,22 @@ def main() -> None:
|
|
|
284
290
|
shown = value if name != "CFG_WHATS_NEW" else value.replace("\n", " / ")
|
|
285
291
|
log(f"{name}={shown!r} (source: {src})")
|
|
286
292
|
|
|
293
|
+
# Persist whatsNew to a file under $RUNNER_TEMP so downstream steps can
|
|
294
|
+
# read raw multi-line content without any GitHub Actions ${{ }} YAML
|
|
295
|
+
# interpolation mangling embedded newlines. CFG_WHATS_NEW still carries
|
|
296
|
+
# the same content for backward compatibility; the file is the source
|
|
297
|
+
# of truth.
|
|
298
|
+
whats_new_value = tf["whats_new"][0]
|
|
299
|
+
runner_temp = os.environ.get("RUNNER_TEMP") or str(workspace)
|
|
300
|
+
whats_new_path = Path(runner_temp) / "whats_new.txt"
|
|
301
|
+
whats_new_path.write_text(whats_new_value)
|
|
302
|
+
emit(env_file, "CFG_WHATS_NEW_FILE", str(whats_new_path))
|
|
303
|
+
log(
|
|
304
|
+
f"CFG_WHATS_NEW_FILE={whats_new_path} "
|
|
305
|
+
f"(length={len(whats_new_value)}, "
|
|
306
|
+
f"newlines={whats_new_value.count(chr(10))})"
|
|
307
|
+
)
|
|
308
|
+
|
|
287
309
|
|
|
288
310
|
if __name__ == "__main__":
|
|
289
311
|
main()
|
|
@@ -21,7 +21,12 @@ Environment:
|
|
|
21
21
|
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
|
|
22
22
|
MARKETING_VERSION -- e.g. "1.2.3"
|
|
23
23
|
APP_STORE_VERSION_ID -- appStoreVersion id (REUSE or CREATE)
|
|
24
|
-
|
|
24
|
+
APP_STORE_WHATS_NEW_FILE -- path to file holding release notes
|
|
25
|
+
(preferred; sidesteps any YAML env
|
|
26
|
+
interpolation that may mangle
|
|
27
|
+
multi-line content)
|
|
28
|
+
APP_STORE_WHATS_NEW -- release notes text (fallback when
|
|
29
|
+
_FILE is unset / unreadable)
|
|
25
30
|
APP_STORE_LOCALE -- locale code, default "en-US"
|
|
26
31
|
"""
|
|
27
32
|
|
|
@@ -29,6 +34,7 @@ from __future__ import annotations
|
|
|
29
34
|
|
|
30
35
|
import os
|
|
31
36
|
import sys
|
|
37
|
+
from pathlib import Path
|
|
32
38
|
|
|
33
39
|
from asc_common import get_json, make_jwt, request
|
|
34
40
|
|
|
@@ -101,10 +107,34 @@ def _create_localization(
|
|
|
101
107
|
return resp.json()["data"]["id"]
|
|
102
108
|
|
|
103
109
|
|
|
110
|
+
def _read_whats_new() -> tuple[str, str]:
|
|
111
|
+
"""Return (content, source). Prefer a file path (no env interpolation
|
|
112
|
+
risk). Fall back to APP_STORE_WHATS_NEW env var. Returns ("", "<empty>")
|
|
113
|
+
when neither is populated."""
|
|
114
|
+
path = os.environ.get("APP_STORE_WHATS_NEW_FILE", "").strip()
|
|
115
|
+
if path:
|
|
116
|
+
try:
|
|
117
|
+
content = Path(path).read_text()
|
|
118
|
+
return content, f"file:{path}"
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
_warn(
|
|
121
|
+
f"APP_STORE_WHATS_NEW_FILE={path} unreadable ({exc!r}); "
|
|
122
|
+
f"falling back to APP_STORE_WHATS_NEW env var."
|
|
123
|
+
)
|
|
124
|
+
content = os.environ.get("APP_STORE_WHATS_NEW", "")
|
|
125
|
+
if content:
|
|
126
|
+
return content, "env:APP_STORE_WHATS_NEW"
|
|
127
|
+
return "", "<empty>"
|
|
128
|
+
|
|
129
|
+
|
|
104
130
|
def main() -> int:
|
|
105
|
-
whats_new =
|
|
131
|
+
whats_new, source = _read_whats_new()
|
|
132
|
+
_log(
|
|
133
|
+
f"whatsNew source={source} length={len(whats_new)} "
|
|
134
|
+
f"newlines={whats_new.count(chr(10))}"
|
|
135
|
+
)
|
|
106
136
|
if not whats_new.strip():
|
|
107
|
-
_log("
|
|
137
|
+
_log("whatsNew empty; skipping.")
|
|
108
138
|
return 0
|
|
109
139
|
|
|
110
140
|
version = _require_env("MARKETING_VERSION")
|