@daemux/store-automator 0.10.83 → 0.10.85

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.85"
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.85",
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.85",
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.85",
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()
@@ -11,8 +11,12 @@ REST API (no fastlane dependency here):
11
11
  * Skips the first release (1.0 / 1.0.0 / 0.0 / 0.0.0) -- there are no prior
12
12
  release notes to announce.
13
13
  * Skips when the slot is not in a state that permits editing whatsNew.
14
- * Either PATCHes the existing localization for the requested locale or POSTs
15
- a new one pointing at the appStoreVersion.
14
+ * PATCHes whatsNew on EVERY appStoreVersionLocalization entry with the
15
+ same text. gowalk-step's set_changelog iterates every localization
16
+ (not just the default one) so users in every language see the release
17
+ notes rather than an empty "What's New" section when their locale
18
+ isn't en-US. If no localizations exist yet (brand-new version slot),
19
+ POST a single one for APP_STORE_LOCALE (default en-US) as a seed.
16
20
 
17
21
  Non-fatal by design: any failure is reported as a ::warning:: and the action
18
22
  continues. The TestFlight upload itself has already succeeded by this point.
@@ -21,14 +25,24 @@ Environment:
21
25
  ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
22
26
  MARKETING_VERSION -- e.g. "1.2.3"
23
27
  APP_STORE_VERSION_ID -- appStoreVersion id (REUSE or CREATE)
24
- APP_STORE_WHATS_NEW -- release notes text (empty = skip)
25
- APP_STORE_LOCALE -- locale code, default "en-US"
28
+ APP_STORE_WHATS_NEW_FILE -- path to file holding release notes
29
+ (preferred; sidesteps any YAML env
30
+ interpolation that may mangle
31
+ multi-line content)
32
+ APP_STORE_WHATS_NEW -- release notes text (fallback when
33
+ _FILE is unset / unreadable)
34
+ APP_STORE_LOCALE -- locale to seed when no
35
+ localizations exist; default
36
+ "en-US". Ignored when
37
+ localizations already exist --
38
+ every existing locale is updated.
26
39
  """
27
40
 
28
41
  from __future__ import annotations
29
42
 
30
43
  import os
31
44
  import sys
45
+ from pathlib import Path
32
46
 
33
47
  from asc_common import get_json, make_jwt, request
34
48
 
@@ -101,15 +115,74 @@ def _create_localization(
101
115
  return resp.json()["data"]["id"]
102
116
 
103
117
 
118
+ def _read_whats_new() -> tuple[str, str]:
119
+ """Return (content, source). Prefer a file path (no env interpolation
120
+ risk). Fall back to APP_STORE_WHATS_NEW env var. Returns ("", "<empty>")
121
+ when neither is populated."""
122
+ path = os.environ.get("APP_STORE_WHATS_NEW_FILE", "").strip()
123
+ if path:
124
+ try:
125
+ content = Path(path).read_text()
126
+ return content, f"file:{path}"
127
+ except OSError as exc:
128
+ _warn(
129
+ f"APP_STORE_WHATS_NEW_FILE={path} unreadable ({exc!r}); "
130
+ f"falling back to APP_STORE_WHATS_NEW env var."
131
+ )
132
+ content = os.environ.get("APP_STORE_WHATS_NEW", "")
133
+ if content:
134
+ return content, "env:APP_STORE_WHATS_NEW"
135
+ return "", "<empty>"
136
+
137
+
138
+ def _update_all_localizations(
139
+ token: str, version_id: str, version: str, whats_new: str, seed_locale: str
140
+ ) -> int:
141
+ """PATCH whatsNew on every existing localization. If none exist, POST a
142
+ single seed localization in `seed_locale`. Returns the count of
143
+ localizations written."""
144
+ locs = get_json(
145
+ f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
146
+ )
147
+ entries = locs.get("data") or []
148
+
149
+ if not entries:
150
+ new_id = _create_localization(token, version_id, seed_locale, whats_new)
151
+ _log(
152
+ f"CREATEd appStoreVersionLocalization {new_id} whatsNew for "
153
+ f"{version} ({seed_locale}) -- no prior localizations"
154
+ )
155
+ return 1
156
+
157
+ count = 0
158
+ for item in entries:
159
+ loc_id = item.get("id") or ""
160
+ loc = (item.get("attributes") or {}).get("locale") or "?"
161
+ if not loc_id:
162
+ _warn(f"skipping localization without id: {item!r}")
163
+ continue
164
+ _patch_localization(token, loc_id, whats_new)
165
+ _log(
166
+ f"PATCHed appStoreVersionLocalization {loc_id} whatsNew "
167
+ f"for {version} ({loc})"
168
+ )
169
+ count += 1
170
+ return count
171
+
172
+
104
173
  def main() -> int:
105
- whats_new = os.environ.get("APP_STORE_WHATS_NEW", "")
174
+ whats_new, source = _read_whats_new()
175
+ _log(
176
+ f"whatsNew source={source} length={len(whats_new)} "
177
+ f"newlines={whats_new.count(chr(10))}"
178
+ )
106
179
  if not whats_new.strip():
107
- _log("APP_STORE_WHATS_NEW empty; skipping.")
180
+ _log("whatsNew empty; skipping.")
108
181
  return 0
109
182
 
110
183
  version = _require_env("MARKETING_VERSION")
111
184
  version_id = _require_env("APP_STORE_VERSION_ID")
112
- locale = os.environ.get("APP_STORE_LOCALE", "").strip() or "en-US"
185
+ seed_locale = os.environ.get("APP_STORE_LOCALE", "").strip() or "en-US"
113
186
 
114
187
  if version in SKIP_VERSIONS:
115
188
  _log(f"Skipping whatsNew for first release {version}.")
@@ -130,30 +203,10 @@ def main() -> int:
130
203
  )
131
204
  return 0
132
205
 
133
- locs = get_json(
134
- f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
206
+ count = _update_all_localizations(
207
+ token, version_id, version, whats_new, seed_locale
135
208
  )
136
- existing = next(
137
- (
138
- item
139
- for item in (locs.get("data") or [])
140
- if (item.get("attributes") or {}).get("locale") == locale
141
- ),
142
- None,
143
- )
144
-
145
- if existing:
146
- _patch_localization(token, existing["id"], whats_new)
147
- _log(
148
- f"PATCHed appStoreVersionLocalization {existing['id']} whatsNew "
149
- f"for {version} ({locale})"
150
- )
151
- else:
152
- new_id = _create_localization(token, version_id, locale, whats_new)
153
- _log(
154
- f"CREATEd appStoreVersionLocalization {new_id} whatsNew for "
155
- f"{version} ({locale})"
156
- )
209
+ _log(f"whatsNew set for {count} localizations on {version}")
157
210
  return 0
158
211
 
159
212