gowalk-cicd 1.0.83 → 1.0.85
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/README.md +4 -0
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/android-action/scripts/flutter_manifest.py +31 -19
- package/android-action/scripts/flutter_manifest_env.sh +2 -1
- package/android-action/scripts/flutter_manifest_fixture.py +5 -4
- package/android-action/scripts/jvm_proxy_relay.py +4 -1
- package/android-action/scripts/test_flutter_archive.py +64 -0
- package/android-action/scripts/test_jvm_proxy_cleanup.py +44 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1052,3 +1052,7 @@ different bytes under the same number. Build numbers come from `/builds`,
|
|
|
1052
1052
|
`/preReleaseVersions` **and** `/apps/{id}/buildUploads`, so an unfinished or refused
|
|
1053
1053
|
reservation is never reused; delete such a reservation only after reading it back.
|
|
1054
1054
|
All requests and parts still use the Apple proxy.
|
|
1055
|
+
|
|
1056
|
+
Flutter SDK setup retries interrupted manifest and archive GETs on the assigned proxy. Archive attempts remain
|
|
1057
|
+
bounded, expose only complete bytes to extraction, and never retry authentication or certificate refusals. Cache
|
|
1058
|
+
identities remain those of the pinned upstream Flutter action.
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.85
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.85
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
"""Retry the official Flutter manifest
|
|
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
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
import re
|
|
8
|
+
import shutil
|
|
8
9
|
import subprocess
|
|
9
10
|
import sys
|
|
10
11
|
import tempfile
|
|
@@ -16,30 +17,23 @@ RETRY_HTTP = {408, 429, 500, 502, 503, 504}
|
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def download(arguments: list[str], directory: Path) -> int:
|
|
19
|
-
|
|
20
|
-
if arguments[:-1] != ["--silent", "--connect-timeout", "15", "--retry", "5"]:
|
|
21
|
-
raise ValueError("flutter_manifest_arguments_unrecognized")
|
|
22
|
-
url = urlsplit(arguments[-1])
|
|
23
|
-
if (url.scheme != "https" or not url.hostname or url.username or url.password
|
|
24
|
-
or url.query or url.fragment or not re.fullmatch(
|
|
25
|
-
r"/flutter_infra_release/releases/releases_(linux|macos|windows)\.json", url.path)):
|
|
26
|
-
raise ValueError("flutter_manifest_url_unrecognized")
|
|
20
|
+
kind = request_kind(arguments)
|
|
27
21
|
if not (os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")):
|
|
28
|
-
raise ValueError("
|
|
22
|
+
raise ValueError("flutter_download_proxy_required")
|
|
29
23
|
child_env = {**os.environ, "NO_PROXY": "", "no_proxy": ""}
|
|
30
|
-
deadline, code, http, proxy_http = time.monotonic() + 60, 1, 0, 0
|
|
24
|
+
deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else 600), 1, 0, 0
|
|
31
25
|
for attempt in range(1, 5):
|
|
32
26
|
remaining = deadline - time.monotonic()
|
|
33
27
|
if remaining <= 0:
|
|
34
28
|
break
|
|
35
|
-
destination = directory / f"
|
|
29
|
+
destination = directory / f"download-{attempt}"
|
|
36
30
|
destination.touch(mode=0o600, exist_ok=False)
|
|
37
31
|
destination.chmod(0o600)
|
|
38
32
|
try:
|
|
39
33
|
result = subprocess.run([
|
|
40
34
|
"curl", "--disable", "--silent", "--show-error", "--fail", "--retry", "0",
|
|
41
|
-
"--connect-timeout", "15", "--max-time", str(min(20, remaining)),
|
|
42
|
-
"--max-filesize",
|
|
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),
|
|
43
37
|
"--write-out", "%{http_code} %{http_connect}", arguments[-1],
|
|
44
38
|
], capture_output=True, timeout=remaining, env=child_env)
|
|
45
39
|
code = result.returncode
|
|
@@ -49,20 +43,38 @@ def download(arguments: list[str], directory: Path) -> int:
|
|
|
49
43
|
except subprocess.TimeoutExpired:
|
|
50
44
|
code, http, proxy_http = 28, 0, 0
|
|
51
45
|
if code == 0 and http == 200:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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)
|
|
55
50
|
if attempt > 1:
|
|
56
|
-
print(f"
|
|
51
|
+
print(f"flutter_{kind}_recovered attempts={attempt}", file=sys.stderr)
|
|
57
52
|
return 0
|
|
53
|
+
destination.unlink(missing_ok=True)
|
|
58
54
|
if attempt == 4 or proxy_http in {401, 403, 407} or (code not in RETRY_CODES and http not in RETRY_HTTP):
|
|
59
55
|
break
|
|
60
56
|
time.sleep(min(2 ** (attempt - 1), max(0, deadline - time.monotonic())))
|
|
61
|
-
print(f"
|
|
57
|
+
print(f"flutter_{kind}_transport_failed curl_exit={code} http_status={http} "
|
|
62
58
|
f"proxy_status={proxy_http} attempts={attempt}", file=sys.stderr)
|
|
63
59
|
return code or 1
|
|
64
60
|
|
|
65
61
|
|
|
62
|
+
def request_kind(arguments):
|
|
63
|
+
if not arguments:
|
|
64
|
+
raise ValueError("flutter_download_arguments_missing")
|
|
65
|
+
url = urlsplit(arguments[-1])
|
|
66
|
+
if (url.scheme != "https" or not url.hostname or url.username or url.password
|
|
67
|
+
or url.query or url.fragment or ".." in url.path):
|
|
68
|
+
raise ValueError("flutter_download_url_unrecognized")
|
|
69
|
+
if arguments[:-1] == ["--silent", "--connect-timeout", "15", "--retry", "5"] and re.fullmatch(
|
|
70
|
+
r"/flutter_infra_release/releases/releases_(linux|macos|windows)\.json", url.path):
|
|
71
|
+
return "manifest"
|
|
72
|
+
if arguments[:-1] == ["--connect-timeout", "15", "--retry", "5"] and re.fullmatch(
|
|
73
|
+
r"/flutter_infra_release/releases/[A-Za-z0-9_./+-]+\.(tar\.xz|tar\.gz|zip)", url.path):
|
|
74
|
+
return "archive"
|
|
75
|
+
raise ValueError("flutter_download_arguments_unrecognized")
|
|
76
|
+
|
|
77
|
+
|
|
66
78
|
def main() -> int:
|
|
67
79
|
try:
|
|
68
80
|
with tempfile.TemporaryDirectory(prefix="flutter-manifest-", dir=os.environ["RUNNER_TEMP"]) as scratch:
|
|
@@ -1,7 +1,8 @@
|
|
|
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
|
|
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)
|
|
5
6
|
python3 "${GOWALK_FLUTTER_MANIFEST_HELPER:?}" "$@"
|
|
6
7
|
;;
|
|
7
8
|
*) command curl "$@" ;;
|
|
@@ -57,15 +57,16 @@ class Proxy(socketserver.StreamRequestHandler):
|
|
|
57
57
|
pass
|
|
58
58
|
self.server.requests.append(request[1].decode())
|
|
59
59
|
status = b"401 Unauthorized" if mode == "origin_auth" else b"200 OK"
|
|
60
|
-
|
|
60
|
+
complete = self.server.body
|
|
61
|
+
body = complete[:31] if mode == "partial" else complete
|
|
61
62
|
stream.sendall(b"HTTP/1.1 " + status + b"\r\nContent-Length: "
|
|
62
|
-
+ str(len(
|
|
63
|
+
+ str(len(complete)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
|
63
64
|
except (OSError, ssl.SSLError):
|
|
64
65
|
pass # An untrusted fixture certificate must be rejected by the real client.
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
@contextmanager
|
|
68
|
-
def tls_proxy(root: Path, modes: list[str]):
|
|
69
|
+
def tls_proxy(root: Path, modes: list[str], body: bytes = MANIFEST):
|
|
69
70
|
cert, key = root / "cert.pem", root / "key.pem"
|
|
70
71
|
command = ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
|
|
71
72
|
"-keyout", str(key), "-out", str(cert), "-subj", "/CN=" + HOST,
|
|
@@ -75,7 +76,7 @@ def tls_proxy(root: Path, modes: list[str]):
|
|
|
75
76
|
context.load_cert_chain(cert, key)
|
|
76
77
|
server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Proxy)
|
|
77
78
|
server.daemon_threads = True
|
|
78
|
-
server.context, server.modes = context, list(modes)
|
|
79
|
+
server.context, server.modes, server.body = context, list(modes), body
|
|
79
80
|
server.connects, server.requests, server.lock = 0, [], threading.Lock()
|
|
80
81
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
81
82
|
thread.start()
|
|
@@ -39,11 +39,14 @@ class Relay:
|
|
|
39
39
|
self.ready.set()
|
|
40
40
|
if self.server:
|
|
41
41
|
self.server.close()
|
|
42
|
-
|
|
42
|
+
# Python 3.12+ waits for active clients in Server.wait_closed().
|
|
43
|
+
# Their handlers own the connections, so cancel and drain them first.
|
|
43
44
|
tasks = asyncio.all_tasks(self.loop)
|
|
44
45
|
for task in tasks:
|
|
45
46
|
task.cancel()
|
|
46
47
|
self.loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
|
|
48
|
+
if self.server:
|
|
49
|
+
self.loop.run_until_complete(self.server.wait_closed())
|
|
47
50
|
self.loop.close()
|
|
48
51
|
|
|
49
52
|
async def _upstream(self, request: bytes):
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Real proxy/TLS transfers discard interrupted SDK bytes and preserve terminal refusals."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from flutter_manifest_fixture import HOST, tls_proxy
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).parent
|
|
11
|
+
ARCHIVE = bytes(range(256)) * 512
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ArchiveRecoveryTests(unittest.TestCase):
|
|
15
|
+
def invoke(self, root, server, cert, hooked=True, trusted=True):
|
|
16
|
+
proxy = f"http://fixture-user:fixture-secret@127.0.0.1:{server.server_address[1]}"
|
|
17
|
+
env = {**os.environ, "HOME": str(root), "RUNNER_TEMP": str(root),
|
|
18
|
+
"HTTPS_PROXY": proxy, "https_proxy": proxy, "NO_PROXY": "", "no_proxy": ""}
|
|
19
|
+
for key in ("BASH_ENV", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "SSL_CERT_DIR"):
|
|
20
|
+
env.pop(key, None)
|
|
21
|
+
if trusted:
|
|
22
|
+
env["CURL_CA_BUNDLE"] = str(cert)
|
|
23
|
+
if hooked:
|
|
24
|
+
env.update(BASH_ENV=str(ROOT / "flutter_manifest_env.sh"), NO_PROXY="*", no_proxy="*",
|
|
25
|
+
GOWALK_FLUTTER_MANIFEST_HELPER=str(ROOT / "flutter_manifest.py"))
|
|
26
|
+
(root / ".curlrc").write_text('noproxy = "*"\ninsecure\n')
|
|
27
|
+
url = f"https://{HOST}/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
|
28
|
+
result = subprocess.run(["bash", "-c", 'curl --connect-timeout 15 --retry 5 "$1"', "bash", url],
|
|
29
|
+
env=env, capture_output=True, timeout=20)
|
|
30
|
+
self.assertNotIn(b"fixture-secret", result.stdout + result.stderr)
|
|
31
|
+
self.assertFalse(list(root.glob("flutter-manifest-*")))
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
def test_original_partial_exit18_recovers_to_only_the_complete_archive(self):
|
|
35
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
36
|
+
root = Path(directory)
|
|
37
|
+
with tls_proxy(root, ["partial"], ARCHIVE) as (server, cert):
|
|
38
|
+
original = self.invoke(root, server, cert, hooked=False)
|
|
39
|
+
self.assertEqual(original.returncode, 18)
|
|
40
|
+
with tls_proxy(root, ["partial", "ok"], ARCHIVE) as (server, cert):
|
|
41
|
+
result = self.invoke(root, server, cert)
|
|
42
|
+
self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
|
|
43
|
+
self.assertEqual(server.connects, 2)
|
|
44
|
+
self.assertIn(b"flutter_archive_recovered attempts=2", result.stderr)
|
|
45
|
+
|
|
46
|
+
def test_archive_transport_never_retries_auth_or_untrusted_certificate(self):
|
|
47
|
+
for mode, trusted in [("proxy_auth", True), ("origin_auth", True), ("ok", False)]:
|
|
48
|
+
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
|
|
49
|
+
root = Path(directory)
|
|
50
|
+
with tls_proxy(root, [mode], ARCHIVE) as (server, cert):
|
|
51
|
+
result = self.invoke(root, server, cert, trusted=trusted)
|
|
52
|
+
self.assertNotEqual(result.returncode, 0)
|
|
53
|
+
self.assertEqual((result.stdout, server.connects), (b"", 1))
|
|
54
|
+
|
|
55
|
+
def test_exhausted_archive_retry_never_emits_partial_bytes(self):
|
|
56
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
57
|
+
root = Path(directory)
|
|
58
|
+
with tls_proxy(root, ["partial"] * 4, ARCHIVE) as (server, cert):
|
|
59
|
+
result = self.invoke(root, server, cert)
|
|
60
|
+
self.assertEqual((result.returncode, result.stdout, server.connects), (18, b"", 4))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
unittest.main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Open client sockets must not keep the command's owned relay alive on Python 3.12+."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import socket
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
import jvm_proxy_relay as relay
|
|
7
|
+
from test_jvm_proxy_relay import upstream
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def accepted():
|
|
11
|
+
while not any(task.get_coro().__name__ == "_handle" for task in asyncio.all_tasks()):
|
|
12
|
+
await asyncio.sleep(0.001)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RelayCleanupTests(unittest.TestCase):
|
|
16
|
+
def test_shutdown_cancels_open_clients_before_waiting_for_server_connections(self):
|
|
17
|
+
with upstream() as (proxy, _seen):
|
|
18
|
+
for connected in (False, True):
|
|
19
|
+
with self.subTest(established_tunnel=connected):
|
|
20
|
+
owned = relay.Relay(proxy)
|
|
21
|
+
owned.thread.start()
|
|
22
|
+
self.assertTrue(owned.ready.wait(5))
|
|
23
|
+
client = socket.create_connection(("127.0.0.1", owned.port), timeout=5)
|
|
24
|
+
try:
|
|
25
|
+
if connected:
|
|
26
|
+
client.sendall(b"CONNECT unresolvable.invalid:443 HTTP/1.1\r\nHost: ignored\r\n\r\n")
|
|
27
|
+
self.assertIn(b"200", client.recv(4096))
|
|
28
|
+
else:
|
|
29
|
+
# Synchronize on acceptance while deliberately leaving the request unfinished.
|
|
30
|
+
asyncio.run_coroutine_threadsafe(accepted(), owned.loop).result(timeout=5)
|
|
31
|
+
owned.loop.call_soon_threadsafe(owned.loop.stop)
|
|
32
|
+
owned.thread.join(timeout=2)
|
|
33
|
+
self.assertFalse(owned.thread.is_alive(), "relay cleanup waits on a client it must close")
|
|
34
|
+
self.assertTrue(owned.loop.is_closed())
|
|
35
|
+
self.assertEqual(client.recv(4096), b"")
|
|
36
|
+
with self.assertRaises(OSError):
|
|
37
|
+
socket.create_connection(("127.0.0.1", owned.port), timeout=0.2)
|
|
38
|
+
finally:
|
|
39
|
+
client.close()
|
|
40
|
+
owned.thread.join(timeout=5)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.85
|