gowalk-cicd 1.0.88 → 1.0.90

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.md CHANGED
@@ -155,10 +155,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
155
155
  `NO_PROXY`. The preloader snapshot under `RUNNER_TEMP` also covers official post/save after
156
156
  consumer autoupdate changes the action directory. The installer delivers this development
157
157
  tooling for native iOS repositories too; it does not require an Android app.
158
- - Flutter manifest GET recovery is scoped to the two pinned setup invocations through `BASH_ENV`.
159
- It retains the account proxy, retries transient TLS/transport errors within 60 seconds, and emits
160
- only complete JSON. Partial responses and private curl stderr never enter action outputs;
161
- authentication/certificate failures remain terminal. SDK archive calls retain upstream behavior.
158
+ - Flutter manifest/archive GET recovery is scoped to the two pinned setup invocations through `BASH_ENV`.
159
+ It retains the account proxy and allows four attempts within 60 seconds for manifests or 30 minutes
160
+ for archives, with an 8 GiB archive bound. Interrupted archives resume private bytes with HTTP Range; an origin ignoring
161
+ Range permits a bounded full restart. Only complete JSON or an archive matching the selected official
162
+ manifest's SHA-256 enters action outputs. Authentication/certificate refusals remain terminal.
163
+ `flutter_download_failed` emits fixed transport codes and retained-byte counts, never curl stderr.
162
164
  - Web Release selects bundled renderer resources with `--no-web-resources-cdn` inside
163
165
  the existing proxied build command. This is not a font or integration offline guarantee;
164
166
  preserve app-authored font declarations, bootstrap configuration and other build inputs.
package/README.md CHANGED
@@ -297,6 +297,7 @@ the situation and whose **message** is one line of JSON carrying a versioned
297
297
  | `firebase_symbols_pending` | `gowalk-cicd/firebase-symbols-pending.v1` | `platform`, `app_id`, `source_sha`, `artifact`, `file`, `sha256`, `status` |
298
298
  | `source_maintenance_pending` | `gowalk-cicd/source-maintenance-pending.v1` | `repo`, `base_sha`, `head_sha`, `branch`, `run_id` |
299
299
  | `apple_build_upload_failed` (error) | `gowalk-cicd/apple-build-upload-failed.v2` | `category`, `stage`, `receipt_available`, receipt identifiers (`cfBundleVersion`, `upload_id`, `file_id`, `state`, `provider_status`, `provider_code`, `provider_pointer`), `action` |
300
+ | `flutter_download_failed` (error) | `gowalk-cicd/flutter-download-failed.v1` | `phase`, `curl_exit`, `http_status`, `proxy_status`, `attempts`, `retained_bytes` |
300
301
 
301
302
  Add a field by bumping the schema version; never change the meaning of an
302
303
  existing one.
@@ -1069,6 +1070,9 @@ different bytes under the same number. Build numbers come from `/builds`,
1069
1070
  reservation is never reused; delete such a reservation only after reading it back.
1070
1071
  All requests and parts still use the Apple proxy.
1071
1072
 
1072
- Flutter SDK setup retries interrupted manifest and archive GETs on the assigned proxy. Archive attempts remain
1073
- bounded, expose only complete bytes to extraction, and never retry authentication or certificate refusals. Cache
1074
- identities remain those of the pinned upstream Flutter action.
1073
+ Flutter SDK setup retries interrupted manifest and archive GETs on the assigned proxy, with four attempts
1074
+ within 60 seconds for manifests or 30 minutes for archives, with an 8 GiB archive bound. Retries resume private bytes
1075
+ with HTTP Range; if the origin ignores Range, a bounded full restart is permitted. The completed archive
1076
+ must match the selected official manifest's SHA-256 before extraction. Authentication and certificate
1077
+ refusals remain terminal. Exhausted transfers emit `flutter_download_failed` with fixed status codes and
1078
+ retained-byte counts, without URLs or private curl stderr. Cache identities remain those of the pinned action.
@@ -1 +1 @@
1
- 1.0.88
1
+ 1.0.90
@@ -1 +1 @@
1
- 1.0.88
1
+ 1.0.90
@@ -0,0 +1,60 @@
1
+ """Private Flutter transfers publish only complete JSON or an archive matching the manifest digest."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+
11
+ MAX_ARCHIVE_BYTES = 8 * 1024 * 1024 * 1024
12
+
13
+
14
+ def archive_digest() -> str:
15
+ value = os.environ.get("GOWALK_FLUTTER_ARCHIVE_SHA256", "")
16
+ if not re.fullmatch(r"[a-fA-F0-9]{64}", value):
17
+ raise ValueError("flutter_archive_digest_required")
18
+ return value.lower()
19
+
20
+
21
+ def transfer(url: str, destination: Path, kind: str, remaining: float, env: dict) -> tuple[int, int, int]:
22
+ resume = kind == "archive" and destination.stat().st_size > 0
23
+ command = ["curl", "--disable", "--silent", "--show-error", "--fail", "--retry", "0",
24
+ "--connect-timeout", "15", "--max-time", str(min(20 if kind == "manifest" else 600, remaining)),
25
+ "--max-filesize", str(16777216 if kind == "manifest" else MAX_ARCHIVE_BYTES),
26
+ "--output", str(destination), "--write-out", "%{http_code} %{http_connect}"]
27
+ if resume:
28
+ command += ["--continue-at", "-"]
29
+ try:
30
+ result = subprocess.run([*command, url], capture_output=True, timeout=remaining, env=env)
31
+ status = result.stdout.split()
32
+ http, proxy = (map(int, status) if len(status) == 2 and all(part.isdigit() for part in status) else (0, 0))
33
+ return result.returncode, http, proxy
34
+ except subprocess.TimeoutExpired:
35
+ return 28, 0, 0
36
+
37
+
38
+ def publish(destination: Path, kind: str) -> None:
39
+ if kind == "manifest":
40
+ json.loads(destination.read_bytes())
41
+ else:
42
+ if destination.stat().st_size > MAX_ARCHIVE_BYTES:
43
+ raise ValueError("flutter_archive_size_exceeded")
44
+ digest = hashlib.sha256()
45
+ with destination.open("rb") as source:
46
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
47
+ digest.update(chunk)
48
+ if digest.hexdigest() != archive_digest():
49
+ raise ValueError("flutter_archive_checksum_mismatch")
50
+ with destination.open("rb") as source:
51
+ shutil.copyfileobj(source, sys.stdout.buffer, length=1024 * 1024)
52
+
53
+
54
+ def failure(kind: str, code: int, http: int, proxy: int, attempts: int, retained: int) -> None:
55
+ print(f"flutter_{kind}_transport_failed curl_exit={code} http_status={http} "
56
+ f"proxy_status={proxy} attempts={attempts} retained_bytes={retained}", file=sys.stderr)
57
+ note = {"schema": "gowalk-cicd/flutter-download-failed.v1", "phase": kind,
58
+ "curl_exit": code, "http_status": http, "proxy_status": proxy,
59
+ "attempts": attempts, "retained_bytes": retained}
60
+ print("::error title=flutter_download_failed::" + json.dumps(note, separators=(",", ":")), file=sys.stderr)
@@ -1,17 +1,16 @@
1
1
  """Retry the official Flutter manifest and SDK archive GETs without exposing partial JSON or proxy secrets."""
2
2
  from __future__ import annotations
3
3
 
4
- import json
5
4
  import os
6
5
  from pathlib import Path
7
6
  import re
8
- import shutil
9
- import subprocess
10
7
  import sys
11
8
  import tempfile
12
9
  import time
13
10
  from urllib.parse import urlsplit
14
11
 
12
+ import flutter_download
13
+
15
14
  RETRY_CODES = {5, 6, 7, 18, 28, 35, 52, 55, 56, 92, 95}
16
15
  RETRY_HTTP = {408, 429, 500, 502, 503, 504}
17
16
 
@@ -20,42 +19,36 @@ def download(arguments: list[str], directory: Path) -> int:
20
19
  kind = request_kind(arguments)
21
20
  if not (os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")):
22
21
  raise ValueError("flutter_download_proxy_required")
22
+ if kind == "archive":
23
+ flutter_download.archive_digest()
23
24
  child_env = {**os.environ, "NO_PROXY": "", "no_proxy": ""}
24
- deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else 600), 1, 0, 0
25
+ deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else 1800), 1, 0, 0
26
+ destination = directory / "download"
27
+ destination.touch(mode=0o600, exist_ok=False)
28
+ destination.chmod(0o600)
29
+ attempted = 0
25
30
  for attempt in range(1, 5):
26
31
  remaining = deadline - time.monotonic()
27
32
  if remaining <= 0:
28
33
  break
29
- destination = directory / f"download-{attempt}"
30
- destination.touch(mode=0o600, exist_ok=False)
31
- destination.chmod(0o600)
32
- try:
33
- result = subprocess.run([
34
- "curl", "--disable", "--silent", "--show-error", "--fail", "--retry", "0",
35
- "--connect-timeout", "15", "--max-time", str(min(20 if kind == "manifest" else 300, remaining)),
36
- "--max-filesize", str(16777216 if kind == "manifest" else 2147483648), "--output", str(destination),
37
- "--write-out", "%{http_code} %{http_connect}", arguments[-1],
38
- ], capture_output=True, timeout=remaining, env=child_env)
39
- code = result.returncode
40
- status = result.stdout.split()
41
- http, proxy_http = (map(int, status) if len(status) == 2 and all(part.isdigit() for part in status)
42
- else (0, 0))
43
- except subprocess.TimeoutExpired:
44
- code, http, proxy_http = 28, 0, 0
45
- if code == 0 and http == 200:
46
- if kind == "manifest":
47
- json.loads(destination.read_bytes())
48
- with destination.open("rb") as source:
49
- shutil.copyfileobj(source, sys.stdout.buffer, length=1024 * 1024)
34
+ attempted = attempt
35
+ code, http, proxy_http = flutter_download.transfer(arguments[-1], destination, kind, remaining, child_env)
36
+ if code == 0 and http in ({200, 206} if kind == "archive" else {200}):
37
+ flutter_download.publish(destination, kind)
50
38
  if attempt > 1:
51
39
  print(f"flutter_{kind}_recovered attempts={attempt}", file=sys.stderr)
52
40
  return 0
53
- destination.unlink(missing_ok=True)
54
- if attempt == 4 or proxy_http in {401, 403, 407} or (code not in RETRY_CODES and http not in RETRY_HTTP):
41
+ if attempt == 4 or proxy_http in {401, 403, 407} or http in {401, 403}:
42
+ break
43
+ restart = kind == "archive" and code == 33 and http == 200
44
+ if not restart and code not in RETRY_CODES and http not in RETRY_HTTP:
55
45
  break
46
+ if kind == "manifest" or restart:
47
+ destination.write_bytes(b"")
48
+ if destination.stat().st_size > flutter_download.MAX_ARCHIVE_BYTES:
49
+ raise ValueError("flutter_archive_size_exceeded")
56
50
  time.sleep(min(2 ** (attempt - 1), max(0, deadline - time.monotonic())))
57
- print(f"flutter_{kind}_transport_failed curl_exit={code} http_status={http} "
58
- f"proxy_status={proxy_http} attempts={attempt}", file=sys.stderr)
51
+ flutter_download.failure(kind, code, http, proxy_http, attempted, destination.stat().st_size)
59
52
  return code or 1
60
53
 
61
54
 
@@ -79,8 +72,10 @@ def main() -> int:
79
72
  try:
80
73
  with tempfile.TemporaryDirectory(prefix="flutter-manifest-", dir=os.environ["RUNNER_TEMP"]) as scratch:
81
74
  return download(sys.argv[1:], Path(scratch))
82
- except (ValueError, OSError, KeyError):
83
- print("flutter_manifest_invalid_request_or_response", file=sys.stderr)
75
+ except (ValueError, OSError, KeyError) as error:
76
+ safe = {"flutter_archive_digest_required", "flutter_archive_size_exceeded", "flutter_archive_checksum_mismatch"}
77
+ reason = str(error) if str(error) in safe else "flutter_manifest_invalid_request_or_response"
78
+ print("::error::" + reason, file=sys.stderr)
84
79
  return 1
85
80
 
86
81
 
@@ -1,10 +1,14 @@
1
1
  # Loaded only by the two pinned Flutter setup invocations, never the whole job.
2
2
  curl() {
3
3
  case "${!#}" in
4
- */flutter_infra_release/releases/releases_*.json|*/flutter_infra_release/releases/*.tar.xz|\
5
- */flutter_infra_release/releases/*.tar.gz|*/flutter_infra_release/releases/*.zip)
4
+ */flutter_infra_release/releases/releases_*.json)
6
5
  python3 "${GOWALK_FLUTTER_MANIFEST_HELPER:?}" "$@"
7
6
  ;;
7
+ */flutter_infra_release/releases/*.tar.xz|*/flutter_infra_release/releases/*.tar.gz|\
8
+ */flutter_infra_release/releases/*.zip)
9
+ GOWALK_FLUTTER_ARCHIVE_SHA256="$(printf '%s' "${VERSION_MANIFEST:-}" | jq -er '.sha256')" \
10
+ python3 "${GOWALK_FLUTTER_MANIFEST_HELPER:?}" "$@"
11
+ ;;
8
12
  *) command curl "$@" ;;
9
13
  esac
10
14
  }
@@ -30,6 +30,26 @@ def official_setup() -> bytes:
30
30
  return source
31
31
 
32
32
 
33
+ def response(server, mode, headers, path):
34
+ requested = headers.get(b"range", b"").decode()
35
+ server.ranges.append(requested)
36
+ complete = server.body[path] if isinstance(server.body, dict) else server.body
37
+ extra = b""
38
+ status = b"401 Unauthorized" if mode == "origin_auth" else b"200 OK"
39
+ if mode.startswith("range"):
40
+ if not requested.startswith("bytes=") or not requested.endswith("-"):
41
+ return b"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Length: 0\r\n\r\n"
42
+ start = int(requested[6:-1])
43
+ extra = f"Content-Range: bytes {start}-{len(complete) - 1}/{len(complete)}\r\n".encode()
44
+ complete, status = complete[start:], b"206 Partial Content"
45
+ body = complete[:31] if mode in {"partial", "range_partial", "large_partial", "too_large"} else complete
46
+ if mode == "range_corrupt":
47
+ body = bytes([body[0] ^ 1]) + body[1:]
48
+ size = {"large_partial": 2 * 1024 ** 3 + 1, "too_large": 9 * 1024 ** 3}.get(mode, len(complete))
49
+ return (b"HTTP/1.1 " + status + b"\r\n" + extra + b"Content-Length: "
50
+ + str(size).encode() + b"\r\nConnection: close\r\n\r\n" + body)
51
+
52
+
33
53
  class Proxy(socketserver.StreamRequestHandler):
34
54
  def handle(self):
35
55
  first = self.rfile.readline(4096).split()
@@ -53,20 +73,18 @@ class Proxy(socketserver.StreamRequestHandler):
53
73
  stream.settimeout(3)
54
74
  with stream.makefile("rb") as reader:
55
75
  request = reader.readline(4096).split()
56
- while reader.readline(4096) not in (b"\r\n", b"\n", b""):
57
- pass
76
+ headers = {}
77
+ while (line := reader.readline(4096)) not in (b"\r\n", b"\n", b""):
78
+ name, _, value = line.partition(b":")
79
+ headers[name.lower()] = value.strip()
58
80
  self.server.requests.append(request[1].decode())
59
- status = b"401 Unauthorized" if mode == "origin_auth" else b"200 OK"
60
- complete = self.server.body
61
- body = complete[:31] if mode == "partial" else complete
62
- stream.sendall(b"HTTP/1.1 " + status + b"\r\nContent-Length: "
63
- + str(len(complete)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
81
+ stream.sendall(response(self.server, mode, headers, request[1].decode()))
64
82
  except (OSError, ssl.SSLError):
65
83
  pass # An untrusted fixture certificate must be rejected by the real client.
66
84
 
67
85
 
68
86
  @contextmanager
69
- def tls_proxy(root: Path, modes: list[str], body: bytes = MANIFEST):
87
+ def tls_proxy(root: Path, modes: list[str], body: bytes | dict[str, bytes] = MANIFEST):
70
88
  cert, key = root / "cert.pem", root / "key.pem"
71
89
  command = ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
72
90
  "-keyout", str(key), "-out", str(cert), "-subj", "/CN=" + HOST,
@@ -78,6 +96,7 @@ def tls_proxy(root: Path, modes: list[str], body: bytes = MANIFEST):
78
96
  server.daemon_threads = True
79
97
  server.context, server.modes, server.body = context, list(modes), body
80
98
  server.connects, server.requests, server.lock = 0, [], threading.Lock()
99
+ server.ranges = []
81
100
  thread = threading.Thread(target=server.serve_forever, daemon=True)
82
101
  thread.start()
83
102
  try:
@@ -1,4 +1,6 @@
1
- """Real proxy/TLS transfers discard interrupted SDK bytes and preserve terminal refusals."""
1
+ """Real proxy/TLS transfers resume private SDK bytes and verify the official manifest digest."""
2
+ import hashlib
3
+ import json
2
4
  import os
3
5
  from pathlib import Path
4
6
  import subprocess
@@ -12,10 +14,12 @@ ARCHIVE = bytes(range(256)) * 512
12
14
 
13
15
 
14
16
  class ArchiveRecoveryTests(unittest.TestCase):
15
- def invoke(self, root, server, cert, hooked=True, trusted=True):
17
+ def invoke(self, root, server, cert, hooked=True, trusted=True, digest=None):
16
18
  proxy = f"http://fixture-user:fixture-secret@127.0.0.1:{server.server_address[1]}"
17
19
  env = {**os.environ, "HOME": str(root), "RUNNER_TEMP": str(root),
18
- "HTTPS_PROXY": proxy, "https_proxy": proxy, "NO_PROXY": "", "no_proxy": ""}
20
+ "HTTPS_PROXY": proxy, "https_proxy": proxy, "NO_PROXY": "", "no_proxy": "",
21
+ "VERSION_MANIFEST": json.dumps({"sha256": hashlib.sha256(ARCHIVE).hexdigest()
22
+ if digest is None else digest})}
19
23
  for key in ("BASH_ENV", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "SSL_CERT_DIR"):
20
24
  env.pop(key, None)
21
25
  if trusted:
@@ -37,10 +41,11 @@ class ArchiveRecoveryTests(unittest.TestCase):
37
41
  with tls_proxy(root, ["partial"], ARCHIVE) as (server, cert):
38
42
  original = self.invoke(root, server, cert, hooked=False)
39
43
  self.assertEqual(original.returncode, 18)
40
- with tls_proxy(root, ["partial", "ok"], ARCHIVE) as (server, cert):
44
+ with tls_proxy(root, ["partial", "range"], ARCHIVE) as (server, cert):
41
45
  result = self.invoke(root, server, cert)
42
46
  self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
43
47
  self.assertEqual(server.connects, 2)
48
+ self.assertEqual(server.ranges, ["", "bytes=31-"])
44
49
  self.assertIn(b"flutter_archive_recovered attempts=2", result.stderr)
45
50
 
46
51
  def test_archive_transport_never_retries_auth_or_untrusted_certificate(self):
@@ -52,12 +57,54 @@ class ArchiveRecoveryTests(unittest.TestCase):
52
57
  self.assertNotEqual(result.returncode, 0)
53
58
  self.assertEqual((result.stdout, server.connects), (b"", 1))
54
59
 
60
+ def test_authentication_refusal_after_partial_transfer_stops_without_publishing(self):
61
+ for mode in ("proxy_auth", "origin_auth"):
62
+ with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
63
+ root = Path(directory)
64
+ with tls_proxy(root, ["partial", mode], ARCHIVE) as (server, cert):
65
+ result = self.invoke(root, server, cert)
66
+ self.assertNotEqual(result.returncode, 0)
67
+ self.assertEqual((result.stdout, server.connects), (b"", 2))
68
+
55
69
  def test_exhausted_archive_retry_never_emits_partial_bytes(self):
56
70
  with tempfile.TemporaryDirectory() as directory:
57
71
  root = Path(directory)
58
- with tls_proxy(root, ["partial"] * 4, ARCHIVE) as (server, cert):
72
+ modes = ["partial", "range_partial", "range_partial", "range_partial"]
73
+ with tls_proxy(root, modes, ARCHIVE) as (server, cert):
59
74
  result = self.invoke(root, server, cert)
60
75
  self.assertEqual((result.returncode, result.stdout, server.connects), (18, b"", 4))
76
+ self.assertEqual(server.ranges, ["", "bytes=31-", "bytes=62-", "bytes=93-"])
77
+ self.assertIn(b'"retained_bytes":124', result.stderr)
78
+
79
+ def test_repeated_disconnects_resume_until_the_verified_archive_is_complete(self):
80
+ with tempfile.TemporaryDirectory() as directory:
81
+ root = Path(directory)
82
+ with tls_proxy(root, ["partial", "range_partial", "range"], ARCHIVE) as (server, cert):
83
+ result = self.invoke(root, server, cert)
84
+ self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
85
+ self.assertEqual(server.ranges, ["", "bytes=31-", "bytes=62-"])
86
+
87
+ def test_origin_ignoring_range_restarts_without_appending_another_full_copy(self):
88
+ with tempfile.TemporaryDirectory() as directory:
89
+ root = Path(directory)
90
+ with tls_proxy(root, ["partial", "ok", "ok"], ARCHIVE) as (server, cert):
91
+ result = self.invoke(root, server, cert)
92
+ self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
93
+ self.assertEqual(server.ranges, ["", "bytes=31-", ""])
94
+
95
+ def test_changed_resumed_bytes_fail_checksum_before_any_output(self):
96
+ with tempfile.TemporaryDirectory() as directory:
97
+ root = Path(directory)
98
+ with tls_proxy(root, ["partial", "range_corrupt"], ARCHIVE) as (server, cert):
99
+ result = self.invoke(root, server, cert)
100
+ self.assertEqual((result.returncode, result.stdout, server.connects), (1, b"", 2))
101
+
102
+ def test_missing_manifest_digest_refuses_before_download(self):
103
+ with tempfile.TemporaryDirectory() as directory:
104
+ root = Path(directory)
105
+ with tls_proxy(root, [], ARCHIVE) as (server, cert):
106
+ result = self.invoke(root, server, cert, digest="")
107
+ self.assertEqual((result.returncode, result.stdout, server.connects), (1, b"", 0))
61
108
 
62
109
 
63
110
  if __name__ == "__main__":
@@ -0,0 +1,68 @@
1
+ """The unchanged official setup supplies its selected digest and extracts only verified resumed bytes."""
2
+ import hashlib
3
+ import io
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import subprocess
8
+ import tarfile
9
+ import tempfile
10
+ import unittest
11
+
12
+ from flutter_manifest_fixture import HOST, official_setup, tls_proxy
13
+
14
+ ROOT = Path(__file__).parent
15
+ PREFIX = "/flutter_infra_release/releases/"
16
+
17
+
18
+ def fixture_archive():
19
+ buffer = io.BytesIO()
20
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
21
+ entry = tarfile.TarInfo("flutter/bin/flutter")
22
+ content = b"#!/bin/sh\nexit 0\n"
23
+ entry.size, entry.mode = len(content), 0o755
24
+ archive.addfile(entry, io.BytesIO(content))
25
+ return buffer.getvalue()
26
+
27
+
28
+ class OfficialArchiveSetupTests(unittest.TestCase):
29
+ @classmethod
30
+ def setUpClass(cls):
31
+ cls.source = official_setup()
32
+
33
+ def test_cold_setup_resumes_and_extracts_using_its_actual_manifest_scope(self):
34
+ with tempfile.TemporaryDirectory() as directory:
35
+ root = Path(directory)
36
+ setup, sdk = root / "setup.sh", root / "sdk"
37
+ setup.write_bytes(self.source)
38
+ archive = fixture_archive()
39
+ manifest = json.dumps({"releases": [{"channel": "stable", "version": "3.44.0",
40
+ "dart_sdk_arch": "x64", "hash": "a" * 40, "sha256": hashlib.sha256(archive).hexdigest(),
41
+ "archive": "fixture-sdk.tar.gz"}]}).encode()
42
+ bodies = {PREFIX + "releases_linux.json": manifest, PREFIX + "fixture-sdk.tar.gz": archive}
43
+ with tls_proxy(root, ["ok", "partial", "range"], bodies) as (server, cert):
44
+ proxy = f"http://fixture-user:fixture-secret@127.0.0.1:{server.server_address[1]}"
45
+ env = {**os.environ, "HOME": str(root), "RUNNER_TEMP": str(root), "RUNNER_OS": "Linux",
46
+ "RUNNER_ARCH": "X64", "RUNNER_TOOL_CACHE": str(root / "tools"),
47
+ "PUB_CACHE": str(root / "pub"), "FLUTTER_STORAGE_BASE_URL": "https://" + HOST,
48
+ "HTTPS_PROXY": proxy, "https_proxy": proxy, "NO_PROXY": "*", "no_proxy": "*",
49
+ "CURL_CA_BUNDLE": str(cert), "BASH_ENV": str(ROOT / "flutter_manifest_env.sh"),
50
+ "GOWALK_FLUTTER_MANIFEST_HELPER": str(ROOT / "flutter_manifest.py")}
51
+ for name in ("VERSION_MANIFEST", "GOWALK_FLUTTER_ARCHIVE_SHA256"):
52
+ env.pop(name, None)
53
+ for name in ("GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH"):
54
+ env[name] = str(root / name.lower())
55
+ result = subprocess.run(["bash", str(setup), "-n", "3.44.0", "-a", "x64",
56
+ "-c", str(sdk), "stable"], env=env, cwd=root,
57
+ capture_output=True, timeout=20)
58
+ self.assertEqual(result.returncode, 0, result.stderr)
59
+ self.assertEqual((sdk / "flutter/bin/flutter").read_bytes(), b"#!/bin/sh\nexit 0\n")
60
+ self.assertEqual(server.ranges, ["", "", "bytes=31-"])
61
+ self.assertEqual(server.requests, [PREFIX + "releases_linux.json"]
62
+ + [PREFIX + "fixture-sdk.tar.gz"] * 2)
63
+ self.assertNotIn(b"fixture-secret", result.stdout + result.stderr)
64
+ self.assertFalse(list(root.glob("flutter-manifest-*")))
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()
@@ -0,0 +1,28 @@
1
+ """Current multi-gigabyte SDK response sizes remain recoverable through the real curl transport."""
2
+ from pathlib import Path
3
+ import tempfile
4
+ import unittest
5
+
6
+ import test_flutter_archive as archive_fixture
7
+ from flutter_manifest_fixture import tls_proxy
8
+
9
+
10
+ class ArchiveSizeTests(unittest.TestCase):
11
+ def test_advertised_sdk_size_above_two_gib_does_not_prevent_recovery(self):
12
+ with tempfile.TemporaryDirectory() as directory:
13
+ root = Path(directory)
14
+ with tls_proxy(root, ["large_partial", "range"], archive_fixture.ARCHIVE) as (server, cert):
15
+ result = archive_fixture.ArchiveRecoveryTests().invoke(root, server, cert)
16
+ self.assertEqual((result.returncode, result.stdout), (0, archive_fixture.ARCHIVE))
17
+ self.assertEqual(server.ranges, ["", "bytes=31-"])
18
+
19
+ def test_archive_size_limit_still_refuses_oversized_transfers(self):
20
+ with tempfile.TemporaryDirectory() as directory:
21
+ root = Path(directory)
22
+ with tls_proxy(root, ["too_large"], archive_fixture.ARCHIVE) as (server, cert):
23
+ result = archive_fixture.ArchiveRecoveryTests().invoke(root, server, cert)
24
+ self.assertEqual((result.returncode, result.stdout, server.connects), (63, b"", 1))
25
+
26
+
27
+ if __name__ == "__main__":
28
+ unittest.main()
@@ -20,7 +20,8 @@ class ManifestPathScopeTests(unittest.TestCase):
20
20
  parent = root / "parent/flutter-setup"
21
21
  scripts = parent.parent / "scripts"
22
22
  parent.mkdir(parents=True); scripts.mkdir()
23
- for name in ("play_store_proxy.py", "flutter_manifest.py", "flutter_manifest_env.sh"):
23
+ names = ("play_store_proxy.py", "flutter_manifest.py", "flutter_download.py", "flutter_manifest_env.sh")
24
+ for name in names:
24
25
  shutil.copyfile(ROOT / "android-action/scripts" / name, scripts / name)
25
26
  env_file, output = root / "env", root / "output"
26
27
  env = {**os.environ, "GITHUB_ACTION_PATH": str(parent), "GITHUB_ENV": str(env_file),
@@ -1 +1 @@
1
- 1.0.88
1
+ 1.0.90
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.88",
3
+ "version": "1.0.90",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {