gowalk-cicd 1.0.72 → 1.0.73

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
@@ -145,6 +145,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
145
145
  `NO_PROXY`. The preloader snapshot under `RUNNER_TEMP` also covers official post/save after
146
146
  consumer autoupdate changes the action directory. The installer delivers this development
147
147
  tooling for native iOS repositories too; it does not require an Android app.
148
+ - Flutter manifest GET recovery is scoped to the two pinned setup invocations through `BASH_ENV`.
149
+ It retains the account proxy, retries transient TLS/transport errors within 60 seconds, and emits
150
+ only complete JSON. Partial responses and private curl stderr never enter action outputs;
151
+ authentication/certificate failures remain terminal. SDK archive calls retain upstream behavior.
148
152
  - Flutter setup scopes `SEGMENT_DOWNLOAD_TIMEOUT_MINS=1` to the composite action's optional
149
153
  SDK/pub cache restores. This bounds each cache segment, not the whole cache or SDK download.
150
154
  A cache timeout becomes a cache miss; normal proxied SDK setup remains mandatory and its
@@ -1 +1 @@
1
- 1.0.72
1
+ 1.0.73
@@ -1 +1 @@
1
- 1.0.72
1
+ 1.0.73
@@ -84,9 +84,13 @@ runs:
84
84
  using: composite
85
85
  steps:
86
86
  - name: Require provider transport
87
+ id: transport
87
88
  shell: bash
88
89
  run: |
89
90
  python3 "$GITHUB_ACTION_PATH/../scripts/play_store_proxy.py" --bootstrap-env
91
+ printf 'manifest_env=%s\nmanifest_helper=%s\n' \
92
+ "$GITHUB_ACTION_PATH/../scripts/flutter_manifest_env.sh" \
93
+ "$GITHUB_ACTION_PATH/../scripts/flutter_manifest.py" >> "$GITHUB_OUTPUT"
90
94
  - name: Retain the cache process scope
91
95
  id: scope
92
96
  if: ${{ inputs.cache == 'true' || inputs.pub-cache == 'true' }}
@@ -96,6 +100,9 @@ runs:
96
100
  - name: Resolve Flutter release and cache identities
97
101
  id: flutter-action
98
102
  uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
103
+ env:
104
+ BASH_ENV: ${{ steps.transport.outputs.manifest_env }}
105
+ GOWALK_FLUTTER_MANIFEST_HELPER: ${{ steps.transport.outputs.manifest_helper }}
99
106
  with:
100
107
  channel: ${{ inputs.channel }}
101
108
  flutter-version: ${{ inputs.flutter-version }}
@@ -134,6 +141,9 @@ runs:
134
141
  - name: Install or reuse the resolved Flutter SDK
135
142
  if: ${{ inputs.dry-run != 'true' }}
136
143
  uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
144
+ env:
145
+ BASH_ENV: ${{ steps.transport.outputs.manifest_env }}
146
+ GOWALK_FLUTTER_MANIFEST_HELPER: ${{ steps.transport.outputs.manifest_helper }}
137
147
  with:
138
148
  channel: ${{ steps.flutter-action.outputs.CHANNEL }}
139
149
  flutter-version: ${{ steps.flutter-action.outputs.VERSION }}
@@ -0,0 +1,76 @@
1
+ """Retry the official Flutter manifest GET without exposing partial JSON or proxy secrets."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ from urllib.parse import urlsplit
13
+
14
+ RETRY_CODES = {5, 6, 7, 18, 28, 35, 52, 55, 56, 92, 95}
15
+ RETRY_HTTP = {408, 429, 500, 502, 503, 504}
16
+
17
+
18
+ def download(arguments: list[str], directory: Path) -> int:
19
+ # This is the exact GET made by the pinned upstream setup.sh. Refuse new semantics.
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")
27
+ if not (os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")):
28
+ raise ValueError("flutter_manifest_proxy_required")
29
+ child_env = {**os.environ, "NO_PROXY": "", "no_proxy": ""}
30
+ deadline, code, http, proxy_http = time.monotonic() + 60, 1, 0, 0
31
+ for attempt in range(1, 5):
32
+ remaining = deadline - time.monotonic()
33
+ if remaining <= 0:
34
+ break
35
+ destination = directory / f"manifest-{attempt}.json"
36
+ destination.touch(mode=0o600, exist_ok=False)
37
+ destination.chmod(0o600)
38
+ try:
39
+ result = subprocess.run([
40
+ "curl", "--disable", "--silent", "--show-error", "--fail", "--retry", "0",
41
+ "--connect-timeout", "15", "--max-time", str(min(20, remaining)),
42
+ "--max-filesize", "16777216", "--output", str(destination),
43
+ "--write-out", "%{http_code} %{http_connect}", arguments[-1],
44
+ ], capture_output=True, timeout=remaining, env=child_env)
45
+ code = result.returncode
46
+ status = result.stdout.split()
47
+ http, proxy_http = (map(int, status) if len(status) == 2 and all(part.isdigit() for part in status)
48
+ else (0, 0))
49
+ except subprocess.TimeoutExpired:
50
+ code, http, proxy_http = 28, 0, 0
51
+ if code == 0 and http == 200:
52
+ body = destination.read_bytes()
53
+ json.loads(body) # A successful transport must deliver complete JSON.
54
+ sys.stdout.buffer.write(body)
55
+ if attempt > 1:
56
+ print(f"flutter_manifest_recovered attempts={attempt}", file=sys.stderr)
57
+ return 0
58
+ if attempt == 4 or proxy_http in {401, 403, 407} or (code not in RETRY_CODES and http not in RETRY_HTTP):
59
+ break
60
+ time.sleep(min(2 ** (attempt - 1), max(0, deadline - time.monotonic())))
61
+ print(f"flutter_manifest_transport_failed curl_exit={code} http_status={http} "
62
+ f"proxy_status={proxy_http} attempts={attempt}", file=sys.stderr)
63
+ return code or 1
64
+
65
+
66
+ def main() -> int:
67
+ try:
68
+ with tempfile.TemporaryDirectory(prefix="flutter-manifest-", dir=os.environ["RUNNER_TEMP"]) as scratch:
69
+ return download(sys.argv[1:], Path(scratch))
70
+ except (ValueError, OSError, KeyError):
71
+ print("flutter_manifest_invalid_request_or_response", file=sys.stderr)
72
+ return 1
73
+
74
+
75
+ if __name__ == "__main__":
76
+ sys.exit(main())
@@ -0,0 +1,9 @@
1
+ # Loaded only by the two pinned Flutter setup invocations, never the whole job.
2
+ curl() {
3
+ case "${!#}" in
4
+ */flutter_infra_release/releases/releases_*.json)
5
+ python3 "${GOWALK_FLUTTER_MANIFEST_HELPER:?}" "$@"
6
+ ;;
7
+ *) command curl "$@" ;;
8
+ esac
9
+ }
@@ -0,0 +1,85 @@
1
+ """An owned CONNECT proxy terminates only fixture TLS; it never dials a destination."""
2
+ from contextlib import contextmanager
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import socket
8
+ import socketserver
9
+ import ssl
10
+ import struct
11
+ import subprocess
12
+ import threading
13
+ import urllib.request
14
+
15
+ from test_flutter_cache_setup import SETUP_SHA, SETUP_URL
16
+
17
+ HOST = "manifest.fixture.invalid"
18
+ MANIFEST = json.dumps({"releases": [{"channel": "stable", "version": "3.44.0", "dart_sdk_arch": "x64",
19
+ "hash": "a" * 40, "sha256": "b" * 64, "archive": "owned-sdk.tar.xz"}]}).encode()
20
+
21
+
22
+ def official_setup() -> bytes:
23
+ fixture = os.environ.get("GOWALK_CACHE_ACTION_FIXTURES")
24
+ if fixture:
25
+ source = (Path(fixture) / "flutter-setup.sh").read_bytes()
26
+ else:
27
+ with urllib.request.urlopen(SETUP_URL, timeout=30) as response:
28
+ source = response.read(128 * 1024)
29
+ assert hashlib.sha256(source).hexdigest() == SETUP_SHA
30
+ return source
31
+
32
+
33
+ class Proxy(socketserver.StreamRequestHandler):
34
+ def handle(self):
35
+ first = self.rfile.readline(4096).split()
36
+ while self.rfile.readline(4096) not in (b"\r\n", b"\n", b""):
37
+ pass
38
+ if first != [b"CONNECT", (HOST + ":443").encode(), b"HTTP/1.1"]:
39
+ return
40
+ with self.server.lock:
41
+ self.server.connects += 1
42
+ mode = self.server.modes.pop(0)
43
+ if mode == "proxy_auth":
44
+ self.wfile.write(b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n")
45
+ return
46
+ self.wfile.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
47
+ self.wfile.flush()
48
+ if mode == "reset":
49
+ self.connection.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
50
+ return
51
+ try:
52
+ with self.server.context.wrap_socket(self.connection, server_side=True) as stream:
53
+ stream.settimeout(3)
54
+ with stream.makefile("rb") as reader:
55
+ request = reader.readline(4096).split()
56
+ while reader.readline(4096) not in (b"\r\n", b"\n", b""):
57
+ pass
58
+ self.server.requests.append(request[1].decode())
59
+ status = b"401 Unauthorized" if mode == "origin_auth" else b"200 OK"
60
+ body = MANIFEST[:31] if mode == "partial" else MANIFEST
61
+ stream.sendall(b"HTTP/1.1 " + status + b"\r\nContent-Length: "
62
+ + str(len(MANIFEST)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
63
+ except (OSError, ssl.SSLError):
64
+ pass # An untrusted fixture certificate must be rejected by the real client.
65
+
66
+
67
+ @contextmanager
68
+ def tls_proxy(root: Path, modes: list[str]):
69
+ cert, key = root / "cert.pem", root / "key.pem"
70
+ command = ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
71
+ "-keyout", str(key), "-out", str(cert), "-subj", "/CN=" + HOST,
72
+ "-addext", "subjectAltName=DNS:" + HOST]
73
+ subprocess.run(command, check=True, capture_output=True, timeout=10)
74
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
75
+ context.load_cert_chain(cert, key)
76
+ server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Proxy)
77
+ server.daemon_threads = True
78
+ server.context, server.modes = context, list(modes)
79
+ server.connects, server.requests, server.lock = 0, [], threading.Lock()
80
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
81
+ thread.start()
82
+ try:
83
+ yield server, cert
84
+ finally:
85
+ server.shutdown(); server.server_close(); thread.join(3)
@@ -0,0 +1,119 @@
1
+ """Real curl and jq execute the byte-identical official setup through owned TLS failures."""
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, MANIFEST, official_setup, tls_proxy
9
+
10
+ ROOT = Path(__file__).parent
11
+
12
+
13
+ class ManifestRecoveryTests(unittest.TestCase):
14
+ @classmethod
15
+ def setUpClass(cls):
16
+ cls.source = official_setup()
17
+
18
+ def setUp(self):
19
+ self.directory = tempfile.TemporaryDirectory()
20
+ self.addCleanup(self.directory.cleanup)
21
+ self.root = Path(self.directory.name)
22
+ self.setup = self.root / "setup.sh"
23
+ self.setup.write_bytes(self.source)
24
+ (self.root / "test").mkdir()
25
+ (self.root / "test/releases_linux.json").write_bytes(MANIFEST)
26
+ # Local curl preferences may never disable the assigned proxy or certificate checks.
27
+ (self.root / ".curlrc").write_text('noproxy = "*"\ninsecure\n')
28
+
29
+ def invoke(self, server, cert, *, hooked=True, trusted=True, test_mode=False, bound=True):
30
+ proxy = f"http://fixture-user:fixture-secret@127.0.0.1:{server.server_address[1]}"
31
+ env = {**os.environ, "HOME": str(self.root), "RUNNER_TEMP": str(self.root), "RUNNER_OS": "Linux",
32
+ "RUNNER_ARCH": "X64", "RUNNER_TOOL_CACHE": str(self.root / "tools"),
33
+ "PUB_CACHE": str(self.root / "pub"), "FLUTTER_STORAGE_BASE_URL": "https://" + HOST,
34
+ "GITHUB_OUTPUT": str(self.root / "output"), "HTTPS_PROXY": proxy, "https_proxy": proxy,
35
+ "NO_PROXY": "*", "no_proxy": "*"}
36
+ for key in ("BASH_ENV", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "SSL_CERT_DIR"):
37
+ env.pop(key, None)
38
+ if not bound:
39
+ env.pop("HTTPS_PROXY"); env.pop("https_proxy")
40
+ if trusted:
41
+ env["CURL_CA_BUNDLE"] = str(cert)
42
+ if hooked:
43
+ env.update(BASH_ENV=str(ROOT / "flutter_manifest_env.sh"),
44
+ GOWALK_FLUTTER_MANIFEST_HELPER=str(ROOT / "flutter_manifest.py"))
45
+ else:
46
+ # The baseline deliberately runs the original curl defaults, without stale local bypass settings.
47
+ env.update(HOME=str(self.root / "absent-home"), NO_PROXY="", no_proxy="")
48
+ args = ["bash", str(self.setup), "-p", "-n", "3.44.0", "-a", "x64", "-k", "custom-:version:-:hash:"]
49
+ result = subprocess.run([*args, *(["-t"] if test_mode else []), "stable"], env=env,
50
+ capture_output=True, text=True, timeout=20)
51
+ self.assertNotIn("fixture-secret", result.stdout + result.stderr)
52
+ self.assertFalse(list(self.root.glob("flutter-manifest-*")))
53
+ return result
54
+
55
+ def test_original_tls_exit35_recovers_without_changing_official_cache_identity(self):
56
+ with tls_proxy(self.root, ["reset"]) as (server, cert):
57
+ original = self.invoke(server, cert, hooked=False)
58
+ self.assertEqual(original.returncode, 35)
59
+ self.assertEqual((server.connects, server.requests), (1, []))
60
+ with tls_proxy(self.root, ["reset", "ok"]) as (server, cert):
61
+ result = self.invoke(server, cert)
62
+ self.assertEqual(result.returncode, 0, result.stderr)
63
+ self.assertEqual((server.connects, len(server.requests)), (2, 1))
64
+ self.assertIn("flutter_manifest_recovered attempts=2", result.stderr)
65
+ baseline = self.invoke(server, cert, test_mode=True)
66
+ self.assertEqual((self.root / "output").read_text(), baseline.stdout)
67
+ self.assertEqual(result.stdout, "")
68
+
69
+ def test_partial_body_is_discarded_before_the_next_complete_json(self):
70
+ with tls_proxy(self.root, ["partial", "ok"]) as (server, cert):
71
+ result = self.invoke(server, cert)
72
+ self.assertEqual(result.returncode, 0, result.stderr)
73
+ self.assertEqual((server.connects, len(server.requests)), (2, 2))
74
+ self.assertIn("VERSION=3.44.0", (self.root / "output").read_text())
75
+ self.assertEqual(result.stdout, "")
76
+
77
+ def test_proxy_auth_refuses_once_without_any_destination_request(self):
78
+ with tls_proxy(self.root, ["proxy_auth"]) as (server, cert):
79
+ result = self.invoke(server, cert)
80
+ self.assertNotEqual(result.returncode, 0)
81
+ self.assertEqual((server.connects, server.requests), (1, []))
82
+ self.assertIn("proxy_status=407 attempts=1", result.stderr)
83
+ self.assertEqual(result.stdout, "")
84
+
85
+ def test_untrusted_certificate_never_retries_or_uses_curlrc_insecure(self):
86
+ with tls_proxy(self.root, ["ok"]) as (server, cert):
87
+ result = self.invoke(server, cert, trusted=False)
88
+ self.assertEqual(result.returncode, 60)
89
+ self.assertEqual((server.connects, server.requests), (1, []))
90
+ self.assertIn("attempts=1", result.stderr)
91
+ self.assertEqual(result.stdout, "")
92
+
93
+ def test_origin_auth_is_terminal_without_exposing_its_body(self):
94
+ with tls_proxy(self.root, ["origin_auth"]) as (server, cert):
95
+ result = self.invoke(server, cert)
96
+ self.assertEqual(result.returncode, 22)
97
+ self.assertEqual((server.connects, len(server.requests)), (1, 1))
98
+ self.assertIn("http_status=401", result.stderr)
99
+ self.assertIn("attempts=1", result.stderr)
100
+ self.assertEqual(result.stdout, "")
101
+
102
+ def test_repeated_tls_resets_stop_after_four_private_attempts(self):
103
+ with tls_proxy(self.root, ["reset"] * 4) as (server, cert):
104
+ result = self.invoke(server, cert)
105
+ self.assertEqual(result.returncode, 35)
106
+ self.assertEqual((server.connects, server.requests), (4, []))
107
+ self.assertIn("attempts=4", result.stderr)
108
+ self.assertEqual(result.stdout, "")
109
+
110
+ def test_missing_proxy_refuses_before_any_connection(self):
111
+ with tls_proxy(self.root, []) as (server, cert):
112
+ result = self.invoke(server, cert, bound=False)
113
+ self.assertEqual(result.returncode, 1)
114
+ self.assertEqual((server.connects, server.requests), (0, []))
115
+ self.assertEqual(result.stdout, "")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()
@@ -0,0 +1,41 @@
1
+ """The parent composite freezes helper paths before a nested action changes its context."""
2
+ import os
3
+ from pathlib import Path
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import unittest
8
+
9
+ import yaml
10
+
11
+ ROOT = Path(__file__).resolve().parents[2]
12
+
13
+
14
+ class ManifestPathScopeTests(unittest.TestCase):
15
+ def test_real_transport_step_captures_parent_paths_without_exporting_a_job_hook(self):
16
+ action = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
17
+ guard = action["runs"]["steps"][0]
18
+ with tempfile.TemporaryDirectory(prefix="manifest parent ") as directory:
19
+ root = Path(directory)
20
+ parent = root / "parent/flutter-setup"
21
+ scripts = parent.parent / "scripts"
22
+ parent.mkdir(parents=True); scripts.mkdir()
23
+ for name in ("play_store_proxy.py", "flutter_manifest.py", "flutter_manifest_env.sh"):
24
+ shutil.copyfile(ROOT / "android-action/scripts" / name, scripts / name)
25
+ env_file, output = root / "env", root / "output"
26
+ env = {**os.environ, "GITHUB_ACTION_PATH": str(parent), "GITHUB_ENV": str(env_file),
27
+ "GITHUB_OUTPUT": str(output), "RUNNER_TEMP": str(root),
28
+ "GOOGLE_STORE_PROXY_URL": "http://fixture-user:fixture-secret@127.0.0.1:9"}
29
+ result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", guard["run"]], env=env,
30
+ capture_output=True, text=True, timeout=10)
31
+ self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "", ""))
32
+ paths = dict(line.split("=", 1) for line in output.read_text().splitlines())
33
+ self.assertEqual(Path(paths["manifest_env"]).resolve(), (scripts / "flutter_manifest_env.sh").resolve())
34
+ self.assertEqual(Path(paths["manifest_helper"]).resolve(), (scripts / "flutter_manifest.py").resolve())
35
+ self.assertNotIn("BASH_ENV", env_file.read_text())
36
+ self.assertNotIn("GOWALK_FLUTTER_MANIFEST_HELPER", env_file.read_text())
37
+ self.assertNotIn("fixture-secret", output.read_text())
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()
@@ -31,10 +31,18 @@ class BootstrapWiringTests(unittest.TestCase):
31
31
  self.assertIn("--snapshot", scope["run"])
32
32
  self.assertEqual(resolve["with"]["dry-run"], "true")
33
33
  for step in (resolve, install):
34
- self.assertNotIn("env", step)
34
+ self.assertEqual(step["uses"], "subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2")
35
+ self.assertEqual(step["env"], {
36
+ "BASH_ENV": "${{ steps.transport.outputs.manifest_env }}",
37
+ "GOWALK_FLUTTER_MANIFEST_HELPER": "${{ steps.transport.outputs.manifest_helper }}"})
35
38
  self.assertEqual(step["with"]["cache"], "false")
36
39
  self.assertEqual(step["with"]["pub-cache"], "false")
37
40
  self.assertNotIn("continue-on-error", step)
41
+ self.assertNotIn("BASH_ENV", setup.get("env", {}))
42
+ for step in (guard, scope, sdk_cache, pub_cache):
43
+ self.assertNotIn("BASH_ENV", step.get("env", {}))
44
+ self.assertEqual(guard["id"], "transport")
45
+ self.assertIn('>> "$GITHUB_OUTPUT"', guard["run"])
38
46
  for step in (sdk_cache, pub_cache):
39
47
  self.assertTrue(step["continue-on-error"])
40
48
  self.assertIn("steps.scope.outputs.preloader", step["env"]["NODE_OPTIONS"])
@@ -1 +1 @@
1
- 1.0.72
1
+ 1.0.73
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.72",
3
+ "version": "1.0.73",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {