@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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.94"
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.94",
15
+ "version": "0.10.96",
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.94",
3
+ "version": "0.10.96",
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.94",
3
+ "version": "0.10.96",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -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 in creds/` — cert/profile refresh only
288
+ - `ci: refresh signing identity` — cert/profile refresh only
239
289
  - `ci: autoupdate swift-app-ci` — vendored action update only
240
- - `ci: refresh signing identity + autoupdate swift-app-ci` both in one run
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
- Raises SystemExit(1) on non-retryable failure with a clear stderr message.
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
- total_attempts = len(backoffs) + 1
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, json=json_body
224
+ method, url, headers=headers, params=params,
225
+ json=json_body, timeout=timeout,
101
226
  )
102
227
  except requests.RequestException as exc:
103
- if attempt < total_attempts - 1:
104
- delay = backoffs[attempt]
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
- if resp.status_code in _RETRY_STATUSES and attempt < total_attempts - 1:
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]}"