gowalk-cicd 1.0.89 → 1.0.91
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 +4 -2
- package/README.md +8 -1
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/android-action/scripts/flutter_download.py +37 -9
- package/android-action/scripts/flutter_manifest.py +5 -1
- package/android-action/scripts/flutter_manifest_fixture.py +3 -2
- package/android-action/scripts/test_flutter_archive_size.py +28 -0
- package/android-action/scripts/test_flutter_download_budget.py +42 -0
- package/android-action/scripts/test_flutter_transfer_progress.py +81 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -156,11 +156,13 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
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
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
|
|
160
|
-
for archives. Interrupted archives resume private
|
|
159
|
+
It retains the account proxy and allows four attempts within 60 seconds for manifests or 20 minutes
|
|
160
|
+
for archives, with an 8 GiB archive bound. Interrupted archives resume private bytes with HTTP Range; an origin ignoring
|
|
161
161
|
Range permits a bounded full restart. Only complete JSON or an archive matching the selected official
|
|
162
162
|
manifest's SHA-256 enters action outputs. Authentication/certificate refusals remain terminal.
|
|
163
163
|
`flutter_download_failed` emits fixed transport codes and retained-byte counts, never curl stderr.
|
|
164
|
+
Archive progress emits retained byte counts every minute and safe results after each attempt;
|
|
165
|
+
the transfer deadline leaves room for a failure receipt before a 30-minute validation job ends.
|
|
164
166
|
- Web Release selects bundled renderer resources with `--no-web-resources-cdn` inside
|
|
165
167
|
the existing proxied build command. This is not a font or integration offline guarantee;
|
|
166
168
|
preserve app-authored font declarations, bootstrap configuration and other build inputs.
|
package/README.md
CHANGED
|
@@ -299,6 +299,13 @@ the situation and whose **message** is one line of JSON carrying a versioned
|
|
|
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
300
|
| `flutter_download_failed` (error) | `gowalk-cicd/flutter-download-failed.v1` | `phase`, `curl_exit`, `http_status`, `proxy_status`, `attempts`, `retained_bytes` |
|
|
301
301
|
|
|
302
|
+
SDK transfers also emit `flutter_download_progress` notices with schema
|
|
303
|
+
`gowalk-cicd/flutter-download-progress.v1`. Each carries `phase`, `event`, `elapsed_ms`
|
|
304
|
+
and `retained_bytes`; `attempt_finished` adds `attempt`, `curl_exit`, `http_status`
|
|
305
|
+
and `proxy_status`. In-progress notices arrive each minute; elapsed time belongs to
|
|
306
|
+
the current attempt. Archives have a 20-minute total transfer budget so a failure
|
|
307
|
+
receipt precedes a 30-minute validation job's deadline. Progress is not a success receipt.
|
|
308
|
+
|
|
302
309
|
Add a field by bumping the schema version; never change the meaning of an
|
|
303
310
|
existing one.
|
|
304
311
|
|
|
@@ -1071,7 +1078,7 @@ reservation is never reused; delete such a reservation only after reading it bac
|
|
|
1071
1078
|
All requests and parts still use the Apple proxy.
|
|
1072
1079
|
|
|
1073
1080
|
Flutter SDK setup retries interrupted manifest and archive GETs on the assigned proxy, with four attempts
|
|
1074
|
-
within 60 seconds for manifests or
|
|
1081
|
+
within 60 seconds for manifests or 30 minutes for archives, with an 8 GiB archive bound. Retries resume private bytes
|
|
1075
1082
|
with HTTP Range; if the origin ignores Range, a bounded full restart is permitted. The completed archive
|
|
1076
1083
|
must match the selected official manifest's SHA-256 before extraction. Authentication and certificate
|
|
1077
1084
|
refusals remain terminal. Exhausted transfers emit `flutter_download_failed` with fixed status codes and
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.91
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.91
|
|
@@ -7,8 +7,10 @@ import re
|
|
|
7
7
|
import shutil
|
|
8
8
|
import subprocess
|
|
9
9
|
import sys
|
|
10
|
+
import time
|
|
10
11
|
|
|
11
|
-
MAX_ARCHIVE_BYTES =
|
|
12
|
+
MAX_ARCHIVE_BYTES = 8 * 1024 * 1024 * 1024
|
|
13
|
+
PROGRESS_SECONDS = 60
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
def archive_digest() -> str:
|
|
@@ -20,19 +22,45 @@ def archive_digest() -> str:
|
|
|
20
22
|
|
|
21
23
|
def transfer(url: str, destination: Path, kind: str, remaining: float, env: dict) -> tuple[int, int, int]:
|
|
22
24
|
resume = kind == "archive" and destination.stat().st_size > 0
|
|
25
|
+
budget = min(20 if kind == "manifest" else 600, remaining)
|
|
23
26
|
command = ["curl", "--disable", "--silent", "--show-error", "--fail", "--retry", "0",
|
|
24
|
-
"--connect-timeout", "15", "--max-time", str(
|
|
27
|
+
"--connect-timeout", "15", "--max-time", str(budget),
|
|
25
28
|
"--max-filesize", str(16777216 if kind == "manifest" else MAX_ARCHIVE_BYTES),
|
|
26
29
|
"--output", str(destination), "--write-out", "%{http_code} %{http_connect}"]
|
|
27
30
|
if resume:
|
|
28
31
|
command += ["--continue-at", "-"]
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
with subprocess.Popen([*command, url], stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) as process:
|
|
33
|
+
try:
|
|
34
|
+
return wait_transfer(process, destination, kind, budget)
|
|
35
|
+
finally:
|
|
36
|
+
if process.poll() is None:
|
|
37
|
+
process.kill()
|
|
38
|
+
process.communicate()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def wait_transfer(process, destination: Path, kind: str, budget: float) -> tuple[int, int, int]:
|
|
42
|
+
started = time.monotonic()
|
|
43
|
+
while True:
|
|
44
|
+
left = budget - (time.monotonic() - started)
|
|
45
|
+
if left <= 0:
|
|
46
|
+
return 28, 0, 0
|
|
47
|
+
try:
|
|
48
|
+
output, _private_error = process.communicate(timeout=min(PROGRESS_SECONDS, left))
|
|
49
|
+
status = output.split()
|
|
50
|
+
http, proxy = (map(int, status) if len(status) == 2
|
|
51
|
+
and all(part.isdigit() for part in status) else (0, 0))
|
|
52
|
+
return process.returncode, http, proxy
|
|
53
|
+
except subprocess.TimeoutExpired:
|
|
54
|
+
if kind == "archive":
|
|
55
|
+
progress(destination, started, "transferring")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def progress(destination: Path, started: float, event: str, **fields) -> None:
|
|
59
|
+
note = {"schema": "gowalk-cicd/flutter-download-progress.v1", "phase": "archive", "event": event,
|
|
60
|
+
"elapsed_ms": round((time.monotonic() - started) * 1000),
|
|
61
|
+
"retained_bytes": destination.stat().st_size, **fields}
|
|
62
|
+
print("::notice title=flutter_download_progress::" + json.dumps(note, separators=(",", ":")),
|
|
63
|
+
file=sys.stderr, flush=True)
|
|
36
64
|
|
|
37
65
|
|
|
38
66
|
def publish(destination: Path, kind: str) -> None:
|
|
@@ -22,7 +22,7 @@ def download(arguments: list[str], directory: Path) -> int:
|
|
|
22
22
|
if kind == "archive":
|
|
23
23
|
flutter_download.archive_digest()
|
|
24
24
|
child_env = {**os.environ, "NO_PROXY": "", "no_proxy": ""}
|
|
25
|
-
deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else
|
|
25
|
+
deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else 1200), 1, 0, 0
|
|
26
26
|
destination = directory / "download"
|
|
27
27
|
destination.touch(mode=0o600, exist_ok=False)
|
|
28
28
|
destination.chmod(0o600)
|
|
@@ -32,7 +32,11 @@ def download(arguments: list[str], directory: Path) -> int:
|
|
|
32
32
|
if remaining <= 0:
|
|
33
33
|
break
|
|
34
34
|
attempted = attempt
|
|
35
|
+
started = time.monotonic()
|
|
35
36
|
code, http, proxy_http = flutter_download.transfer(arguments[-1], destination, kind, remaining, child_env)
|
|
37
|
+
if kind == "archive":
|
|
38
|
+
flutter_download.progress(destination, started, "attempt_finished", attempt=attempt,
|
|
39
|
+
curl_exit=code, http_status=http, proxy_status=proxy_http)
|
|
36
40
|
if code == 0 and http in ({200, 206} if kind == "archive" else {200}):
|
|
37
41
|
flutter_download.publish(destination, kind)
|
|
38
42
|
if attempt > 1:
|
|
@@ -42,11 +42,12 @@ def response(server, mode, headers, path):
|
|
|
42
42
|
start = int(requested[6:-1])
|
|
43
43
|
extra = f"Content-Range: bytes {start}-{len(complete) - 1}/{len(complete)}\r\n".encode()
|
|
44
44
|
complete, status = complete[start:], b"206 Partial Content"
|
|
45
|
-
body = complete[:31] if mode in {"partial", "range_partial"} else complete
|
|
45
|
+
body = complete[:31] if mode in {"partial", "range_partial", "large_partial", "too_large"} else complete
|
|
46
46
|
if mode == "range_corrupt":
|
|
47
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))
|
|
48
49
|
return (b"HTTP/1.1 " + status + b"\r\n" + extra + b"Content-Length: "
|
|
49
|
-
+ str(
|
|
50
|
+
+ str(size).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
class Proxy(socketserver.StreamRequestHandler):
|
|
@@ -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()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Archive failure remains observable before the consumer's 30-minute validation job expires."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import flutter_manifest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DownloadBudgetTests(unittest.TestCase):
|
|
15
|
+
def test_failed_archive_reports_before_the_outer_job_cancels_it(self):
|
|
16
|
+
elapsed = [0.0]
|
|
17
|
+
|
|
18
|
+
def transfer(_url, destination, _kind, remaining, _env):
|
|
19
|
+
elapsed[0] += min(600, remaining)
|
|
20
|
+
with destination.open("ab") as output:
|
|
21
|
+
output.write(b"partial")
|
|
22
|
+
return 28, 200, 200
|
|
23
|
+
|
|
24
|
+
with tempfile.TemporaryDirectory() as directory, contextlib.redirect_stderr(io.StringIO()) as error:
|
|
25
|
+
with patch.dict(os.environ, HTTPS_PROXY="http://fixture-user:fixture-secret@localhost:1",
|
|
26
|
+
GOWALK_FLUTTER_ARCHIVE_SHA256="a" * 64):
|
|
27
|
+
with patch.object(flutter_manifest.time, "monotonic", side_effect=lambda: elapsed[0]), \
|
|
28
|
+
patch.object(flutter_manifest.time, "sleep", side_effect=lambda seconds: None), \
|
|
29
|
+
patch.object(flutter_manifest.flutter_download, "transfer", side_effect=transfer):
|
|
30
|
+
result = flutter_manifest.download(["--connect-timeout", "15", "--retry", "5",
|
|
31
|
+
"https://fixture.invalid/flutter_infra_release/releases/stable/linux/sdk.tar.xz"],
|
|
32
|
+
Path(directory))
|
|
33
|
+
self.assertEqual(result, 28)
|
|
34
|
+
self.assertLess(elapsed[0], 1800 - 60, "A failure receipt must precede the enclosing job deadline")
|
|
35
|
+
receipt = next(line.split("::", 2)[2] for line in error.getvalue().splitlines()
|
|
36
|
+
if line.startswith("::error title=flutter_download_failed::"))
|
|
37
|
+
self.assertGreater(json.loads(receipt)["retained_bytes"], 0)
|
|
38
|
+
self.assertNotIn("fixture-secret", error.getvalue())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
unittest.main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Private child transfers expose byte progress and terminate within their own deadline."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
import flutter_download
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TransferProgressTests(unittest.TestCase):
|
|
18
|
+
def invoke(self, script, remaining):
|
|
19
|
+
children = []
|
|
20
|
+
popen = subprocess.Popen
|
|
21
|
+
with tempfile.TemporaryDirectory() as directory, contextlib.redirect_stderr(io.StringIO()) as error:
|
|
22
|
+
destination = Path(directory) / "archive"
|
|
23
|
+
destination.touch()
|
|
24
|
+
|
|
25
|
+
def child(_command, **kwargs):
|
|
26
|
+
process = popen([sys.executable, "-c", script, str(destination)], **kwargs)
|
|
27
|
+
children.append(process)
|
|
28
|
+
return process
|
|
29
|
+
|
|
30
|
+
with patch.object(flutter_download.subprocess, "Popen", side_effect=child), \
|
|
31
|
+
patch.object(flutter_download, "PROGRESS_SECONDS", .03):
|
|
32
|
+
result = flutter_download.transfer("https://fixture.invalid/sdk.tar.xz", destination,
|
|
33
|
+
"archive", remaining, dict(os.environ))
|
|
34
|
+
data = destination.read_bytes()
|
|
35
|
+
self.assertTrue(all(process.poll() is not None for process in children))
|
|
36
|
+
self.assertNotIn("fixture-secret", error.getvalue())
|
|
37
|
+
notices = [json.loads(line.split("::", 2)[2]) for line in error.getvalue().splitlines()]
|
|
38
|
+
self.assertTrue(notices)
|
|
39
|
+
self.assertTrue(any(note["retained_bytes"] > 0 for note in notices))
|
|
40
|
+
self.assertTrue(all(note["event"] == "transferring" for note in notices))
|
|
41
|
+
return result, data
|
|
42
|
+
|
|
43
|
+
def test_progress_does_not_publish_private_child_stderr_or_partial_archive(self):
|
|
44
|
+
script = ("import pathlib,sys,time; p=pathlib.Path(sys.argv[1]); p.write_bytes(b'partial'); "
|
|
45
|
+
"sys.stderr.write('fixture-secret'); time.sleep(.2); p.write_bytes(b'complete'); "
|
|
46
|
+
"print('206 200',end='')")
|
|
47
|
+
result, data = self.invoke(script, 2)
|
|
48
|
+
self.assertEqual((result, data), ((0, 206, 200), b"complete"))
|
|
49
|
+
|
|
50
|
+
def test_deadline_kills_only_the_owned_transfer_and_preserves_resume_bytes(self):
|
|
51
|
+
script = ("import pathlib,sys,time; pathlib.Path(sys.argv[1]).write_bytes(b'partial'); "
|
|
52
|
+
"sys.stderr.write('fixture-secret'); time.sleep(5)")
|
|
53
|
+
started = time.monotonic()
|
|
54
|
+
result, data = self.invoke(script, .2)
|
|
55
|
+
self.assertLess(time.monotonic() - started, 2)
|
|
56
|
+
self.assertEqual((result, data), ((28, 0, 0), b"partial"))
|
|
57
|
+
|
|
58
|
+
def test_progress_failure_does_not_leave_the_owned_child_running(self):
|
|
59
|
+
popen, children = subprocess.Popen, []
|
|
60
|
+
|
|
61
|
+
def child(_command, **kwargs):
|
|
62
|
+
process = popen([sys.executable, "-c", "import time; time.sleep(5)"], **kwargs)
|
|
63
|
+
children.append(process)
|
|
64
|
+
return process
|
|
65
|
+
|
|
66
|
+
started = time.monotonic()
|
|
67
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
68
|
+
destination = Path(directory) / "archive"
|
|
69
|
+
destination.touch()
|
|
70
|
+
with patch.object(flutter_download.subprocess, "Popen", side_effect=child), \
|
|
71
|
+
patch.object(flutter_download, "PROGRESS_SECONDS", .03), \
|
|
72
|
+
patch.object(flutter_download, "progress", side_effect=OSError("closed output")):
|
|
73
|
+
with self.assertRaises(OSError):
|
|
74
|
+
flutter_download.transfer("https://fixture.invalid/sdk.tar.xz", destination,
|
|
75
|
+
"archive", 2, dict(os.environ))
|
|
76
|
+
self.assertLess(time.monotonic() - started, 2)
|
|
77
|
+
self.assertTrue(all(process.poll() is not None for process in children))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.91
|