gowalk-cicd 1.0.116 → 1.0.118

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.
@@ -1 +1 @@
1
- 1.0.116
1
+ 1.0.118
@@ -1 +1 @@
1
- 1.0.116
1
+ 1.0.118
@@ -49,6 +49,13 @@ inputs:
49
49
  description: Git clone source
50
50
  required: false
51
51
  default: https://github.com/flutter/flutter.git
52
+ archive-budget-seconds:
53
+ description: >-
54
+ Seconds the proxied SDK archive transfer may take. Raise it in a release
55
+ job, whose own timeout is far longer than a validation job's; the helper
56
+ clamps it and the partial archive is cached either way.
57
+ required: false
58
+ default: ''
52
59
  outputs:
53
60
  CHANNEL:
54
61
  value: ${{ steps.flutter-action.outputs.CHANNEL }}
@@ -196,6 +203,7 @@ runs:
196
203
  ${{ inputs.cache == 'true' && format('{0}/gowalk-flutter-archives', runner.temp) || '' }}
197
204
  GOWALK_FLUTTER_RELEASE_CACHE: ${{ steps.release-metadata.outputs.path }}
198
205
  GOWALK_FLUTTER_RELEASE_PIN: ${{ steps.release-metadata.outputs.pin }}
206
+ GOWALK_FLUTTER_ARCHIVE_BUDGET_S: ${{ inputs.archive-budget-seconds }}
199
207
  with:
200
208
  channel: ${{ steps.flutter-action.outputs.CHANNEL }}
201
209
  flutter-version: ${{ steps.flutter-action.outputs.VERSION }}
@@ -9,11 +9,42 @@ import time
9
9
  from urllib.parse import urlsplit
10
10
 
11
11
  import flutter_download
12
+
13
+ #: Seconds an archive transfer may take before its receipt is published. The
14
+ #: enclosing job's own room is the only thing that bounds it usefully, and the
15
+ #: two differ by a factor of three: a validation job must report inside 30
16
+ #: minutes, while a release job has 45-60. A default-branch deploy cannot
17
+ #: restore the SDK cache a task branch built (GitHub scopes branch caches away
18
+ #: from the default branch), so it downloads the whole archive through the
19
+ #: account's residential exit at a few hundred KB/s — and a fixed 20-minute cap
20
+ #: made that a guaranteed failure: obd-scanner's release retained 428 MB of its
21
+ #: archive, failed on curl 28, and shipped nothing (2026-09-09).
22
+ ARCHIVE_BUDGET_S = 1200
23
+ MAX_ARCHIVE_BUDGET_S = 3000
24
+
25
+
26
+ def archive_budget() -> int:
27
+ """The archive deadline, from ``GOWALK_FLUTTER_ARCHIVE_BUDGET_S`` when the
28
+ workflow declares one. Clamped: never below the current default, never
29
+ beyond a release job's own timeout."""
30
+ try:
31
+ declared = int(os.environ.get("GOWALK_FLUTTER_ARCHIVE_BUDGET_S", "") or ARCHIVE_BUDGET_S)
32
+ except ValueError:
33
+ return ARCHIVE_BUDGET_S
34
+ return max(ARCHIVE_BUDGET_S, min(declared, MAX_ARCHIVE_BUDGET_S))
12
35
  import flutter_archive_cache
13
36
  import flutter_release_cache
14
37
 
15
38
  RETRY_CODES = {5, 6, 7, 18, 28, 35, 52, 55, 56, 92, 95}
16
39
  RETRY_HTTP = {408, 429, 500, 502, 503, 504}
40
+ #: Attempts each kind of transfer may make. The archive's deadline already
41
+ #: bounds its pass, so four stands there. The MANIFEST is the one that gave up
42
+ #: early: the account exit refused four TLS handshakes in ten seconds (curl 35,
43
+ #: proxy_status 200) and the iOS release failed with fifty seconds of its own
44
+ #: sixty-second budget unspent — an hour after the same fetch had succeeded
45
+ #: (obd-scanner, 2026-09-09). It is a few KB, so riding out an exit that is
46
+ #: briefly refusing costs one small request each time and nothing else.
47
+ MAX_ATTEMPTS = {"manifest": 12, "archive": 4}
17
48
 
18
49
 
19
50
  def download(arguments: list[str], directory: Path) -> int:
@@ -23,7 +54,7 @@ def download(arguments: list[str], directory: Path) -> int:
23
54
  if kind == "archive":
24
55
  flutter_download.archive_digest()
25
56
  child_env = {**os.environ, "NO_PROXY": "", "no_proxy": ""}
26
- deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else 1200), 1, 0, 0
57
+ deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else archive_budget()), 1, 0, 0
27
58
  destination = directory / "download"
28
59
  destination.touch(mode=0o600, exist_ok=True)
29
60
  destination.chmod(0o600)
@@ -32,7 +63,8 @@ def download(arguments: list[str], directory: Path) -> int:
32
63
  if flutter_archive_cache.reuse_complete(destination, kind):
33
64
  return 0
34
65
  attempted = 0
35
- for attempt in range(1, 5):
66
+ limit = MAX_ATTEMPTS[kind]
67
+ for attempt in range(1, limit + 1):
36
68
  remaining = deadline - time.monotonic()
37
69
  if remaining <= 0:
38
70
  break
@@ -48,7 +80,7 @@ def download(arguments: list[str], directory: Path) -> int:
48
80
  if attempt > 1:
49
81
  print(f"flutter_{kind}_recovered attempts={attempt}", file=sys.stderr)
50
82
  return 0
51
- if attempt == 4 or proxy_http in {401, 403, 407} or http in {401, 403}:
83
+ if attempt == limit or proxy_http in {401, 403, 407} or http in {401, 403}:
52
84
  break
53
85
  restart = kind == "archive" and code == 33 and http == 200
54
86
  if (not restart and code not in RETRY_CODES and http not in RETRY_HTTP
@@ -58,7 +90,13 @@ def download(arguments: list[str], directory: Path) -> int:
58
90
  destination.write_bytes(b"")
59
91
  if destination.stat().st_size > flutter_download.MAX_ARCHIVE_BYTES:
60
92
  raise ValueError("flutter_archive_size_exceeded")
61
- time.sleep(min(2 ** (attempt - 1), max(0, deadline - time.monotonic())))
93
+ left = max(0.0, deadline - time.monotonic())
94
+ # The manifest waits a flat second between tries: it is riding out an
95
+ # exit that is refusing right now, and an exponential backoff would
96
+ # spend its sixty-second window sleeping instead of retrying. The
97
+ # archive keeps the growing wait — its attempts are minutes long.
98
+ pause = 1 if kind == "manifest" else 2 ** (attempt - 1)
99
+ time.sleep(min(pause, left))
62
100
  flutter_download.failure(kind, code, http, proxy_http, attempted, destination.stat().st_size)
63
101
  return code or 1
64
102
 
@@ -38,5 +38,108 @@ class DownloadBudgetTests(unittest.TestCase):
38
38
  self.assertNotIn("fixture-secret", error.getvalue())
39
39
 
40
40
 
41
+ class ArchiveBudgetTests(unittest.TestCase):
42
+ def test_a_release_job_may_declare_more_archive_room_than_a_validation_job(self):
43
+ with patch.dict(os.environ, {}, clear=False):
44
+ os.environ.pop("GOWALK_FLUTTER_ARCHIVE_BUDGET_S", None)
45
+ self.assertEqual(flutter_manifest.archive_budget(), 1200)
46
+ os.environ["GOWALK_FLUTTER_ARCHIVE_BUDGET_S"] = "2100"
47
+ self.assertEqual(flutter_manifest.archive_budget(), 2100)
48
+ # Never shorter than the default, never past a release job's timeout,
49
+ # and a malformed value is the default rather than a refusal.
50
+ os.environ["GOWALK_FLUTTER_ARCHIVE_BUDGET_S"] = "60"
51
+ self.assertEqual(flutter_manifest.archive_budget(), 1200)
52
+ os.environ["GOWALK_FLUTTER_ARCHIVE_BUDGET_S"] = "999999"
53
+ self.assertEqual(flutter_manifest.archive_budget(), 3000)
54
+ os.environ["GOWALK_FLUTTER_ARCHIVE_BUDGET_S"] = "not-a-number"
55
+ self.assertEqual(flutter_manifest.archive_budget(), 1200)
56
+ os.environ["GOWALK_FLUTTER_ARCHIVE_BUDGET_S"] = ""
57
+ self.assertEqual(flutter_manifest.archive_budget(), 1200)
58
+
59
+ def test_a_declared_budget_actually_extends_the_transfer_deadline(self):
60
+ elapsed = [0.0]
61
+
62
+ def transfer(_url, destination, _kind, remaining, _env):
63
+ elapsed[0] += min(600, remaining)
64
+ with destination.open("ab") as output:
65
+ output.write(b"partial")
66
+ return 28, 200, 200
67
+
68
+ with tempfile.TemporaryDirectory() as directory, contextlib.redirect_stderr(io.StringIO()):
69
+ with patch.dict(os.environ, HTTPS_PROXY="http://fixture:secret@localhost:1",
70
+ GOWALK_FLUTTER_ARCHIVE_SHA256="a" * 64,
71
+ GOWALK_FLUTTER_ARCHIVE_BUDGET_S="2100"):
72
+ with patch.object(flutter_manifest.time, "monotonic", side_effect=lambda: elapsed[0]), \
73
+ patch.object(flutter_manifest.time, "sleep", side_effect=lambda seconds: None), \
74
+ patch.object(flutter_manifest.flutter_download, "transfer", side_effect=transfer):
75
+ flutter_manifest.download(["--connect-timeout", "15", "--retry", "5",
76
+ "https://fixture.invalid/flutter_infra_release/releases/stable/linux/sdk.tar.xz"],
77
+ Path(directory))
78
+ # The declared deadline bounds the pass, not the two 600 s attempts a
79
+ # 1200 s default allowed: the last attempt gets the remaining 300 s.
80
+ self.assertEqual(elapsed[0], 2100)
81
+
82
+
83
+
84
+
85
+ class ManifestRetryTests(unittest.TestCase):
86
+ def test_a_briefly_refusing_exit_is_retried_until_the_deadline(self):
87
+ """The iOS release lost its manifest to four TLS refusals in ten seconds
88
+ (curl 35, proxy_status 200) with fifty seconds of budget unspent, an hour
89
+ after the same fetch succeeded."""
90
+ elapsed, attempts = [0.0], []
91
+
92
+ def transfer(_url, destination, _kind, _remaining, _env):
93
+ attempts.append(elapsed[0])
94
+ elapsed[0] += 2.5 # a TLS refusal fails fast
95
+ destination.write_bytes(b"")
96
+ return 35, 0, 200
97
+
98
+ with tempfile.TemporaryDirectory() as directory, contextlib.redirect_stderr(io.StringIO()) as error:
99
+ with patch.dict(os.environ, HTTPS_PROXY="http://fixture:secret@localhost:1"):
100
+ with patch.object(flutter_manifest.time, "monotonic", side_effect=lambda: elapsed[0]), \
101
+ patch.object(flutter_manifest.time, "sleep",
102
+ side_effect=lambda seconds: elapsed.__setitem__(0, elapsed[0] + seconds)), \
103
+ patch.object(flutter_manifest.flutter_download, "transfer", side_effect=transfer):
104
+ result = flutter_manifest.download(["--silent", "--connect-timeout", "15", "--retry", "5",
105
+ "https://fixture.invalid/flutter_infra_release/releases/releases_macos.json"],
106
+ Path(directory))
107
+ self.assertEqual(result, 35)
108
+ # It used the manifest's whole 60 s window instead of giving up at 10 s.
109
+ self.assertGreater(len(attempts), 4)
110
+ self.assertLessEqual(len(attempts), flutter_manifest.MAX_ATTEMPTS["manifest"])
111
+ self.assertGreater(elapsed[0], 30)
112
+ # The deadline bounds the pass; the last attempt may end just past it.
113
+ self.assertLess(elapsed[0], 70)
114
+ self.assertIn("attempts=%d" % len(attempts), error.getvalue())
115
+ self.assertNotIn("secret", error.getvalue())
116
+
117
+ def test_an_exit_that_recovers_mid_window_still_publishes(self):
118
+ elapsed, calls = [0.0], []
119
+
120
+ def transfer(_url, destination, _kind, _remaining, _env):
121
+ calls.append(1)
122
+ elapsed[0] += 2.5
123
+ if len(calls) < 6:
124
+ destination.write_bytes(b"")
125
+ return 35, 0, 200
126
+ destination.write_bytes(b'{"releases": []}')
127
+ return 0, 200, 200
128
+
129
+ with tempfile.TemporaryDirectory() as directory, contextlib.redirect_stderr(io.StringIO()) as error:
130
+ with patch.dict(os.environ, HTTPS_PROXY="http://fixture:secret@localhost:1"):
131
+ with patch.object(flutter_manifest.time, "monotonic", side_effect=lambda: elapsed[0]), \
132
+ patch.object(flutter_manifest.time, "sleep",
133
+ side_effect=lambda seconds: elapsed.__setitem__(0, elapsed[0] + seconds)), \
134
+ patch.object(flutter_manifest.flutter_download, "publish", side_effect=lambda *a: None), \
135
+ patch.object(flutter_manifest.flutter_download, "transfer", side_effect=transfer):
136
+ result = flutter_manifest.download(["--silent", "--connect-timeout", "15", "--retry", "5",
137
+ "https://fixture.invalid/flutter_infra_release/releases/releases_macos.json"],
138
+ Path(directory))
139
+ self.assertEqual(result, 0)
140
+ self.assertEqual(len(calls), 6)
141
+ self.assertIn("flutter_manifest_recovered attempts=6", error.getvalue())
142
+
143
+
41
144
  if __name__ == "__main__":
42
145
  unittest.main()
@@ -5,6 +5,8 @@ import subprocess
5
5
  import tempfile
6
6
  import unittest
7
7
 
8
+ import flutter_manifest
9
+
8
10
  from flutter_manifest_fixture import HOST, MANIFEST, official_setup, tls_proxy
9
11
 
10
12
  ROOT = Path(__file__).parent
@@ -47,7 +49,7 @@ class ManifestRecoveryTests(unittest.TestCase):
47
49
  env.update(HOME=str(self.root / "absent-home"), NO_PROXY="", no_proxy="")
48
50
  args = ["bash", str(self.setup), "-p", "-n", "3.44.0", "-a", "x64", "-k", "custom-:version:-:hash:"]
49
51
  result = subprocess.run([*args, *(["-t"] if test_mode else []), "stable"], env=env,
50
- capture_output=True, text=True, timeout=20)
52
+ capture_output=True, text=True, timeout=120)
51
53
  self.assertNotIn("fixture-secret", result.stdout + result.stderr)
52
54
  self.assertFalse(list(self.root.glob("flutter-manifest-*")))
53
55
  return result
@@ -99,12 +101,18 @@ class ManifestRecoveryTests(unittest.TestCase):
99
101
  self.assertIn("attempts=1", result.stderr)
100
102
  self.assertEqual(result.stdout, "")
101
103
 
102
- def test_repeated_tls_resets_stop_after_four_private_attempts(self):
103
- with tls_proxy(self.root, ["reset"] * 4) as (server, cert):
104
+ def test_repeated_tls_resets_are_retried_across_the_manifest_window(self):
105
+ """An exit that refuses TLS is retried until the manifest's own deadline,
106
+ not abandoned after four handshakes: obd-scanner's iOS release lost its
107
+ manifest to four refusals in ten seconds with fifty seconds of its
108
+ sixty-second budget unspent, an hour after the same fetch succeeded
109
+ (2026-09-09). Nothing is ever sent to the origin."""
110
+ limit = flutter_manifest.MAX_ATTEMPTS["manifest"]
111
+ with tls_proxy(self.root, ["reset"] * limit) as (server, cert):
104
112
  result = self.invoke(server, cert)
105
113
  self.assertEqual(result.returncode, 35)
106
- self.assertEqual((server.connects, server.requests), (4, []))
107
- self.assertIn("attempts=4", result.stderr)
114
+ self.assertEqual((server.connects, server.requests), (limit, []))
115
+ self.assertIn(f"attempts={limit}", result.stderr)
108
116
  self.assertEqual(result.stdout, "")
109
117
 
110
118
  def test_missing_proxy_refuses_before_any_connection(self):
@@ -7,6 +7,8 @@ import sys
7
7
  import tempfile
8
8
  import unittest
9
9
 
10
+ import flutter_manifest
11
+
10
12
  from flutter_manifest_fixture import HOST, MANIFEST, tls_proxy
11
13
 
12
14
  ROOT = Path(__file__).parent
@@ -35,7 +37,7 @@ class ProxyRetryTests(unittest.TestCase):
35
37
  args.insert(0, "--silent")
36
38
  result = subprocess.run([sys.executable, str(ROOT / "flutter_manifest.py"), *args,
37
39
  f"https://{HOST}/flutter_infra_release/releases/{path}"],
38
- env=env, capture_output=True, timeout=20)
40
+ env=env, capture_output=True, timeout=120)
39
41
  self.assertNotIn(b"fixture-secret", result.stdout + result.stderr)
40
42
  self.assertFalse(list(self.root.glob("flutter-manifest-*")))
41
43
  return result
@@ -48,12 +50,17 @@ class ProxyRetryTests(unittest.TestCase):
48
50
  self.assertEqual((server.connects, len(server.requests)), (2, 1))
49
51
  self.assertIn(b"flutter_manifest_recovered attempts=2", result.stderr)
50
52
 
51
- def test_repeated_connect_failure_stops_after_four_attempts_without_origin_request(self):
52
- with tls_proxy(self.root, ["proxy_502"] * 4) as (server, cert):
53
+ def test_repeated_connect_failure_never_reaches_the_origin(self):
54
+ """A refusing proxy is retried across the manifest's own window and the
55
+ origin request is never sent — the attempt count follows the manifest
56
+ limit, not a fixed four (see test_flutter_manifest)."""
57
+ limit = flutter_manifest.MAX_ATTEMPTS["manifest"]
58
+ with tls_proxy(self.root, ["proxy_502"] * limit) as (server, cert):
53
59
  result = self.invoke(server, cert)
54
60
  self.assertNotEqual(result.returncode, 0)
55
- self.assertEqual((result.stdout, server.connects, server.requests), (b"", 4, []))
56
- self.assertIn(b"http_status=0 proxy_status=502 attempts=4 retained_bytes=0", result.stderr)
61
+ self.assertEqual((result.stdout, server.connects, server.requests), (b"", limit, []))
62
+ self.assertIn(f"http_status=0 proxy_status=502 attempts={limit} retained_bytes=0".encode(),
63
+ result.stderr)
57
64
 
58
65
  def test_connect_failure_preserves_partial_archive_for_a_verified_range_resume(self):
59
66
  with tls_proxy(self.root, ["partial", "proxy_502", "range"], ARCHIVE) as (server, cert):
@@ -20,6 +20,9 @@ class BootstrapWiringTests(unittest.TestCase):
20
20
  if step is install:
21
21
  expected_env["GOWALK_FLUTTER_ARCHIVE_CACHE"] = (
22
22
  "${{ inputs.cache == 'true' && format('{0}/gowalk-flutter-archives', runner.temp) || '' }}")
23
+ # Only the step that actually transfers the archive carries the
24
+ # caller's declared budget; the dry-run resolve must not.
25
+ expected_env["GOWALK_FLUTTER_ARCHIVE_BUDGET_S"] = "${{ inputs.archive-budget-seconds }}"
23
26
  self.assertEqual(step["env"], expected_env)
24
27
  self.assertEqual(step["with"]["cache"], "false")
25
28
  self.assertEqual(step["with"]["pub-cache"], "false")
@@ -38,6 +41,11 @@ class BootstrapWiringTests(unittest.TestCase):
38
41
  self.assertEqual(job["env"]["GOOGLE_STORE_PROXY_URL"],
39
42
  "${{ secrets.GOOGLE_STORE_PROXY_URL }}")
40
43
  self.assertNotIn("continue-on-error", step)
44
+ # A release job runs on the default branch, which cannot
45
+ # restore a task branch's SDK cache, so it declares the
46
+ # archive room its own timeout allows (2026-09-09).
47
+ if name == "deploy.yml" and int(job.get("timeout-minutes", 0)) >= 45:
48
+ self.assertEqual(step["with"]["archive-budget-seconds"], "2100")
41
49
  self.assertEqual(checked, 3)
42
50
  setup = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
43
51
  steps = setup["runs"]["steps"]
@@ -65,7 +73,9 @@ class BootstrapWiringTests(unittest.TestCase):
65
73
  setup = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
66
74
  inputs = {"channel", "flutter-version", "flutter-version-file", "architecture", "cache", "cache-key",
67
75
  "cache-path", "pub-cache", "pub-cache-key", "pub-cache-path", "dry-run", "git-source"}
68
- self.assertEqual(set(setup["inputs"]), inputs)
76
+ # `archive-budget-seconds` is this action's own knob, not one of the
77
+ # official action's inputs: it reaches the transfer as an env var.
78
+ self.assertEqual(set(setup["inputs"]), inputs | {"archive-budget-seconds"})
69
79
  resolve = next(step for step in setup["runs"]["steps"] if step.get("id") == "flutter-action")
70
80
  for name in inputs - {"cache", "pub-cache", "dry-run"}:
71
81
  self.assertEqual(resolve["with"][name], "${{ inputs." + name + " }}")
@@ -1 +1 @@
1
- 1.0.116
1
+ 1.0.118
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.116",
3
+ "version": "1.0.118",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -101,6 +101,12 @@ jobs:
101
101
  with:
102
102
  channel: stable
103
103
  cache: true
104
+ # A release runs on the default branch, which cannot restore the SDK
105
+ # cache a task branch built, so it downloads the whole archive through
106
+ # the account exit. This job has an hour; the helper's own 20-minute
107
+ # default belongs to a validation job and turned that download into a
108
+ # guaranteed failure (obd-scanner, 2026-09-09: 428 MB retained, curl 28).
109
+ archive-budget-seconds: '2100'
104
110
  - name: Retain CocoaPods cache process scope
105
111
  id: pods-cache-scope
106
112
  if: ${{ steps.flutter.outputs.enabled == 'true' }}
@@ -411,6 +417,9 @@ jobs:
411
417
  with:
412
418
  channel: stable
413
419
  cache: true
420
+ # 35 of this job's 45 minutes; see the iOS job for why a release needs
421
+ # more archive room than a validation run.
422
+ archive-budget-seconds: '2100'
414
423
  # Flutter apps can depend on private git packages. Most repos in this fleet
415
424
  # embed a PAT directly in the pubspec URL, which publishes the token into
416
425
  # source and into every fork of the lockfile. Setting the optional