gowalk-cicd 1.0.96 → 1.0.97

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
@@ -165,6 +165,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
165
165
  `flutter_download_failed` emits fixed transport codes and retained-byte counts, never curl stderr.
166
166
  Archive progress emits retained byte counts every minute and safe results after each attempt;
167
167
  the transfer deadline leaves room for a failure receipt before a 30-minute validation job ends.
168
+ With SDK caching enabled, failed archive transfers preserve public bytes in a job-owned,
169
+ digest-scoped directory. Separate optional cache restore/save steps retain them between runs;
170
+ they never expose partial bytes as an installed SDK. Successful or corrupt completed archives
171
+ are removed from that partial cache. Full SDK cache identities and provider transport stay unchanged.
168
172
  - Web Release selects bundled renderer resources with `--no-web-resources-cdn` inside
169
173
  the existing proxied build command. This is not a font or integration offline guarantee;
170
174
  preserve app-authored font declarations, bootstrap configuration and other build inputs.
package/README.md CHANGED
@@ -321,6 +321,11 @@ and `retained_bytes`; `attempt_finished` adds `attempt`, `curl_exit`, `http_stat
321
321
  and `proxy_status`. In-progress notices arrive each minute; elapsed time belongs to
322
322
  the current attempt. Archives have a 20-minute total transfer budget so a failure
323
323
  receipt precedes a 30-minute validation job's deadline. Progress is not a success receipt.
324
+ When SDK caching is enabled, an exhausted transfer preserves its partial public archive for
325
+ the next run under a separate release-specific cache key. Resumption still uses the selected
326
+ official manifest digest and the configured provider proxy; only a verified complete archive
327
+ can reach SDK extraction. Partial-cache restore uses the existing one-minute cache segment
328
+ bound. Optional cache operations do not extend the transfer deadline or turn a failed installation green.
324
329
 
325
330
  Add a field by bumping the schema version; never change the meaning of an
326
331
  existing one.
@@ -1 +1 @@
1
- 1.0.96
1
+ 1.0.97
@@ -1 +1 @@
1
- 1.0.96
1
+ 1.0.97
@@ -138,6 +138,18 @@ runs:
138
138
  with:
139
139
  path: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
140
140
  key: ${{ steps.flutter-action.outputs.PUB-CACHE-KEY }}-${{ hashFiles('**/pubspec.lock') }}
141
+ - name: Restore interrupted Flutter archive
142
+ if: ${{ inputs.cache == 'true' && inputs.dry-run != 'true' && steps.cache-flutter.outputs.cache-hit != 'true' }}
143
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
144
+ continue-on-error: true
145
+ env:
146
+ NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
147
+ SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
148
+ with:
149
+ path: ${{ runner.temp }}/gowalk-flutter-archives
150
+ key: >-
151
+ flutter-archive-v1-${{ steps.flutter-action.outputs.CACHE-KEY }}-${{ github.run_id }}-${{ github.run_attempt }}
152
+ restore-keys: flutter-archive-v1-${{ steps.flutter-action.outputs.CACHE-KEY }}-
141
153
  - name: Install or reuse the resolved Flutter SDK
142
154
  id: install
143
155
  if: ${{ inputs.dry-run != 'true' }}
@@ -145,6 +157,8 @@ runs:
145
157
  env:
146
158
  BASH_ENV: ${{ steps.transport.outputs.manifest_env }}
147
159
  GOWALK_FLUTTER_MANIFEST_HELPER: ${{ steps.transport.outputs.manifest_helper }}
160
+ GOWALK_FLUTTER_ARCHIVE_CACHE: >-
161
+ ${{ inputs.cache == 'true' && format('{0}/gowalk-flutter-archives', runner.temp) || '' }}
148
162
  with:
149
163
  channel: ${{ steps.flutter-action.outputs.CHANNEL }}
150
164
  flutter-version: ${{ steps.flutter-action.outputs.VERSION }}
@@ -158,6 +172,18 @@ runs:
158
172
  pub-cache-path: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
159
173
  dry-run: 'false'
160
174
  git-source: ${{ inputs.git-source }}
175
+ - name: Preserve interrupted Flutter archive
176
+ if: >-
177
+ ${{ !cancelled() && inputs.cache == 'true' && steps.install.outcome == 'failure'
178
+ && steps.scope.outcome == 'success' }}
179
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
180
+ continue-on-error: true
181
+ env:
182
+ NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
183
+ with:
184
+ path: ${{ runner.temp }}/gowalk-flutter-archives
185
+ key: >-
186
+ flutter-archive-v1-${{ steps.flutter-action.outputs.CACHE-KEY }}-${{ github.run_id }}-${{ github.run_attempt }}
161
187
  # A later app failure must not discard a successfully installed, expensive SDK.
162
188
  - name: Save installed Flutter SDK
163
189
  if: >-
@@ -0,0 +1,47 @@
1
+ """Keep only public SDK archive bytes in the job's optional, digest-scoped download cache."""
2
+ from contextlib import contextmanager
3
+ import os
4
+ from pathlib import Path
5
+ import tempfile
6
+
7
+ import flutter_download
8
+
9
+
10
+ def cached_directory() -> Path:
11
+ root = Path(os.environ["GOWALK_FLUTTER_ARCHIVE_CACHE"])
12
+ temporary = Path(os.environ["RUNNER_TEMP"]).resolve(strict=True)
13
+ if root.is_symlink() or root.parent.resolve(strict=True) != temporary:
14
+ raise ValueError("flutter_archive_cache_scope_invalid")
15
+ root.mkdir(mode=0o700, exist_ok=True)
16
+ directory = root / flutter_download.archive_digest()
17
+ if directory.is_symlink():
18
+ raise ValueError("flutter_archive_cache_scope_invalid")
19
+ directory.mkdir(mode=0o700, exist_ok=True)
20
+ destination = directory / "download"
21
+ if destination.is_symlink() or (destination.exists() and not destination.is_file()):
22
+ raise ValueError("flutter_archive_cache_scope_invalid")
23
+ if destination.exists() and destination.stat().st_size > flutter_download.MAX_ARCHIVE_BYTES:
24
+ raise ValueError("flutter_archive_size_exceeded")
25
+ return directory
26
+
27
+
28
+ @contextmanager
29
+ def workspace(kind: str):
30
+ if kind != "archive" or not os.environ.get("GOWALK_FLUTTER_ARCHIVE_CACHE"):
31
+ with tempfile.TemporaryDirectory(prefix="flutter-manifest-", dir=os.environ["RUNNER_TEMP"]) as scratch:
32
+ yield Path(scratch)
33
+ return
34
+ directory = cached_directory()
35
+ try:
36
+ yield directory
37
+ except ValueError as error:
38
+ if str(error) == "flutter_archive_checksum_mismatch":
39
+ (directory / "download").unlink(missing_ok=True)
40
+ raise
41
+
42
+
43
+ def reuse_complete(destination: Path, kind: str) -> bool:
44
+ if kind != "archive" or not destination.stat().st_size or not flutter_download.archive_matches(destination):
45
+ return False
46
+ flutter_download.publish(destination, kind)
47
+ return True
@@ -63,18 +63,21 @@ def progress(destination: Path, started: float, event: str, **fields) -> None:
63
63
  file=sys.stderr, flush=True)
64
64
 
65
65
 
66
+ def archive_matches(destination: Path) -> bool:
67
+ if destination.stat().st_size > MAX_ARCHIVE_BYTES:
68
+ raise ValueError("flutter_archive_size_exceeded")
69
+ digest = hashlib.sha256()
70
+ with destination.open("rb") as source:
71
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
72
+ digest.update(chunk)
73
+ return digest.hexdigest() == archive_digest()
74
+
75
+
66
76
  def publish(destination: Path, kind: str) -> None:
67
77
  if kind == "manifest":
68
78
  json.loads(destination.read_bytes())
69
- else:
70
- if destination.stat().st_size > MAX_ARCHIVE_BYTES:
71
- raise ValueError("flutter_archive_size_exceeded")
72
- digest = hashlib.sha256()
73
- with destination.open("rb") as source:
74
- for chunk in iter(lambda: source.read(1024 * 1024), b""):
75
- digest.update(chunk)
76
- if digest.hexdigest() != archive_digest():
77
- raise ValueError("flutter_archive_checksum_mismatch")
79
+ elif not archive_matches(destination):
80
+ raise ValueError("flutter_archive_checksum_mismatch")
78
81
  with destination.open("rb") as source:
79
82
  shutil.copyfileobj(source, sys.stdout.buffer, length=1024 * 1024)
80
83
 
@@ -5,11 +5,11 @@ import os
5
5
  from pathlib import Path
6
6
  import re
7
7
  import sys
8
- import tempfile
9
8
  import time
10
9
  from urllib.parse import urlsplit
11
10
 
12
11
  import flutter_download
12
+ import flutter_archive_cache
13
13
 
14
14
  RETRY_CODES = {5, 6, 7, 18, 28, 35, 52, 55, 56, 92, 95}
15
15
  RETRY_HTTP = {408, 429, 500, 502, 503, 504}
@@ -24,8 +24,10 @@ def download(arguments: list[str], directory: Path) -> int:
24
24
  child_env = {**os.environ, "NO_PROXY": "", "no_proxy": ""}
25
25
  deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else 1200), 1, 0, 0
26
26
  destination = directory / "download"
27
- destination.touch(mode=0o600, exist_ok=False)
27
+ destination.touch(mode=0o600, exist_ok=True)
28
28
  destination.chmod(0o600)
29
+ if flutter_archive_cache.reuse_complete(destination, kind):
30
+ return 0
29
31
  attempted = 0
30
32
  for attempt in range(1, 5):
31
33
  remaining = deadline - time.monotonic()
@@ -74,8 +76,11 @@ def request_kind(arguments):
74
76
 
75
77
  def main() -> int:
76
78
  try:
77
- with tempfile.TemporaryDirectory(prefix="flutter-manifest-", dir=os.environ["RUNNER_TEMP"]) as scratch:
78
- return download(sys.argv[1:], Path(scratch))
79
+ with flutter_archive_cache.workspace(request_kind(sys.argv[1:])) as scratch:
80
+ result = download(sys.argv[1:], scratch)
81
+ if result == 0:
82
+ (scratch / "download").unlink(missing_ok=True)
83
+ return result
79
84
  except (ValueError, OSError, KeyError) as error:
80
85
  safe = {"flutter_archive_digest_required", "flutter_archive_size_exceeded", "flutter_archive_checksum_mismatch"}
81
86
  reason = str(error) if str(error) in safe else "flutter_manifest_invalid_request_or_response"
@@ -0,0 +1,102 @@
1
+ """A later job resumes retained public SDK bytes without bypassing digest or account checks."""
2
+ import hashlib
3
+ import os
4
+ from pathlib import Path
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from unittest.mock import patch
11
+
12
+ from flutter_manifest_fixture import tls_proxy
13
+ import test_flutter_archive as archive_fixture
14
+
15
+ ARCHIVE, ROOT = archive_fixture.ARCHIVE, archive_fixture.ROOT
16
+ DIGEST = hashlib.sha256(ARCHIVE).hexdigest()
17
+
18
+
19
+ def invoke(root, server, cert, **kwargs):
20
+ with patch.dict(os.environ, {"GOWALK_FLUTTER_ARCHIVE_CACHE": str(root / "gowalk-flutter-archives")}):
21
+ return archive_fixture.ArchiveRecoveryTests().invoke(root, server, cert, **kwargs)
22
+
23
+
24
+ class ArchiveCacheTests(unittest.TestCase):
25
+ def test_later_job_resumes_after_the_previous_job_exhausted_its_retries(self):
26
+ with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:
27
+ root, resumed = Path(first), Path(second)
28
+ with tls_proxy(root, ["partial", "range_partial", "range_partial", "range_partial"],
29
+ ARCHIVE) as (server, cert):
30
+ result = invoke(root, server, cert)
31
+ self.assertEqual((result.returncode, result.stdout), (18, b""))
32
+ partial = root / "gowalk-flutter-archives" / DIGEST / "download"
33
+ self.assertEqual(partial.read_bytes(), ARCHIVE[:124])
34
+ shutil.copytree(root / "gowalk-flutter-archives", resumed / "gowalk-flutter-archives")
35
+ with tls_proxy(resumed, ["range"], ARCHIVE) as (server, cert):
36
+ result = invoke(resumed, server, cert)
37
+ self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
38
+ self.assertEqual(server.ranges, ["bytes=124-"])
39
+ self.assertFalse((resumed / "gowalk-flutter-archives" / DIGEST / "download").exists())
40
+
41
+ def test_complete_cached_archive_is_verified_without_another_archive_request(self):
42
+ with tempfile.TemporaryDirectory() as directory:
43
+ root = Path(directory)
44
+ partial = root / "gowalk-flutter-archives" / DIGEST / "download"
45
+ partial.parent.mkdir(parents=True)
46
+ partial.write_bytes(ARCHIVE)
47
+ with tls_proxy(root, [], ARCHIVE) as (server, cert):
48
+ result = invoke(root, server, cert)
49
+ self.assertEqual((result.returncode, result.stdout, server.connects), (0, ARCHIVE, 0))
50
+
51
+ def test_complete_cached_bytes_cannot_bypass_a_missing_proxy(self):
52
+ with tempfile.TemporaryDirectory() as directory:
53
+ root = Path(directory)
54
+ partial = root / "gowalk-flutter-archives" / DIGEST / "download"
55
+ partial.parent.mkdir(parents=True)
56
+ partial.write_bytes(ARCHIVE)
57
+ env = {**os.environ, "RUNNER_TEMP": str(root), "GOWALK_FLUTTER_ARCHIVE_SHA256": DIGEST,
58
+ "GOWALK_FLUTTER_ARCHIVE_CACHE": str(root / "gowalk-flutter-archives"),
59
+ "HTTPS_PROXY": "", "https_proxy": ""}
60
+ url = "https://provider.invalid/flutter_infra_release/releases/stable/sdk.tar.xz"
61
+ result = subprocess.run([sys.executable, str(ROOT / "flutter_manifest.py"),
62
+ "--connect-timeout", "15", "--retry", "5", url],
63
+ env=env, capture_output=True, timeout=10)
64
+ self.assertEqual((result.returncode, result.stdout), (1, b""))
65
+ self.assertEqual(partial.read_bytes(), ARCHIVE)
66
+
67
+ def test_changed_digest_cannot_reuse_another_release_partial(self):
68
+ with tempfile.TemporaryDirectory() as directory:
69
+ root = Path(directory)
70
+ foreign = root / "gowalk-flutter-archives" / ("a" * 64) / "download"
71
+ foreign.parent.mkdir(parents=True)
72
+ foreign.write_bytes(b"unrelated release")
73
+ with tls_proxy(root, ["ok"], ARCHIVE) as (server, cert):
74
+ result = invoke(root, server, cert)
75
+ self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
76
+ self.assertEqual(server.ranges, [""])
77
+ self.assertEqual(foreign.read_bytes(), b"unrelated release")
78
+
79
+ def test_corrupted_restored_partial_fails_verification_and_is_not_cached_again(self):
80
+ with tempfile.TemporaryDirectory() as directory:
81
+ root = Path(directory)
82
+ partial = root / "gowalk-flutter-archives" / DIGEST / "download"
83
+ partial.parent.mkdir(parents=True)
84
+ partial.write_bytes(b"wrong bytes")
85
+ with tls_proxy(root, ["range"], ARCHIVE) as (server, cert):
86
+ result = invoke(root, server, cert)
87
+ self.assertEqual((result.returncode, result.stdout), (1, b""))
88
+ self.assertEqual(server.ranges, ["bytes=11-"])
89
+ self.assertFalse(partial.exists())
90
+
91
+ def test_symlinked_cache_does_not_read_or_write_another_directory(self):
92
+ with tempfile.TemporaryDirectory() as directory, tempfile.TemporaryDirectory() as outside:
93
+ root = Path(directory)
94
+ (root / "gowalk-flutter-archives").symlink_to(outside, target_is_directory=True)
95
+ with tls_proxy(root, [], ARCHIVE) as (server, cert):
96
+ result = invoke(root, server, cert)
97
+ self.assertEqual((result.returncode, result.stdout, server.connects), (1, b"", 0))
98
+ self.assertEqual(list(Path(outside).iterdir()), [])
99
+
100
+
101
+ if __name__ == "__main__":
102
+ unittest.main()
@@ -24,7 +24,11 @@ class BootstrapWiringTests(unittest.TestCase):
24
24
  self.assertNotIn("continue-on-error", step)
25
25
  self.assertEqual(checked, 3)
26
26
  setup = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
27
- guard, scope, resolve, sdk_cache, pub_cache, install, save = setup["runs"]["steps"]
27
+ steps = setup["runs"]["steps"]
28
+ by_id = {step.get("id"): step for step in steps}
29
+ guard, scope, resolve, sdk_cache, pub_cache, install = (
30
+ by_id[name] for name in ("transport", "scope", "flutter-action", "cache-flutter", "cache-pub", "install"))
31
+ save = next(step for step in steps if step["name"] == "Save installed Flutter SDK")
28
32
  self.assertIn('play_store_proxy.py" --bootstrap-env', guard["run"])
29
33
  self.assertNotIn("continue-on-error", guard)
30
34
  self.assertTrue(scope["continue-on-error"])
@@ -32,9 +36,13 @@ class BootstrapWiringTests(unittest.TestCase):
32
36
  self.assertEqual(resolve["with"]["dry-run"], "true")
33
37
  for step in (resolve, install):
34
38
  self.assertEqual(step["uses"], "subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2")
35
- self.assertEqual(step["env"], {
39
+ expected_env = {
36
40
  "BASH_ENV": "${{ steps.transport.outputs.manifest_env }}",
37
- "GOWALK_FLUTTER_MANIFEST_HELPER": "${{ steps.transport.outputs.manifest_helper }}"})
41
+ "GOWALK_FLUTTER_MANIFEST_HELPER": "${{ steps.transport.outputs.manifest_helper }}"}
42
+ if step is install:
43
+ expected_env["GOWALK_FLUTTER_ARCHIVE_CACHE"] = (
44
+ "${{ inputs.cache == 'true' && format('{0}/gowalk-flutter-archives', runner.temp) || '' }}")
45
+ self.assertEqual(step["env"], expected_env)
38
46
  self.assertEqual(step["with"]["cache"], "false")
39
47
  self.assertEqual(step["with"]["pub-cache"], "false")
40
48
  self.assertNotIn("continue-on-error", step)
@@ -66,7 +74,8 @@ class BootstrapWiringTests(unittest.TestCase):
66
74
  self.assertEqual(restore["uses"],
67
75
  "actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25")
68
76
  install = next(step for step in steps if step.get("id") == "install")
69
- save = steps[steps.index(install) + 1]
77
+ save = next(step for step in steps if step["name"] == "Save installed Flutter SDK")
78
+ self.assertGreater(steps.index(save), steps.index(install))
70
79
  self.assertEqual(save["uses"], "actions/cache/save@caa296126883cff596d87d8935842f9db880ef25")
71
80
  self.assertEqual(save["with"], restore["with"])
72
81
  self.assertTrue(save["continue-on-error"])
@@ -1 +1 @@
1
- 1.0.96
1
+ 1.0.97
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.96",
3
+ "version": "1.0.97",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {