gowalk-cicd 1.0.88 → 1.0.89
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 +6 -4
- package/README.md +7 -3
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/android-action/scripts/flutter_download.py +60 -0
- package/android-action/scripts/flutter_manifest.py +25 -30
- package/android-action/scripts/flutter_manifest_env.sh +6 -2
- package/android-action/scripts/flutter_manifest_fixture.py +26 -8
- package/android-action/scripts/test_flutter_archive.py +52 -5
- package/android-action/scripts/test_flutter_archive_setup.py +68 -0
- package/android-action/scripts/test_flutter_manifest_scope.py +2 -1
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
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
|
|
160
|
-
|
|
161
|
-
|
|
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 600 seconds
|
|
160
|
+
for archives. Interrupted archives resume private partial 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
|
|
1073
|
-
|
|
1074
|
-
|
|
1073
|
+
Flutter SDK setup retries interrupted manifest and archive GETs on the assigned proxy, with four attempts
|
|
1074
|
+
within 60 seconds for manifests or 600 seconds for archives. Archive retries resume retained 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.
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.89
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.89
|
|
@@ -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 = 2147483648
|
|
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 300, 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
25
|
deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else 600), 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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,25 @@ 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"} else complete
|
|
46
|
+
if mode == "range_corrupt":
|
|
47
|
+
body = bytes([body[0] ^ 1]) + body[1:]
|
|
48
|
+
return (b"HTTP/1.1 " + status + b"\r\n" + extra + b"Content-Length: "
|
|
49
|
+
+ str(len(complete)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
|
50
|
+
|
|
51
|
+
|
|
33
52
|
class Proxy(socketserver.StreamRequestHandler):
|
|
34
53
|
def handle(self):
|
|
35
54
|
first = self.rfile.readline(4096).split()
|
|
@@ -53,20 +72,18 @@ class Proxy(socketserver.StreamRequestHandler):
|
|
|
53
72
|
stream.settimeout(3)
|
|
54
73
|
with stream.makefile("rb") as reader:
|
|
55
74
|
request = reader.readline(4096).split()
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
headers = {}
|
|
76
|
+
while (line := reader.readline(4096)) not in (b"\r\n", b"\n", b""):
|
|
77
|
+
name, _, value = line.partition(b":")
|
|
78
|
+
headers[name.lower()] = value.strip()
|
|
58
79
|
self.server.requests.append(request[1].decode())
|
|
59
|
-
|
|
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)
|
|
80
|
+
stream.sendall(response(self.server, mode, headers, request[1].decode()))
|
|
64
81
|
except (OSError, ssl.SSLError):
|
|
65
82
|
pass # An untrusted fixture certificate must be rejected by the real client.
|
|
66
83
|
|
|
67
84
|
|
|
68
85
|
@contextmanager
|
|
69
|
-
def tls_proxy(root: Path, modes: list[str], body: bytes = MANIFEST):
|
|
86
|
+
def tls_proxy(root: Path, modes: list[str], body: bytes | dict[str, bytes] = MANIFEST):
|
|
70
87
|
cert, key = root / "cert.pem", root / "key.pem"
|
|
71
88
|
command = ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
|
|
72
89
|
"-keyout", str(key), "-out", str(cert), "-subj", "/CN=" + HOST,
|
|
@@ -78,6 +95,7 @@ def tls_proxy(root: Path, modes: list[str], body: bytes = MANIFEST):
|
|
|
78
95
|
server.daemon_threads = True
|
|
79
96
|
server.context, server.modes, server.body = context, list(modes), body
|
|
80
97
|
server.connects, server.requests, server.lock = 0, [], threading.Lock()
|
|
98
|
+
server.ranges = []
|
|
81
99
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
82
100
|
thread.start()
|
|
83
101
|
try:
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
"""Real proxy/TLS transfers
|
|
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", "
|
|
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
|
-
|
|
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()
|
|
@@ -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
|
-
|
|
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.
|
|
1
|
+
1.0.89
|