@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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.83"
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.83",
15
+ "version": "0.10.84",
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.83",
3
+ "version": "0.10.84",
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.83",
3
+ "version": "0.10.84",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -2,22 +2,33 @@
2
2
  """
3
3
  Resolve the App Store Connect version slot for this run.
4
4
 
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.
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 get_json, make_jwt, request
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 "M.m" or "M.m.p"."""
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::Latest App Store versionString {current_version!r} is "
67
- f"not a valid semantic version (expected MAJOR.MINOR[.PATCH])."
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. Mirrors gowalk-step: no sort, no
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 main() -> None:
147
- key_id = env("ASC_KEY_ID")
148
- issuer_id = env("ASC_ISSUER_ID")
149
- key_path = env("ASC_KEY_PATH")
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
- # 1. First ever release.
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
- print(json.dumps(_result("CREATE", new_version, new_id)))
161
- return
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
- # 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"]
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
- # 3. Blocking: pending developer release.
170
- if state == "PENDING_DEVELOPER_RELEASE":
208
+ if blocking:
209
+ target = _pick_highest_semver(blocking)
171
210
  print(
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.",
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
- # 4. Live: auto-bump to next version.
180
- if state == "READY_FOR_SALE":
181
- new_version = calculate_next_version(version_string)
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={version_string}, "
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
- print(json.dumps(_result("CREATE", new_version, new_id)))
188
- return
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
- # 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)))
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
- APP_STORE_WHATS_NEW -- release notes text (empty = skip)
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 = os.environ.get("APP_STORE_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("APP_STORE_WHATS_NEW empty; skipping.")
137
+ _log("whatsNew empty; skipping.")
108
138
  return 0
109
139
 
110
140
  version = _require_env("MARKETING_VERSION")