@daemux/store-automator 0.10.94 → 0.10.96
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/github/IOS_NATIVE_CI_SETUP.md +58 -3
- package/templates/scripts/ci/ios-native/asc_build_history.py +284 -0
- package/templates/scripts/ci/ios-native/asc_common.py +137 -34
- package/templates/scripts/ci/ios-native/asc_version_create.py +365 -0
- package/templates/scripts/ci/ios-native/asc_version_fetch.py +274 -0
- package/templates/scripts/ci/ios-native/asc_version_reuse.py +177 -0
- package/templates/scripts/ci/ios-native/autoupdate_check.sh +43 -2
- package/templates/scripts/ci/ios-native/commit_bot_changes.sh +29 -7
- package/templates/scripts/ci/ios-native/manage_marketing_version.py +269 -203
- package/templates/scripts/ci/ios-native/mmv_decide_create.py +181 -0
- package/templates/scripts/ci/ios-native/mmv_floor_check.py +260 -0
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +64 -29
- package/templates/scripts/ci/ios-native/version_utils.py +352 -0
|
@@ -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.96"
|
|
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.96",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -45,6 +45,55 @@ Subsequent builds need neither — the ASC API key handles everything.
|
|
|
45
45
|
|
|
46
46
|
Leave your Xcode project's `MARKETING_VERSION` at `1.0` initially. CI auto-rolls the marketing version forward (patch bump) whenever the prior version hits `READY_FOR_SALE` in App Store Connect. Until then, the pipeline keeps bumping the build number against the current marketing version.
|
|
47
47
|
|
|
48
|
+
### Auto-bumping MARKETING_VERSION
|
|
49
|
+
|
|
50
|
+
When the ASC combined floor (max of pending review, `preReleaseVersions`,
|
|
51
|
+
or builds-via-`preReleaseVersion`) exceeds your project's
|
|
52
|
+
`MARKETING_VERSION`, the action auto-bumps and commits the new value as
|
|
53
|
+
part of the same bot commit that handles cert refresh / autoupdate.
|
|
54
|
+
Default policy is `patch` — bumps the patch component.
|
|
55
|
+
|
|
56
|
+
**Opt out** via the action input:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
- uses: ./.github/actions/swift-app
|
|
60
|
+
with:
|
|
61
|
+
marketing-version-auto-bump: 'none'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
In `'none'` mode, the floor check fails the build and you must bump
|
|
65
|
+
`MARKETING_VERSION` manually before retrying.
|
|
66
|
+
|
|
67
|
+
**Policy `'minor'`** bumps the minor component (e.g. `1.0.5` → `1.1.0`)
|
|
68
|
+
when floor exceeds project. Useful for projects where every release
|
|
69
|
+
is a minor.
|
|
70
|
+
|
|
71
|
+
**Side effect:** the bot commit subject reflects what was changed,
|
|
72
|
+
e.g. `ci: refresh signing identity + bump MARKETING_VERSION [skip ci]`.
|
|
73
|
+
|
|
74
|
+
**Source-of-truth resolution.** The auto-bump writes the new value
|
|
75
|
+
into the file your project actually reads from, in this order:
|
|
76
|
+
|
|
77
|
+
1. **xcodegen `project.yml`** (preferred when present): regex-rewrite
|
|
78
|
+
of the `MARKETING_VERSION:` key, preserving formatting. The
|
|
79
|
+
generated `*.xcodeproj` is regenerated on every build, so editing
|
|
80
|
+
it directly would lose the bump.
|
|
81
|
+
2. **`*.xcconfig`** sitting alongside the project (top level or one
|
|
82
|
+
subdirectory deep): handles non-xcodegen projects that hoist
|
|
83
|
+
`MARKETING_VERSION` into xcconfig.
|
|
84
|
+
3. **`*.xcodeproj/project.pbxproj`**: only when no xcodegen spec is
|
|
85
|
+
present. Edits every `MARKETING_VERSION = X.Y.Z;` build setting
|
|
86
|
+
in place.
|
|
87
|
+
4. **`Info.plist` `CFBundleShortVersionString`**: last-ditch fallback
|
|
88
|
+
for projects that hardcode the version literal.
|
|
89
|
+
|
|
90
|
+
If your project uses xcodegen but `MARKETING_VERSION` is not in
|
|
91
|
+
`project.yml` or any `.xcconfig`, the auto-bump emits a `::warning::`
|
|
92
|
+
and the build fails (refusing to silently edit the generated pbxproj
|
|
93
|
+
that xcodegen would wipe). Either move `MARKETING_VERSION` under
|
|
94
|
+
`settings.base` in `project.yml`, or pin `marketing-version-auto-bump:
|
|
95
|
+
'none'` and bump manually.
|
|
96
|
+
|
|
48
97
|
## Step 5 — Push to `main`
|
|
49
98
|
|
|
50
99
|
```bash
|
|
@@ -233,11 +282,17 @@ is NEVER auto-committed — see "deploy.yml is not auto-updated" below.
|
|
|
233
282
|
| Combined commit | One commit when cert refresh + autoupdate fire in the same run (subjects below) |
|
|
234
283
|
| Failure mode | Non-fatal: a failed `npm view` or `npx` emits `::warning::` and the build continues |
|
|
235
284
|
|
|
236
|
-
Commit subjects (all carry `[skip ci]
|
|
285
|
+
Commit subjects (all carry `[skip ci]`; multiple components are joined
|
|
286
|
+
by ` + ` in the order shown):
|
|
237
287
|
|
|
238
|
-
- `ci: refresh signing identity
|
|
288
|
+
- `ci: refresh signing identity` — cert/profile refresh only
|
|
239
289
|
- `ci: autoupdate swift-app-ci` — vendored action update only
|
|
240
|
-
- `ci:
|
|
290
|
+
- `ci: bump MARKETING_VERSION` — auto-bump only (rare; floor moved
|
|
291
|
+
ahead between cert / autoupdate cycles)
|
|
292
|
+
- `ci: refresh signing identity + autoupdate swift-app-ci` — both
|
|
293
|
+
- `ci: refresh signing identity + bump MARKETING_VERSION` — cert + bump
|
|
294
|
+
- `ci: autoupdate swift-app-ci + bump MARKETING_VERSION` — autoupdate + bump
|
|
295
|
+
- `ci: refresh signing identity + autoupdate swift-app-ci + bump MARKETING_VERSION` — all three
|
|
241
296
|
|
|
242
297
|
### `paths-ignore` requirement
|
|
243
298
|
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Note: fetch_prerelease_versions (~58 lines) and
|
|
3
|
+
# fetch_builds_prerelease_versions (~59 lines) exceed the 50-line
|
|
4
|
+
# function cap. Pre-existing in the source-of-truth; refactor tracked
|
|
5
|
+
# separately to avoid bundling unrelated edits into round-13 trims.
|
|
6
|
+
"""
|
|
7
|
+
App Store Connect pre-release-version history fetcher.
|
|
8
|
+
|
|
9
|
+
Split out of asc_version_fetch.py so each module stays focused and under
|
|
10
|
+
the 400-line cap. This module owns:
|
|
11
|
+
|
|
12
|
+
- `fetch_prerelease_versions`: paginated list of every TestFlight train
|
|
13
|
+
versionString (/v1/apps/{id}/preReleaseVersions -> attributes.version).
|
|
14
|
+
TestFlight trains persist across app updates and feed ASC's upload
|
|
15
|
+
validator's "previously approved version" floor.
|
|
16
|
+
|
|
17
|
+
Why NOT /v1/apps/{id}/builds? Two reasons, both learned the hard way:
|
|
18
|
+
|
|
19
|
+
1. The app-scoped /builds relationship rejects
|
|
20
|
+
``filter[preReleaseVersion.platform]`` with HTTP 400 ("parameter is
|
|
21
|
+
not permitted on this endpoint"). That filter is only valid on the
|
|
22
|
+
top-level /v1/builds collection, not the relationship view.
|
|
23
|
+
|
|
24
|
+
2. Even if the filter worked, /builds.attributes.version is the INTEGER
|
|
25
|
+
build number (CFBundleVersion), NOT the marketing versionString.
|
|
26
|
+
Empirical proof from CI run 24640182161:
|
|
27
|
+
ASC /builds: scanning 6 builds -> v='3','2','1'
|
|
28
|
+
Those '3','2','1' are build numbers. Marketing versions on the same
|
|
29
|
+
app looked like 1.0, 1.0.1, 1.0.4.
|
|
30
|
+
|
|
31
|
+
So /v1/apps/{id}/builds is unfit for marketing-version discovery.
|
|
32
|
+
/v1/apps/{id}/preReleaseVersions is the authoritative source:
|
|
33
|
+
``attributes.version`` there IS the marketing versionString.
|
|
34
|
+
|
|
35
|
+
Why no ``filter[platform]`` on the request? CI run 24640348572 proved
|
|
36
|
+
the app-scoped ``/apps/{id}/preReleaseVersions`` relationship endpoint
|
|
37
|
+
rejects ``filter[platform]`` with HTTP 400 ("parameter is not permitted
|
|
38
|
+
on this endpoint"). The filter is only valid on the top-level
|
|
39
|
+
``/preReleaseVersions`` collection. So we fetch unfiltered and filter
|
|
40
|
+
client-side.
|
|
41
|
+
|
|
42
|
+
Defensive-inclusion rule (CI run 24640430898): records with an EXPLICIT
|
|
43
|
+
non-iOS platform (``MAC_OS``, ``TV_OS``, ``VISION_OS``) are excluded;
|
|
44
|
+
records with iOS OR null/missing platform are INCLUDED. Rationale: the
|
|
45
|
+
409 failure proved ASC can hide preReleaseVersion records that still
|
|
46
|
+
collide with a POST to /appStoreVersions. Better to overbump one patch
|
|
47
|
+
than to miss a hidden collision.
|
|
48
|
+
|
|
49
|
+
Also in this module (CI run 24640430898): ``fetch_builds_prerelease_versions``
|
|
50
|
+
queries ``/v1/builds?filter[app]={id}&include=preReleaseVersion``,
|
|
51
|
+
cross-references the ``included`` array, and extracts each build's
|
|
52
|
+
preReleaseVersion.attributes.version (the marketing version, not the
|
|
53
|
+
build number). This reveals preReleaseVersions that the direct
|
|
54
|
+
/preReleaseVersions query might miss (pagination truncation or state
|
|
55
|
+
filtering). The build's own ``attributes.version`` is the INTEGER build
|
|
56
|
+
number (CFBundleVersion) and is NOT used.
|
|
57
|
+
|
|
58
|
+
Why the TOP-LEVEL ``/v1/builds`` collection (not ``/v1/apps/{id}/builds``)?
|
|
59
|
+
CI run 24640675162 proved the app-scoped relationship endpoint rejects
|
|
60
|
+
``include=preReleaseVersion`` with HTTP 400 PARAMETER_ERROR.ILLEGAL
|
|
61
|
+
(`"The parameter 'include' can not be used with this request"`). The
|
|
62
|
+
top-level ``/v1/builds`` collection DOES accept both ``filter[app]``
|
|
63
|
+
and ``include``, so we query that instead and scope by ``filter[app]``.
|
|
64
|
+
|
|
65
|
+
(``next_build_number.py`` still uses /builds -- but it needs build
|
|
66
|
+
numbers, not marketing versions, so that usage is correct.)
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
from __future__ import annotations
|
|
70
|
+
|
|
71
|
+
import sys
|
|
72
|
+
|
|
73
|
+
from asc_common import request
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _iter_pages(path: str, token: str, params: dict):
|
|
77
|
+
"""Generator over `.data` lists across paginated ASC responses.
|
|
78
|
+
|
|
79
|
+
Follows `links.next` verbatim (stripping the `/v1` prefix so we stay
|
|
80
|
+
on the retrying client). Yields each record; callers decide what to
|
|
81
|
+
extract and how to log.
|
|
82
|
+
"""
|
|
83
|
+
resp = request("GET", path, token, params=params)
|
|
84
|
+
while True:
|
|
85
|
+
data = resp.json()
|
|
86
|
+
for item in data.get("data", []):
|
|
87
|
+
yield item
|
|
88
|
+
next_url = ((data.get("links") or {}).get("next")) or ""
|
|
89
|
+
if not next_url:
|
|
90
|
+
return
|
|
91
|
+
next_path = next_url.split("/v1", 1)[-1] if "/v1" in next_url else next_url
|
|
92
|
+
resp = request("GET", next_path, token)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_EXPLICIT_NON_IOS_PLATFORMS = {"MAC_OS", "TV_OS", "VISION_OS"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _platform_is_counted(platform: str | None) -> bool:
|
|
99
|
+
"""Defensive inclusion rule: include iOS and null/missing platform;
|
|
100
|
+
exclude only explicit known-wrong platforms. Keeps hidden records
|
|
101
|
+
from slipping past the floor (CI run 24640430898)."""
|
|
102
|
+
if platform in _EXPLICIT_NON_IOS_PLATFORMS:
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fetch_prerelease_versions(app_id: str, token: str) -> list[str]:
|
|
108
|
+
"""Return every TestFlight train's versionString for the app.
|
|
109
|
+
|
|
110
|
+
Paginates through links.next so mature apps with >200 trains are not
|
|
111
|
+
truncated. Filters client-side -- the app-scoped
|
|
112
|
+
``/apps/{id}/preReleaseVersions`` relationship endpoint rejects
|
|
113
|
+
``filter[platform]`` with HTTP 400 (CI run 24640348572), so we drop
|
|
114
|
+
the server-side filter and evaluate ``attributes.platform`` on each
|
|
115
|
+
returned record instead.
|
|
116
|
+
|
|
117
|
+
Defensive inclusion (CI run 24640430898): iOS records AND records
|
|
118
|
+
with null/missing platform are INCLUDED; only explicit MAC_OS /
|
|
119
|
+
TV_OS / VISION_OS are excluded. Records with missing/null version
|
|
120
|
+
are skipped regardless.
|
|
121
|
+
"""
|
|
122
|
+
path = f"/apps/{app_id}/preReleaseVersions"
|
|
123
|
+
params = {"limit": 200}
|
|
124
|
+
out: list[str] = []
|
|
125
|
+
skipped = 0
|
|
126
|
+
for item in _iter_pages(path, token, params):
|
|
127
|
+
attrs = item.get("attributes") or {}
|
|
128
|
+
version = attrs.get("version")
|
|
129
|
+
platform = attrs.get("platform")
|
|
130
|
+
record_id = item.get("id", "")
|
|
131
|
+
if not version:
|
|
132
|
+
print(
|
|
133
|
+
f"[prerelease-history] skip id={record_id} "
|
|
134
|
+
f"platform={platform or '<null>'} reason=missing-version",
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
)
|
|
137
|
+
skipped += 1
|
|
138
|
+
continue
|
|
139
|
+
if not _platform_is_counted(platform):
|
|
140
|
+
print(
|
|
141
|
+
f"[prerelease-history] skip id={record_id} version={version} "
|
|
142
|
+
f"platform={platform or '<null>'} reason=not-ios",
|
|
143
|
+
file=sys.stderr,
|
|
144
|
+
)
|
|
145
|
+
skipped += 1
|
|
146
|
+
continue
|
|
147
|
+
out.append(version)
|
|
148
|
+
if platform == "IOS":
|
|
149
|
+
log_msg = (
|
|
150
|
+
f"[prerelease-history] version={version} "
|
|
151
|
+
f"platform=IOS id={record_id}"
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
log_msg = (
|
|
155
|
+
f"[prerelease-history] version={version} "
|
|
156
|
+
f"platform=<null> id={record_id} "
|
|
157
|
+
f"reason=included-defensively"
|
|
158
|
+
)
|
|
159
|
+
print(log_msg, file=sys.stderr)
|
|
160
|
+
print(
|
|
161
|
+
f"[prerelease-history] done: trains={len(out)} skipped={skipped}",
|
|
162
|
+
file=sys.stderr,
|
|
163
|
+
)
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def fetch_builds_prerelease_versions(app_id: str, token: str) -> list[str]:
|
|
168
|
+
"""Return every build's referenced preReleaseVersion marketing version.
|
|
169
|
+
|
|
170
|
+
Queries the TOP-LEVEL
|
|
171
|
+
``/v1/builds?filter[app]={id}&include=preReleaseVersion`` collection
|
|
172
|
+
and cross-references the ``included`` array to resolve each build's
|
|
173
|
+
``relationships.preReleaseVersion.data.id`` -> the corresponding
|
|
174
|
+
``included`` record's ``attributes.version`` (marketing version,
|
|
175
|
+
NOT the build number).
|
|
176
|
+
|
|
177
|
+
The app-scoped relationship endpoint ``/v1/apps/{id}/builds`` rejects
|
|
178
|
+
the ``include`` parameter with HTTP 400 PARAMETER_ERROR.ILLEGAL
|
|
179
|
+
(`"The parameter 'include' can not be used with this request"`,
|
|
180
|
+
CI run 24640675162). The top-level collection accepts both
|
|
181
|
+
``filter[app]`` and ``include``, so we query that and scope by app.
|
|
182
|
+
|
|
183
|
+
Paginates through links.next. Same defensive platform rule as
|
|
184
|
+
fetch_prerelease_versions: iOS / null included, explicit non-iOS
|
|
185
|
+
excluded. Each cross-reference is logged.
|
|
186
|
+
|
|
187
|
+
Rationale: CI run 24640430898 exposed a hidden preReleaseVersion
|
|
188
|
+
that the direct /preReleaseVersions query did not surface, yet a
|
|
189
|
+
POST to /appStoreVersions for that version returned 409. The
|
|
190
|
+
builds->preReleaseVersion path is a second, independent view of
|
|
191
|
+
those same records and catches ones missed by the direct query.
|
|
192
|
+
"""
|
|
193
|
+
path = "/builds"
|
|
194
|
+
params = {
|
|
195
|
+
"filter[app]": app_id,
|
|
196
|
+
"include": "preReleaseVersion",
|
|
197
|
+
"limit": 200,
|
|
198
|
+
}
|
|
199
|
+
out: list[str] = []
|
|
200
|
+
counters = {"builds_scanned": 0, "skipped": 0}
|
|
201
|
+
|
|
202
|
+
resp = request("GET", path, token, params=params)
|
|
203
|
+
while True:
|
|
204
|
+
data = resp.json()
|
|
205
|
+
included_by_id = _index_included(data.get("included") or [])
|
|
206
|
+
for build in data.get("data", []):
|
|
207
|
+
counters["builds_scanned"] += 1
|
|
208
|
+
version = _extract_build_prerelease_version(
|
|
209
|
+
build, included_by_id, counters,
|
|
210
|
+
)
|
|
211
|
+
if version is not None:
|
|
212
|
+
out.append(version)
|
|
213
|
+
|
|
214
|
+
next_url = ((data.get("links") or {}).get("next")) or ""
|
|
215
|
+
if not next_url:
|
|
216
|
+
break
|
|
217
|
+
next_path = next_url.split("/v1", 1)[-1] if "/v1" in next_url else next_url
|
|
218
|
+
resp = request("GET", next_path, token)
|
|
219
|
+
|
|
220
|
+
print(
|
|
221
|
+
f"[builds-prerelease] done: builds={counters['builds_scanned']} "
|
|
222
|
+
f"versions={len(out)} skipped={counters['skipped']}",
|
|
223
|
+
file=sys.stderr,
|
|
224
|
+
)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _extract_build_prerelease_version(
|
|
229
|
+
build: dict, included_by_id: dict[str, dict], counters: dict,
|
|
230
|
+
) -> str | None:
|
|
231
|
+
"""Return the build's referenced preReleaseVersion marketing version
|
|
232
|
+
if it passes the defensive platform filter; else None. ``counters``
|
|
233
|
+
is mutated to track skips for the done-line summary."""
|
|
234
|
+
rel_id = _prerelease_ref_id(build)
|
|
235
|
+
if not rel_id:
|
|
236
|
+
return None
|
|
237
|
+
ref = included_by_id.get(rel_id)
|
|
238
|
+
if ref is None:
|
|
239
|
+
return None
|
|
240
|
+
attrs = ref.get("attributes") or {}
|
|
241
|
+
version = attrs.get("version")
|
|
242
|
+
platform = attrs.get("platform")
|
|
243
|
+
if not version:
|
|
244
|
+
counters["skipped"] += 1
|
|
245
|
+
return None
|
|
246
|
+
if not _platform_is_counted(platform):
|
|
247
|
+
print(
|
|
248
|
+
f"[builds-prerelease] skip build={build.get('id', '')} "
|
|
249
|
+
f"prerelease_id={rel_id} version={version} "
|
|
250
|
+
f"platform={platform or '<null>'} reason=not-ios",
|
|
251
|
+
file=sys.stderr,
|
|
252
|
+
)
|
|
253
|
+
counters["skipped"] += 1
|
|
254
|
+
return None
|
|
255
|
+
print(
|
|
256
|
+
f"[builds-prerelease] build={build.get('id', '')} "
|
|
257
|
+
f"prerelease_id={rel_id} version={version} "
|
|
258
|
+
f"platform={platform or '<null>'}",
|
|
259
|
+
file=sys.stderr,
|
|
260
|
+
)
|
|
261
|
+
return version
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _index_included(included: list[dict]) -> dict[str, dict]:
|
|
265
|
+
"""Build {id: record} map restricted to preReleaseVersions for O(1)
|
|
266
|
+
cross-reference from a build's relationships.preReleaseVersion.data.id."""
|
|
267
|
+
out: dict[str, dict] = {}
|
|
268
|
+
for rec in included:
|
|
269
|
+
if rec.get("type") != "preReleaseVersions":
|
|
270
|
+
continue
|
|
271
|
+
rid = rec.get("id")
|
|
272
|
+
if rid:
|
|
273
|
+
out[rid] = rec
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _prerelease_ref_id(build: dict) -> str | None:
|
|
278
|
+
"""Return the preReleaseVersion id referenced by a build, or None
|
|
279
|
+
if the relationship is absent/empty."""
|
|
280
|
+
rels = build.get("relationships") or {}
|
|
281
|
+
pre = rels.get("preReleaseVersion") or {}
|
|
282
|
+
data = pre.get("data") or {}
|
|
283
|
+
rid = data.get("id")
|
|
284
|
+
return rid if rid else None
|
|
@@ -8,6 +8,7 @@ constants used by multiple scripts in this action.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import os
|
|
11
12
|
import sys
|
|
12
13
|
import time
|
|
13
14
|
from typing import Any
|
|
@@ -18,6 +19,37 @@ import requests
|
|
|
18
19
|
|
|
19
20
|
ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
|
|
20
21
|
|
|
22
|
+
# ----------------------------------------------------------------------
|
|
23
|
+
# ASC version-state taxonomy (consumed by manage_marketing_version's
|
|
24
|
+
# ``_classify_match_at_target``):
|
|
25
|
+
#
|
|
26
|
+
# terminal -- READY_FOR_SALE / PROCESSING_FOR_APP_STORE /
|
|
27
|
+
# PENDING_APPLE_RELEASE / archived (REPLACED_WITH_NEW_VERSION,
|
|
28
|
+
# REMOVED_FROM_SALE, NOT_APPLICABLE). Slot is locked; auto-roll
|
|
29
|
+
# may advance the project's MARKETING_VERSION to the next
|
|
30
|
+
# patch instead of rejecting.
|
|
31
|
+
# in_review -- WAITING_FOR_REVIEW / IN_REVIEW (Apple is actively reviewing)
|
|
32
|
+
# OR PENDING_DEVELOPER_RELEASE (Apple approved; awaiting the
|
|
33
|
+
# developer's manual release click). Auto-roll is forbidden:
|
|
34
|
+
# advancing past either state would race the review process or
|
|
35
|
+
# bypass the developer's release decision.
|
|
36
|
+
# editable -- PREPARE_FOR_SUBMISSION / REJECTED / METADATA_REJECTED /
|
|
37
|
+
# DEVELOPER_REJECTED / INVALID_BINARY. REUSE the row's id
|
|
38
|
+
# (developer-actionable; mutating is safe).
|
|
39
|
+
# unknown -- any state in NONE of the above. Round-12 fail-closed: the
|
|
40
|
+
# classifier rejects rather than guessing, so a future ASC
|
|
41
|
+
# state cannot silently 409 on CREATE or interfere with
|
|
42
|
+
# whatever Apple is doing with the row.
|
|
43
|
+
#
|
|
44
|
+
# Round-11 design decision: the classifier uses POSITIVE allowlists below
|
|
45
|
+
# rather than ``not in EDITABLE_STATES``. The negative test misclassified
|
|
46
|
+
# PENDING_DEVELOPER_RELEASE as terminal -- it's NOT editable, but it's also
|
|
47
|
+
# NOT auto-rollable (the developer has an approved build awaiting their
|
|
48
|
+
# manual release). Positive allowlists make the taxonomy explicit and
|
|
49
|
+
# prevent future ASC states from silently falling into the auto-roll
|
|
50
|
+
# bucket.
|
|
51
|
+
# ----------------------------------------------------------------------
|
|
52
|
+
|
|
21
53
|
# App Store version states that allow editing the current draft.
|
|
22
54
|
EDITABLE_STATES = {
|
|
23
55
|
"PREPARE_FOR_SUBMISSION",
|
|
@@ -29,6 +61,28 @@ EDITABLE_STATES = {
|
|
|
29
61
|
"DEVELOPER_REJECTED",
|
|
30
62
|
}
|
|
31
63
|
|
|
64
|
+
# Subset of EDITABLE_STATES safe to mutate (attach a build, rename via PATCH)
|
|
65
|
+
# without going through Apple Review. WAITING_FOR_REVIEW and IN_REVIEW are
|
|
66
|
+
# editable in the broad ASC sense (developer can withdraw), but Apple is
|
|
67
|
+
# actively reviewing them: attaching a TestFlight build or PATCH-renaming
|
|
68
|
+
# such a row would silently interfere with App Review. REJECTED /
|
|
69
|
+
# METADATA_REJECTED / INVALID_BINARY come back from Apple Review and are
|
|
70
|
+
# explicitly developer-actionable, so mutating them is safe.
|
|
71
|
+
REUSABLE_STATES = {
|
|
72
|
+
"PREPARE_FOR_SUBMISSION",
|
|
73
|
+
"DEVELOPER_REJECTED",
|
|
74
|
+
"REJECTED",
|
|
75
|
+
"METADATA_REJECTED",
|
|
76
|
+
"INVALID_BINARY",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Editable states that are currently under Apple Review. Mutating these
|
|
80
|
+
# would interfere with the review process.
|
|
81
|
+
IN_REVIEW_STATES = {
|
|
82
|
+
"WAITING_FOR_REVIEW",
|
|
83
|
+
"IN_REVIEW",
|
|
84
|
+
}
|
|
85
|
+
|
|
32
86
|
# Terminal states: the version is locked and a new one must be created.
|
|
33
87
|
TERMINAL_STATES = {
|
|
34
88
|
"READY_FOR_SALE",
|
|
@@ -51,6 +105,17 @@ BLOCKING_STATES = {"PENDING_DEVELOPER_RELEASE"}
|
|
|
51
105
|
_RETRY_STATUSES = {429, 500, 502, 503, 504}
|
|
52
106
|
_RETRY_BACKOFFS = (2, 8, 30) # seconds
|
|
53
107
|
|
|
108
|
+
# Per-request timeouts (seconds). Splitting connect vs read so a slow TLS
|
|
109
|
+
# handshake can't masquerade as a slow API response (and vice versa).
|
|
110
|
+
# Without these, ``requests.request(...)`` would block the CI runner
|
|
111
|
+
# indefinitely on a hung TCP socket -- the runner only kills the job at
|
|
112
|
+
# its 6h hard limit, by which point the on-call has already paged.
|
|
113
|
+
_DEFAULT_TIMEOUT_CONNECT_SEC = 10.0
|
|
114
|
+
_DEFAULT_TIMEOUT_READ_SEC = 30.0
|
|
115
|
+
# Env overrides for slow networks / unusually large ASC responses.
|
|
116
|
+
_TIMEOUT_CONNECT_ENV = "ASC_REQUEST_TIMEOUT_CONNECT_SEC"
|
|
117
|
+
_TIMEOUT_READ_ENV = "ASC_REQUEST_TIMEOUT_READ_SEC"
|
|
118
|
+
|
|
54
119
|
|
|
55
120
|
def make_jwt(key_id: str, issuer_id: str, key_path: str) -> str:
|
|
56
121
|
"""Return an ES256 JWT valid for 20 minutes, audience appstoreconnect-v1."""
|
|
@@ -68,6 +133,64 @@ def make_jwt(key_id: str, issuer_id: str, key_path: str) -> str:
|
|
|
68
133
|
)
|
|
69
134
|
|
|
70
135
|
|
|
136
|
+
def _request_timeouts() -> tuple[float, float]:
|
|
137
|
+
"""Return ``(connect, read)`` timeouts in seconds. Both env-overridable
|
|
138
|
+
for slow networks (``ASC_REQUEST_TIMEOUT_CONNECT_SEC`` /
|
|
139
|
+
``ASC_REQUEST_TIMEOUT_READ_SEC``); invalid or non-positive values
|
|
140
|
+
fall back to the defaults so a typo can't disable the timeout."""
|
|
141
|
+
def read(env_name: str, default: float) -> float:
|
|
142
|
+
raw = (os.environ.get(env_name) or "").strip()
|
|
143
|
+
if not raw:
|
|
144
|
+
return default
|
|
145
|
+
try:
|
|
146
|
+
value = float(raw)
|
|
147
|
+
except ValueError:
|
|
148
|
+
return default
|
|
149
|
+
return value if value > 0 else default
|
|
150
|
+
return (
|
|
151
|
+
read(_TIMEOUT_CONNECT_ENV, _DEFAULT_TIMEOUT_CONNECT_SEC),
|
|
152
|
+
read(_TIMEOUT_READ_ENV, _DEFAULT_TIMEOUT_READ_SEC),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _retry_network_error(
|
|
157
|
+
method: str, path: str, attempt: int, total: int,
|
|
158
|
+
backoffs: tuple[int, ...], exc: BaseException,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Network error path: sleep and return when retries remain, else
|
|
161
|
+
raise SystemExit (terminal). Caller ``continue``s after return."""
|
|
162
|
+
if attempt >= total - 1:
|
|
163
|
+
raise SystemExit(
|
|
164
|
+
f"ASC {method} {path} network error after {total} "
|
|
165
|
+
f"attempts: {exc!r}"
|
|
166
|
+
)
|
|
167
|
+
delay = backoffs[attempt]
|
|
168
|
+
print(
|
|
169
|
+
f"ASC {method} {path} network error ({exc!r}); "
|
|
170
|
+
f"retrying in {delay}s ({attempt + 1}/{total - 1})",
|
|
171
|
+
file=sys.stderr,
|
|
172
|
+
)
|
|
173
|
+
time.sleep(delay)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _retry_status(
|
|
177
|
+
method: str, path: str, attempt: int, total: int,
|
|
178
|
+
backoffs: tuple[int, ...], status: int,
|
|
179
|
+
) -> bool:
|
|
180
|
+
"""Retryable status code: sleep and return True when retries remain,
|
|
181
|
+
else return False so the caller breaks to the final SystemExit."""
|
|
182
|
+
if attempt >= total - 1:
|
|
183
|
+
return False
|
|
184
|
+
delay = backoffs[attempt]
|
|
185
|
+
print(
|
|
186
|
+
f"ASC {method} {path} returned {status}; "
|
|
187
|
+
f"retrying in {delay}s ({attempt + 1}/{total - 1})",
|
|
188
|
+
file=sys.stderr,
|
|
189
|
+
)
|
|
190
|
+
time.sleep(delay)
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
|
|
71
194
|
def request(
|
|
72
195
|
method: str,
|
|
73
196
|
path: str,
|
|
@@ -78,60 +201,40 @@ def request(
|
|
|
78
201
|
allow_status: set[int] | None = None,
|
|
79
202
|
max_attempts: int = 3,
|
|
80
203
|
) -> requests.Response:
|
|
81
|
-
"""HTTP request with retry on 429/5xx.
|
|
204
|
+
"""HTTP request with retry on 429/5xx and explicit connect/read timeouts.
|
|
82
205
|
|
|
83
206
|
`path` is the portion after `/v1` (e.g. "/apps/123/appStoreVersions").
|
|
84
207
|
`allow_status` lists non-2xx statuses the caller wants returned without
|
|
85
|
-
raising (useful for 409 conflict handling).
|
|
86
|
-
|
|
208
|
+
raising (useful for 409 conflict handling). Per-request timeouts come
|
|
209
|
+
from ``_request_timeouts()`` (env-overridable); without them a hung
|
|
210
|
+
TCP socket would block the CI runner indefinitely.
|
|
211
|
+
Raises SystemExit on non-retryable failure with a clear stderr message.
|
|
87
212
|
"""
|
|
88
213
|
url = f"{ASC_BASE}{path}"
|
|
89
214
|
headers = {"Authorization": f"Bearer {token}"}
|
|
90
215
|
if json_body is not None:
|
|
91
216
|
headers["Content-Type"] = "application/json"
|
|
92
|
-
|
|
93
217
|
backoffs = _RETRY_BACKOFFS[: max(0, max_attempts - 1)]
|
|
94
|
-
|
|
218
|
+
total = len(backoffs) + 1
|
|
219
|
+
timeout = _request_timeouts()
|
|
95
220
|
resp: requests.Response | None = None
|
|
96
|
-
|
|
97
|
-
for attempt in range(total_attempts):
|
|
221
|
+
for attempt in range(total):
|
|
98
222
|
try:
|
|
99
223
|
resp = requests.request(
|
|
100
|
-
method, url, headers=headers, params=params,
|
|
224
|
+
method, url, headers=headers, params=params,
|
|
225
|
+
json=json_body, timeout=timeout,
|
|
101
226
|
)
|
|
102
227
|
except requests.RequestException as exc:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
print(
|
|
106
|
-
f"ASC {method} {path} network error ({exc!r}); "
|
|
107
|
-
f"retrying in {delay}s ({attempt + 1}/{total_attempts - 1})",
|
|
108
|
-
file=sys.stderr,
|
|
109
|
-
)
|
|
110
|
-
time.sleep(delay)
|
|
111
|
-
continue
|
|
112
|
-
raise SystemExit(
|
|
113
|
-
f"ASC {method} {path} network error after {total_attempts} "
|
|
114
|
-
f"attempts: {exc!r}"
|
|
115
|
-
)
|
|
116
|
-
|
|
228
|
+
_retry_network_error(method, path, attempt, total, backoffs, exc)
|
|
229
|
+
continue
|
|
117
230
|
if resp.status_code < 400:
|
|
118
231
|
return resp
|
|
119
|
-
|
|
120
232
|
if allow_status and resp.status_code in allow_status:
|
|
121
233
|
return resp
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
delay = backoffs[attempt]
|
|
125
|
-
print(
|
|
126
|
-
f"ASC {method} {path} returned {resp.status_code}; "
|
|
127
|
-
f"retrying in {delay}s ({attempt + 1}/{total_attempts - 1})",
|
|
128
|
-
file=sys.stderr,
|
|
129
|
-
)
|
|
130
|
-
time.sleep(delay)
|
|
234
|
+
if resp.status_code in _RETRY_STATUSES and _retry_status(
|
|
235
|
+
method, path, attempt, total, backoffs, resp.status_code):
|
|
131
236
|
continue
|
|
132
|
-
|
|
133
237
|
break
|
|
134
|
-
|
|
135
238
|
assert resp is not None
|
|
136
239
|
raise SystemExit(
|
|
137
240
|
f"ASC {method} {path} failed: {resp.status_code}\n{resp.text[:2000]}"
|