gowalk-cicd 1.0.90 → 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 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 30 minutes
159
+ It retains the account proxy and allows four attempts within 60 seconds for manifests or 20 minutes
160
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
 
@@ -1 +1 @@
1
- 1.0.90
1
+ 1.0.91
@@ -1 +1 @@
1
- 1.0.90
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
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(min(20 if kind == "manifest" else 600, remaining)),
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
- 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
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 1800), 1, 0, 0
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:
@@ -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.90
1
+ 1.0.91
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.90",
3
+ "version": "1.0.91",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {