@daemux/store-automator 0.10.72 → 0.10.73

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.72"
8
+ "version": "0.10.73"
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.72",
15
+ "version": "0.10.73",
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.72",
3
+ "version": "0.10.73",
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.72",
3
+ "version": "0.10.73",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Set the App Store "What's New" text (appStoreVersionLocalizations.whatsNew) for
4
+ the draft App Store version slot prepared by manage_marketing_version.py.
5
+
6
+ Mirrors gowalk-step's `fastlane run set_changelog` behavior against the ASC
7
+ REST API (no fastlane dependency here):
8
+
9
+ * Uses the pre-resolved APP_STORE_VERSION_ID env var from the
10
+ "Resolve App Store version slot" step -- no polling, no lookup by version.
11
+ * Skips the first release (1.0 / 1.0.0 / 0.0 / 0.0.0) -- there are no prior
12
+ release notes to announce.
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.
16
+
17
+ Non-fatal by design: any failure is reported as a ::warning:: and the action
18
+ continues. The TestFlight upload itself has already succeeded by this point.
19
+
20
+ Environment:
21
+ ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
22
+ MARKETING_VERSION -- e.g. "1.2.3"
23
+ 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"
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import sys
32
+
33
+ from asc_common import get_json, make_jwt, request
34
+
35
+
36
+ # gowalk-step parity: skip whatsNew for the initial release.
37
+ SKIP_VERSIONS = {"1.0", "1.0.0", "0.0", "0.0.0"}
38
+
39
+ # Apple permits editing appStoreVersionLocalizations.whatsNew only while the
40
+ # version is in an editable state. WAITING_FOR_REVIEW / IN_REVIEW /
41
+ # READY_FOR_SALE etc. are not editable -- skip non-fatally.
42
+ WHATSNEW_EDITABLE_STATES = {
43
+ "PREPARE_FOR_SUBMISSION",
44
+ "REJECTED",
45
+ "METADATA_REJECTED",
46
+ "DEVELOPER_REJECTED",
47
+ "INVALID_BINARY",
48
+ }
49
+
50
+
51
+ def _require_env(name: str) -> str:
52
+ value = os.environ.get(name, "").strip()
53
+ if not value:
54
+ print(f"::error::{name} env var is required", file=sys.stderr)
55
+ raise SystemExit(1)
56
+ return value
57
+
58
+
59
+ def _warn(msg: str) -> None:
60
+ print(f"::warning::{msg}", file=sys.stderr)
61
+
62
+
63
+ def _log(msg: str) -> None:
64
+ print(msg, file=sys.stderr)
65
+
66
+
67
+ def _patch_localization(token: str, localization_id: str, whats_new: str) -> None:
68
+ request(
69
+ "PATCH",
70
+ f"/appStoreVersionLocalizations/{localization_id}",
71
+ token,
72
+ json_body={
73
+ "data": {
74
+ "type": "appStoreVersionLocalizations",
75
+ "id": localization_id,
76
+ "attributes": {"whatsNew": whats_new},
77
+ }
78
+ },
79
+ )
80
+
81
+
82
+ def _create_localization(
83
+ token: str, version_id: str, locale: str, whats_new: str
84
+ ) -> str:
85
+ resp = request(
86
+ "POST",
87
+ "/appStoreVersionLocalizations",
88
+ token,
89
+ json_body={
90
+ "data": {
91
+ "type": "appStoreVersionLocalizations",
92
+ "attributes": {"locale": locale, "whatsNew": whats_new},
93
+ "relationships": {
94
+ "appStoreVersion": {
95
+ "data": {"type": "appStoreVersions", "id": version_id}
96
+ }
97
+ },
98
+ }
99
+ },
100
+ )
101
+ return resp.json()["data"]["id"]
102
+
103
+
104
+ def main() -> int:
105
+ whats_new = os.environ.get("APP_STORE_WHATS_NEW", "")
106
+ if not whats_new.strip():
107
+ _log("APP_STORE_WHATS_NEW empty; skipping.")
108
+ return 0
109
+
110
+ version = _require_env("MARKETING_VERSION")
111
+ version_id = _require_env("APP_STORE_VERSION_ID")
112
+ locale = os.environ.get("APP_STORE_LOCALE", "").strip() or "en-US"
113
+
114
+ if version in SKIP_VERSIONS:
115
+ _log(f"Skipping whatsNew for first release {version}.")
116
+ return 0
117
+
118
+ token = make_jwt(
119
+ _require_env("ASC_KEY_ID"),
120
+ _require_env("ASC_ISSUER_ID"),
121
+ _require_env("ASC_KEY_PATH"),
122
+ )
123
+
124
+ ver = get_json(f"/appStoreVersions/{version_id}", token)
125
+ state = (ver.get("data") or {}).get("attributes", {}).get("appStoreState", "")
126
+ if state not in WHATSNEW_EDITABLE_STATES:
127
+ _warn(
128
+ f"appStoreVersion {version} is {state}; whatsNew not editable in "
129
+ f"this state, skipping."
130
+ )
131
+ return 0
132
+
133
+ locs = get_json(
134
+ f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
135
+ )
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
+ )
157
+ return 0
158
+
159
+
160
+ if __name__ == "__main__":
161
+ try:
162
+ sys.exit(main())
163
+ except SystemExit:
164
+ raise
165
+ except Exception as exc: # non-fatal per gowalk-step's set_changelog parity
166
+ _warn(f"whatsNew setter failed (non-fatal): {exc!r}")
167
+ sys.exit(0)
@@ -1,146 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Set TestFlight "What to Test" (betaBuildLocalization.whatsNew) for the build
4
- just uploaded by this workflow.
5
-
6
- Runs after the altool upload step. Polls ASC until the build record appears
7
- (altool upload returns before Apple has fully ingested the build), then either
8
- PATCHes an existing betaBuildLocalization for the requested locale or POSTs a
9
- new one.
10
-
11
- Environment:
12
- ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
13
- APP_STORE_APPLE_ID -- numeric ASC app id
14
- MARKETING_VERSION -- e.g. "1.2.3"
15
- BUILD_NUMBER -- e.g. "42"
16
- TESTFLIGHT_WHATS_NEW -- release notes text (empty = skip)
17
- TESTFLIGHT_LOCALE -- locale code, default "en-US"
18
-
19
- Non-fatal by design: if the build can't be found within the polling window we
20
- emit a ::warning:: and exit 0, since the TestFlight upload itself may still
21
- have succeeded.
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import os
27
- import sys
28
- import time
29
-
30
- from asc_common import get_json, make_jwt, request
31
-
32
-
33
- POLL_ATTEMPTS = 20
34
- POLL_FIRST_DELAY = 10
35
- POLL_DELAY = 30
36
-
37
-
38
- def _require_env(name: str) -> str:
39
- value = os.environ.get(name, "").strip()
40
- if not value:
41
- print(f"::error::{name} env var is required", file=sys.stderr)
42
- raise SystemExit(1)
43
- return value
44
-
45
-
46
- def _find_build(token: str, app_id: str, marketing_version: str, build_number: str) -> str | None:
47
- """Return the build id matching (marketing_version, build_number), or None."""
48
- params = {
49
- "filter[app]": app_id,
50
- "filter[preReleaseVersion.version]": marketing_version,
51
- "filter[version]": build_number,
52
- "limit": "5",
53
- }
54
- data = get_json("/builds", token, params=params)
55
- items = data.get("data") or []
56
- return items[0].get("id") if items else None
57
-
58
-
59
- def _poll_for_build(token: str, app_id: str, marketing_version: str, build_number: str) -> str | None:
60
- """Poll ASC for the uploaded build. Returns build id or None on timeout."""
61
- for attempt in range(POLL_ATTEMPTS):
62
- delay = POLL_FIRST_DELAY if attempt == 0 else POLL_DELAY
63
- print(
64
- f"Waiting {delay}s for build {marketing_version} ({build_number}) to appear "
65
- f"(attempt {attempt + 1}/{POLL_ATTEMPTS})",
66
- file=sys.stderr,
67
- )
68
- time.sleep(delay)
69
- build_id = _find_build(token, app_id, marketing_version, build_number)
70
- if build_id:
71
- print(f"Found build id {build_id}", file=sys.stderr)
72
- return build_id
73
- return None
74
-
75
-
76
- def _existing_localization(token: str, build_id: str, locale: str) -> str | None:
77
- """Return the betaBuildLocalization id for `locale` on this build, or None."""
78
- data = get_json(f"/builds/{build_id}/betaBuildLocalizations", token)
79
- for item in data.get("data") or []:
80
- if (item.get("attributes") or {}).get("locale") == locale:
81
- return item.get("id")
82
- return None
83
-
84
-
85
- def _patch_localization(token: str, localization_id: str, whats_new: str) -> None:
86
- body = {
87
- "data": {
88
- "type": "betaBuildLocalizations",
89
- "id": localization_id,
90
- "attributes": {"whatsNew": whats_new},
91
- }
92
- }
93
- request("PATCH", f"/betaBuildLocalizations/{localization_id}", token, json_body=body)
94
-
95
-
96
- def _create_localization(token: str, build_id: str, locale: str, whats_new: str) -> None:
97
- body = {
98
- "data": {
99
- "type": "betaBuildLocalizations",
100
- "attributes": {"locale": locale, "whatsNew": whats_new},
101
- "relationships": {
102
- "build": {"data": {"type": "builds", "id": build_id}},
103
- },
104
- }
105
- }
106
- request("POST", "/betaBuildLocalizations", token, json_body=body)
107
-
108
-
109
- def main() -> int:
110
- whats_new = os.environ.get("TESTFLIGHT_WHATS_NEW", "")
111
- if not whats_new.strip():
112
- print("empty whatsNew; skipping", file=sys.stderr)
113
- return 0
114
-
115
- key_id = _require_env("ASC_KEY_ID")
116
- issuer_id = _require_env("ASC_ISSUER_ID")
117
- key_path = _require_env("ASC_KEY_PATH")
118
- app_id = _require_env("APP_STORE_APPLE_ID")
119
- marketing_version = _require_env("MARKETING_VERSION")
120
- build_number = _require_env("BUILD_NUMBER")
121
- locale = os.environ.get("TESTFLIGHT_LOCALE", "").strip() or "en-US"
122
-
123
- token = make_jwt(key_id, issuer_id, key_path)
124
-
125
- build_id = _poll_for_build(token, app_id, marketing_version, build_number)
126
- if not build_id:
127
- print(
128
- f"::warning::build {marketing_version} ({build_number}) not found in ASC "
129
- f"after {POLL_ATTEMPTS} polls; skipping whatsNew update",
130
- file=sys.stderr,
131
- )
132
- return 0
133
-
134
- existing = _existing_localization(token, build_id, locale)
135
- if existing:
136
- _patch_localization(token, existing, whats_new)
137
- action = "updated"
138
- else:
139
- _create_localization(token, build_id, locale, whats_new)
140
- action = "created"
141
- print(f"Set betaBuildLocalization whatsNew for build {build_id} locale {locale} ({action})")
142
- return 0
143
-
144
-
145
- if __name__ == "__main__":
146
- sys.exit(main())