gowalk-cicd 1.0.98 → 1.0.100

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
@@ -183,6 +183,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
183
183
  Save only on a cache miss with caching enabled, a real successful install and verified cache
184
184
  scope. Preserve resolved keys/paths and the official pinned restore/save programs. Failed or
185
185
  dry-run setup never publishes an SDK cache; pub dependency caching retains its job-end behavior.
186
+ - Exact `flutter-version` inputs may reuse previously resolved public release metadata before SDK
187
+ cache lookup. Metadata is scoped to platform, origin, channel, architecture and full version;
188
+ URL/pin/schema/release fields are checked before reuse. Blank, wildcard and version-file inputs
189
+ resolve online. Missing or invalid metadata falls back to the required proxied GET; missing
190
+ account transport still refuses even with a warm cache. SDK keys and checksum checks stay unchanged.
186
191
  - Gradle caches use explicit checksum-pinned restore/save actions, each optional and bounded
187
192
  to two minutes. Preserve setup-java's key inputs and absolute cache paths to reuse existing
188
193
  caches. Java setup, builds and store uploads remain mandatory; save runs after delivery.
@@ -249,6 +254,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
249
254
  dependency download or Google proxy requirement. Initial local lock resolution may use the
250
255
  explicit `--before-pods --initial-pods-lock` guard; CI never uses that development exception.
251
256
  These checks cover the declared native dependency graph, not arbitrary custom build-script traffic.
257
+ `native_bundle.py` honors the selected project's committed Gemfile/lock, falling back to
258
+ the repository root; missing gems install through the same relay with a frozen bundle.
259
+ Both Flutter configuration and the Pods helper use that bundle in their child process,
260
+ preserving Podfile.lock and the parent job environment. Deliver the workflow wrapper and
261
+ action helper together; action-only updates cannot repair earlier custom configuration steps.
252
262
  - Version stamping is per-build-system: Flutter takes `--build-number`, Gradle
253
263
  has no equivalent, so `set_gradle_version()` rewrites the literal in the
254
264
  checkout the way the iOS action patches the pbxproj. Never commit that edit.
package/README.md CHANGED
@@ -48,6 +48,16 @@ and keeps existing cache keys. A successfully installed SDK is saved before app
48
48
  checks run, so a later test/build failure does not force another cold SDK download.
49
49
  Failed setup never saves an SDK cache; pub dependencies retain their job-end save.
50
50
 
51
+ Native dependency preparation uses a committed `Gemfile` and `Gemfile.lock` beside
52
+ the selected iOS project, falling back to the repository root. It installs missing
53
+ bundle gems through the assigned Google relay and runs Pods with `bundle exec`.
54
+ The Flutter workflow uses that same bundle before configuration can invoke Pods;
55
+ Ruby settings remain scoped to those commands. Provide a Ruby/Bundler runtime
56
+ compatible with the app's lockfile. Changed or uncommitted locks fail before delivery.
57
+ When adopting this update, retain the `native_bundle.py` configuration wrapper in
58
+ custom workflows as well as updating the action files. Apps without a Gemfile keep
59
+ their existing CocoaPods runtime.
60
+
51
61
  ## Flutter web release
52
62
 
53
63
  The conditional `deploy-web.yml` workflow runs on changes to Flutter source,
@@ -1 +1 @@
1
- 1.0.98
1
+ 1.0.100
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """Use the app's committed Ruby bundle for native preparation without changing the job environment."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+
15
+ import native_dependency_guard as guard
16
+ from native_pods_diagnostics import refuse
17
+ import native_pods_process
18
+
19
+
20
+ def command(argv: list[str], cwd: Path, log) -> int:
21
+ try:
22
+ return native_pods_process.command(argv, cwd, log)
23
+ except native_pods_process.Interrupted:
24
+ raise guard.Refused("native_pods_operation_interrupted") from None
25
+ except (OSError, RuntimeError, subprocess.SubprocessError):
26
+ raise guard.Refused("native_pods_process_unverified") from None
27
+
28
+
29
+ def committed(root: Path, path: Path, kind: str) -> str:
30
+ if not path.is_file() or path.is_symlink() or path.stat().st_size > 2 * 1024 * 1024:
31
+ raise guard.Refused(f"native_{kind}_required")
32
+ if not path.resolve().is_relative_to(root):
33
+ raise guard.Refused("native_bundle_path_unverified")
34
+ result = subprocess.run(["git", "show", "HEAD:" + path.relative_to(root).as_posix()],
35
+ cwd=root, capture_output=True, check=False, timeout=15)
36
+ if result.returncode or result.stdout != path.read_bytes():
37
+ raise guard.Refused(f"native_{kind}_commit_required")
38
+ return hashlib.sha256(result.stdout).hexdigest()
39
+
40
+
41
+ def unchanged(snapshot: dict[Path, str]) -> None:
42
+ for path, digest in snapshot.items():
43
+ if not path.is_file() or path.is_symlink() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
44
+ raise guard.Refused("native_bundle_inputs_changed")
45
+
46
+
47
+ def prepare(root: Path, folder: Path, proxy_helper: Path, log) -> tuple[list[str], dict[Path, str]]:
48
+ gemfile = next((p for p in (folder / "Gemfile", root / "Gemfile") if p.exists() or p.is_symlink()), None)
49
+ if gemfile is None:
50
+ return [], {}
51
+ snapshot = {gemfile: committed(root, gemfile, "gemfile"),
52
+ gemfile.with_name("Gemfile.lock"): committed(root, gemfile.with_name("Gemfile.lock"), "gemfile_lock")}
53
+ prefix = ["/usr/bin/env", "BUNDLE_GEMFILE=" + str(gemfile), "BUNDLE_FROZEN=true",
54
+ "BUNDLE_PATH=" + (os.environ.get("BUNDLE_PATH") or str(root / ".factory/toolchain/gems"))]
55
+ relay = [sys.executable, str(proxy_helper), "--", *prefix, "bundle"]
56
+ if command([*relay, "check"], gemfile.parent, log):
57
+ if command([*relay, "install", "--jobs", "2", "--retry", "1"], gemfile.parent, log):
58
+ refuse("native_bundle_install_failed", log, guard.Refused)
59
+ unchanged(snapshot)
60
+ return [*prefix, "bundle", "exec"], snapshot
61
+
62
+
63
+ def run(root: Path, folder: Path, proxy_helper: Path, argv: list[str]) -> dict:
64
+ root = root.resolve(strict=True)
65
+ folder = (root / folder).resolve(strict=True)
66
+ proxy_helper = proxy_helper.resolve(strict=True)
67
+ if not folder.is_relative_to(root) or not argv:
68
+ raise guard.Refused("native_bundle_path_unverified")
69
+ if (folder / "Podfile").is_symlink():
70
+ raise guard.Refused("native_workspace_reference_unverified")
71
+ pod_lock = folder / "Podfile.lock"
72
+ pod_snapshot = {pod_lock: committed(root, pod_lock, "podfile_lock")} if (folder / "Podfile").exists() else {}
73
+ with tempfile.TemporaryFile() as log:
74
+ if command([sys.executable, str(proxy_helper)], root, log):
75
+ raise guard.Refused("native_google_proxy_required")
76
+ prefix, snapshot = prepare(root, folder, proxy_helper, log)
77
+ if command([sys.executable, str(proxy_helper), "--", *prefix, *argv], root, log):
78
+ refuse("native_configuration_failed", log, guard.Refused)
79
+ unchanged(snapshot)
80
+ for path, digest in pod_snapshot.items():
81
+ if not path.is_file() or path.is_symlink() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
82
+ raise guard.Refused("native_podfile_lock_changed")
83
+ return {"ok": True, "ruby_bundle": "locked" if snapshot else "not_configured"}
84
+
85
+
86
+ def interrupted(*_args) -> None:
87
+ raise KeyboardInterrupt
88
+
89
+
90
+ def main(argv: list[str]) -> int:
91
+ signal.signal(signal.SIGTERM, interrupted)
92
+ parser = argparse.ArgumentParser(description=__doc__)
93
+ parser.add_argument("--root", type=Path, default=Path.cwd())
94
+ parser.add_argument("--folder", type=Path, default=Path("ios"))
95
+ parser.add_argument("--proxy-helper", type=Path, required=True)
96
+ parser.add_argument("command", nargs=argparse.REMAINDER)
97
+ args = parser.parse_args(argv)
98
+ command_args = args.command[1:] if args.command[:1] == ["--"] else args.command
99
+ try:
100
+ answer = run(args.root, args.folder, args.proxy_helper, command_args)
101
+ except (guard.Refused, OSError, subprocess.SubprocessError) as exc:
102
+ code = str(exc) if isinstance(exc, guard.Refused) else "native_bundle_operation_unverified"
103
+ answer = {"ok": False, "code": code, "next": "Use the app's committed Gemfile and Gemfile.lock with a "
104
+ "compatible Ruby/Bundler runtime; reconcile dependency locks locally and retain deployment guards."}
105
+ if getattr(exc, "diagnostic", ""):
106
+ answer["diagnostic"] = exc.diagnostic
107
+ print(json.dumps(answer, sort_keys=True))
108
+ return 0 if answer["ok"] else 1
109
+
110
+
111
+ if __name__ == "__main__":
112
+ raise SystemExit(main(sys.argv[1:]))
@@ -13,8 +13,9 @@ import sys
13
13
  import tempfile
14
14
 
15
15
  import native_dependency_guard as guard
16
+ import native_bundle
17
+ from native_bundle import command
16
18
  from native_pods_diagnostics import refuse
17
- import native_pods_process
18
19
 
19
20
 
20
21
  def committed(root: Path, lock: Path) -> str:
@@ -27,15 +28,6 @@ def committed(root: Path, lock: Path) -> str:
27
28
  return hashlib.sha256(result.stdout).hexdigest()
28
29
 
29
30
 
30
- def command(argv: list[str], cwd: Path, log) -> int:
31
- try:
32
- return native_pods_process.command(argv, cwd, log)
33
- except native_pods_process.Interrupted:
34
- raise guard.Refused("native_pods_operation_interrupted") from None
35
- except (OSError, RuntimeError, subprocess.SubprocessError):
36
- raise guard.Refused("native_pods_process_unverified") from None
37
-
38
-
39
31
  def prepare(root: Path, container: str, proxy_helper: Path) -> dict:
40
32
  root = root.resolve(strict=True)
41
33
  folder = (root / container).parent.resolve(strict=True) if container else root
@@ -56,9 +48,12 @@ def prepare(root: Path, container: str, proxy_helper: Path) -> dict:
56
48
  refuse("native_project_generation_failed", log, guard.Refused)
57
49
  initial = container or str(guard.selected(root, "", True).relative_to(root))
58
50
  guard.check(root, initial, before_pods=True)
59
- if command([sys.executable, str(proxy_helper), "--", "pod", "install", "--deployment", "--no-repo-update"],
51
+ prefix, bundle_snapshot = native_bundle.prepare(root, folder, proxy_helper, log)
52
+ if command([sys.executable, str(proxy_helper), "--", *prefix, "pod", "install",
53
+ "--deployment", "--no-repo-update"],
60
54
  folder, log):
61
55
  refuse("native_pods_install_failed", log, guard.Refused)
56
+ native_bundle.unchanged(bundle_snapshot)
62
57
  if digest != hashlib.sha256((folder / "Podfile.lock").read_bytes()).hexdigest():
63
58
  raise guard.Refused("native_podfile_lock_changed")
64
59
  workspaces = sorted(folder.glob("*.xcworkspace"))
@@ -0,0 +1,97 @@
1
+ """Exercise real child scoping and committed bundle failures without contacting a provider."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+
10
+ import native_bundle
11
+
12
+ PROXY = Path(__file__).resolve().parents[2] / "android-action/scripts/play_store_proxy.py"
13
+
14
+
15
+ class BundleTests(unittest.TestCase):
16
+ def setUp(self):
17
+ temporary = tempfile.TemporaryDirectory(prefix="native-bundle-test-")
18
+ self.addCleanup(temporary.cleanup)
19
+ self.root = Path(temporary.name).resolve()
20
+ (self.root / "ios").mkdir()
21
+ self.env = {**os.environ, "GOOGLE_STORE_PROXY_URL": "http://fixture:PRIVATE_SENTINEL@127.0.0.1:9",
22
+ "PATH": str(self.root) + os.pathsep + os.environ["PATH"]}
23
+ self.env.pop("BUNDLE_PATH", None)
24
+ subprocess.run(["git", "init", "-q"], cwd=self.root, check=True, capture_output=True)
25
+ bundle = self.root / "bundle"
26
+ bundle.write_text('#!' + sys.executable + '\nimport os,sys\nfrom pathlib import Path\n'
27
+ 'if sys.argv[1] == "check": raise SystemExit(int(os.environ.get("COLD_BUNDLE", "0")))\n'
28
+ 'if sys.argv[1] == "install":\n'
29
+ ' print("PRIVATE_SENTINEL")\n'
30
+ ' if os.environ.get("CHANGE_LOCK"): Path("Gemfile.lock").write_text("changed")\n'
31
+ ' raise SystemExit(int(os.environ.get("INSTALL_EXIT", "0")))\n'
32
+ 'assert sys.argv[1] == "exec"\n'
33
+ 'os.environ["FIXTURE_BUNDLE_ACTIVE"] = "yes"\n'
34
+ 'os.execvp(sys.argv[2], sys.argv[2:])\n')
35
+ bundle.chmod(0o700)
36
+
37
+ def commit(self, *files):
38
+ for name in files:
39
+ path = self.root / name
40
+ path.write_text("fixture " + name + "\n")
41
+ for argv in (["git", "add", *files],
42
+ ["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
43
+ "commit", "-qm", "Freeze fixture inputs"]):
44
+ subprocess.run(argv, cwd=self.root, check=True, capture_output=True)
45
+
46
+ def run_cli(self, script=None):
47
+ script = script or ('import json,os; from pathlib import Path; '
48
+ 'Path("child.json").write_text(json.dumps({k:os.environ.get(k) for k in '
49
+ '["FIXTURE_BUNDLE_ACTIVE","BUNDLE_GEMFILE","BUNDLE_FROZEN","HTTPS_PROXY"]}))')
50
+ result = subprocess.run([sys.executable, native_bundle.__file__, "--root", str(self.root),
51
+ "--proxy-helper", str(PROXY), "--", sys.executable, "-c", script],
52
+ cwd=self.root, env=self.env, capture_output=True, text=True, timeout=20)
53
+ self.assertNotIn("PRIVATE_SENTINEL", result.stdout + result.stderr)
54
+ return result.returncode, json.loads(result.stdout)
55
+
56
+ def test_unconfigured_app_keeps_the_proxied_command(self):
57
+ code, value = self.run_cli()
58
+ self.assertEqual((code, value["ruby_bundle"]), (0, "not_configured"))
59
+ child = json.loads((self.root / "child.json").read_text())
60
+ self.assertIsNone(child["FIXTURE_BUNDLE_ACTIVE"])
61
+ self.assertTrue(child["HTTPS_PROXY"].startswith("http://127.0.0.1:"))
62
+
63
+ def test_nearest_bundle_scopes_configuration_and_preserves_locks(self):
64
+ self.commit("Gemfile", "Gemfile.lock", "ios/Gemfile", "ios/Gemfile.lock", "ios/Podfile", "ios/Podfile.lock")
65
+ self.env["COLD_BUNDLE"] = "1"
66
+ code, value = self.run_cli()
67
+ self.assertEqual((code, value["ruby_bundle"]), (0, "locked"))
68
+ child = json.loads((self.root / "child.json").read_text())
69
+ self.assertEqual(child["FIXTURE_BUNDLE_ACTIVE"], "yes")
70
+ self.assertEqual(child["BUNDLE_GEMFILE"], str(self.root / "ios/Gemfile"))
71
+ self.assertEqual(child["BUNDLE_FROZEN"], "true")
72
+ self.assertTrue(child["HTTPS_PROXY"].startswith("http://127.0.0.1:"))
73
+
74
+ def test_uncommitted_or_modified_bundle_refuses_before_configuration(self):
75
+ (self.root / "Gemfile").write_text("uncommitted")
76
+ self.assertEqual(self.run_cli()[1]["code"], "native_gemfile_commit_required")
77
+ self.commit("Gemfile")
78
+ self.assertEqual(self.run_cli()[1]["code"], "native_gemfile_lock_required")
79
+ self.commit("Gemfile.lock")
80
+ (self.root / "Gemfile.lock").write_text("changed")
81
+ self.assertEqual(self.run_cli()[1]["code"], "native_gemfile_lock_commit_required")
82
+ self.assertFalse((self.root / "child.json").exists())
83
+
84
+ def test_install_failure_and_mutation_do_not_launch_configuration(self):
85
+ self.commit("Gemfile", "Gemfile.lock")
86
+ self.env.update(COLD_BUNDLE="1", INSTALL_EXIT="7")
87
+ self.assertEqual(self.run_cli()[1]["code"], "native_bundle_install_failed")
88
+ self.env.update(INSTALL_EXIT="0", CHANGE_LOCK="1")
89
+ self.assertEqual(self.run_cli()[1]["code"], "native_bundle_inputs_changed")
90
+ self.assertFalse((self.root / "child.json").exists())
91
+
92
+ def test_failed_configuration_and_generated_lock_drift_cannot_pass(self):
93
+ self.commit("Gemfile", "Gemfile.lock", "ios/Podfile", "ios/Podfile.lock")
94
+ code, value = self.run_cli('print("PRIVATE_SENTINEL"); raise SystemExit(7)')
95
+ self.assertEqual((code, value["code"]), (1, "native_configuration_failed"))
96
+ script = 'from pathlib import Path; Path("ios/Podfile.lock").write_text("new serializer")'
97
+ self.assertEqual(self.run_cli(script)[1]["code"], "native_podfile_lock_changed")
@@ -8,6 +8,15 @@ ACTION = Path(__file__).resolve().parents[1] / "action.yml"
8
8
 
9
9
 
10
10
  class WiringTests(unittest.TestCase):
11
+ def test_flutter_configuration_uses_the_same_bundle_before_pods(self):
12
+ template = ACTION.parents[1] / "templates/deploy.yml"
13
+ body = template.read_text().split("- name: Prepare Flutter iOS project\n", 1)[1].split("\n - ", 1)[0]
14
+ shell = body.split("shell: >-\n", 1)[1].split("\n env:", 1)[0]
15
+ self.assertIn(".github/actions/swift-app/scripts/native_bundle.py", shell)
16
+ self.assertIn("--folder ios --proxy-helper .github/actions/android-app/scripts/play_store_proxy.py", shell)
17
+ self.assertIn("-- bash --noprofile --norc -e -o pipefail {0}", shell)
18
+ self.assertIn("flutter build ios", body)
19
+
11
20
  def test_every_selected_xcode_step_has_its_own_preflight(self):
12
21
  source = ACTION.read_text()
13
22
  names = {name: step_block(source, name) for name in step_names(source)}
@@ -56,6 +56,35 @@ class PodsTests(unittest.TestCase):
56
56
  self.env.pop("GOOGLE_STORE_PROXY_URL")
57
57
  self.assertEqual(self.run_cli()[1]["code"], "native_google_proxy_required")
58
58
 
59
+ def test_declared_bundle_controls_the_actual_pod_child(self):
60
+ self.locked()
61
+ (self.root / "Gemfile").write_text('source "https://rubygems.org"\ngem "cocoapods", "1.16.2"\n')
62
+ (self.root / "Gemfile.lock").write_text("fixture gem lock\n")
63
+ for argv in (["git", "add", "Gemfile", "Gemfile.lock"],
64
+ ["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
65
+ "commit", "-qm", "Declare the app bundle"]):
66
+ subprocess.run(argv, cwd=self.root, check=True, capture_output=True)
67
+ bundle = self.root / "bundle"
68
+ bundle.write_text('#!' + sys.executable + '\nimport os,sys\n'
69
+ 'if sys.argv[1] == "install": raise SystemExit(0)\n'
70
+ 'assert sys.argv[1] == "exec"\n'
71
+ 'os.environ["FIXTURE_BUNDLE_ACTIVE"] = "yes"\n'
72
+ 'os.execvp(sys.argv[2], sys.argv[2:])\n')
73
+ bundle.chmod(0o700)
74
+ pod = self.root / "pod"
75
+ pod.write_text('#!' + sys.executable + '\nimport os,json\nfrom pathlib import Path\n'
76
+ 'Path("bundle-child.json").write_text(json.dumps({k:os.environ.get(k) for k in '
77
+ '["FIXTURE_BUNDLE_ACTIVE","BUNDLE_GEMFILE","BUNDLE_FROZEN","HTTPS_PROXY"]}))\n'
78
+ 'raise SystemExit(7)\n')
79
+ pod.chmod(0o700)
80
+ self.env["PATH"] = str(self.root) + os.pathsep + self.env["PATH"]
81
+ self.assertEqual(self.run_cli()[1]["code"], "native_pods_install_failed")
82
+ child = json.loads((self.root / "bundle-child.json").read_text())
83
+ self.assertEqual(child["FIXTURE_BUNDLE_ACTIVE"], "yes")
84
+ self.assertEqual(child["BUNDLE_GEMFILE"], str(self.root / "Gemfile"))
85
+ self.assertEqual(child["BUNDLE_FROZEN"], "true")
86
+ self.assertTrue(child["HTTPS_PROXY"].startswith("http://127.0.0.1:"))
87
+
59
88
  def test_real_child_has_relay_and_deployment_flags_and_preserves_failure(self):
60
89
  self.locked()
61
90
  script = self.root / "pod"
@@ -1 +1 @@
1
- 1.0.98
1
+ 1.0.100
@@ -97,12 +97,36 @@ runs:
97
97
  continue-on-error: true
98
98
  shell: bash
99
99
  run: node "$GITHUB_ACTION_PATH/../scripts/cache_scope.cjs" --snapshot
100
+ - name: Identify an exact Flutter release metadata cache
101
+ id: release-metadata
102
+ if: ${{ inputs.cache == 'true' && steps.scope.outcome == 'success' }}
103
+ continue-on-error: true
104
+ shell: bash
105
+ env:
106
+ GOWALK_FLUTTER_VERSION: ${{ inputs.flutter-version }}
107
+ GOWALK_FLUTTER_VERSION_FILE: ${{ inputs.flutter-version-file }}
108
+ GOWALK_FLUTTER_CHANNEL: ${{ inputs.channel }}
109
+ GOWALK_FLUTTER_ARCHITECTURE: ${{ inputs.architecture }}
110
+ run: python3 "$GITHUB_ACTION_PATH/../scripts/flutter_release_cache.py"
111
+ - name: Restore exact Flutter release metadata
112
+ id: cache-release
113
+ if: ${{ steps.release-metadata.outputs.enabled == 'true' }}
114
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
115
+ continue-on-error: true
116
+ env:
117
+ NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
118
+ SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
119
+ with:
120
+ path: ${{ steps.release-metadata.outputs.path }}
121
+ key: ${{ steps.release-metadata.outputs.key }}
100
122
  - name: Resolve Flutter release and cache identities
101
123
  id: flutter-action
102
124
  uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
103
125
  env:
104
126
  BASH_ENV: ${{ steps.transport.outputs.manifest_env }}
105
127
  GOWALK_FLUTTER_MANIFEST_HELPER: ${{ steps.transport.outputs.manifest_helper }}
128
+ GOWALK_FLUTTER_RELEASE_CACHE: ${{ steps.release-metadata.outputs.path }}
129
+ GOWALK_FLUTTER_RELEASE_PIN: ${{ steps.release-metadata.outputs.pin }}
106
130
  with:
107
131
  channel: ${{ inputs.channel }}
108
132
  flutter-version: ${{ inputs.flutter-version }}
@@ -116,6 +140,17 @@ runs:
116
140
  pub-cache-path: ${{ inputs.pub-cache-path }}
117
141
  dry-run: 'true'
118
142
  git-source: ${{ inputs.git-source }}
143
+ - name: Save resolved Flutter release metadata
144
+ if: >-
145
+ ${{ steps.release-metadata.outputs.enabled == 'true' && steps.flutter-action.outcome == 'success'
146
+ && steps.cache-release.outputs.cache-hit != 'true' }}
147
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
148
+ continue-on-error: true
149
+ env:
150
+ NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
151
+ with:
152
+ path: ${{ steps.release-metadata.outputs.path }}
153
+ key: ${{ steps.release-metadata.outputs.key }}
119
154
  - name: Cache Flutter
120
155
  id: cache-flutter
121
156
  if: ${{ inputs.cache == 'true' }}
@@ -159,6 +194,8 @@ runs:
159
194
  GOWALK_FLUTTER_MANIFEST_HELPER: ${{ steps.transport.outputs.manifest_helper }}
160
195
  GOWALK_FLUTTER_ARCHIVE_CACHE: >-
161
196
  ${{ inputs.cache == 'true' && format('{0}/gowalk-flutter-archives', runner.temp) || '' }}
197
+ GOWALK_FLUTTER_RELEASE_CACHE: ${{ steps.release-metadata.outputs.path }}
198
+ GOWALK_FLUTTER_RELEASE_PIN: ${{ steps.release-metadata.outputs.pin }}
162
199
  with:
163
200
  channel: ${{ steps.flutter-action.outputs.CHANNEL }}
164
201
  flutter-version: ${{ steps.flutter-action.outputs.VERSION }}
@@ -10,6 +10,7 @@ from urllib.parse import urlsplit
10
10
 
11
11
  import flutter_download
12
12
  import flutter_archive_cache
13
+ import flutter_release_cache
13
14
 
14
15
  RETRY_CODES = {5, 6, 7, 18, 28, 35, 52, 55, 56, 92, 95}
15
16
  RETRY_HTTP = {408, 429, 500, 502, 503, 504}
@@ -26,6 +27,8 @@ def download(arguments: list[str], directory: Path) -> int:
26
27
  destination = directory / "download"
27
28
  destination.touch(mode=0o600, exist_ok=True)
28
29
  destination.chmod(0o600)
30
+ if flutter_release_cache.reuse(arguments[-1], destination, kind):
31
+ return 0
29
32
  if flutter_archive_cache.reuse_complete(destination, kind):
30
33
  return 0
31
34
  attempted = 0
@@ -41,6 +44,7 @@ def download(arguments: list[str], directory: Path) -> int:
41
44
  curl_exit=code, http_status=http, proxy_status=proxy_http)
42
45
  if code == 0 and http in ({200, 206} if kind == "archive" else {200}):
43
46
  flutter_download.publish(destination, kind)
47
+ flutter_release_cache.remember(arguments[-1], destination, kind)
44
48
  if attempt > 1:
45
49
  print(f"flutter_{kind}_recovered attempts={attempt}", file=sys.stderr)
46
50
  return 0
@@ -0,0 +1,120 @@
1
+ """Optional public release metadata for exact Flutter pins; SDK identities remain the official action's."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+ import sys
8
+ import tempfile
9
+
10
+ import flutter_download
11
+
12
+ MAX_BYTES = 16 * 1024 * 1024
13
+ EXACT_VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?")
14
+
15
+
16
+ def valid_pin(value) -> bool:
17
+ return (isinstance(value, dict) and set(value) == {"version", "channel", "architecture"}
18
+ and isinstance(value["version"], str)
19
+ and bool(EXACT_VERSION.fullmatch(value["version"]))
20
+ and value["channel"] in ("stable", "beta", "dev")
21
+ and value["architecture"] in ("x64", "arm64"))
22
+
23
+
24
+ def context():
25
+ try:
26
+ pin = json.loads(os.environ.get("GOWALK_FLUTTER_RELEASE_PIN", "null"))
27
+ raw = os.environ.get("GOWALK_FLUTTER_RELEASE_CACHE")
28
+ if not raw or not valid_pin(pin):
29
+ return None
30
+ path = Path(raw)
31
+ parent = Path(os.environ["RUNNER_TEMP"]).resolve(strict=True) / "gowalk-flutter-manifests"
32
+ if (path.is_symlink() or path.parent.is_symlink() or path.parent.resolve() != parent
33
+ or not re.fullmatch(r"[a-f0-9]{64}\.json", path.name)):
34
+ return None
35
+ return path, pin
36
+ except (KeyError, OSError, ValueError, TypeError):
37
+ return None
38
+
39
+
40
+ def matches(manifest, pin) -> bool:
41
+ if not isinstance(manifest, dict) or not isinstance(manifest.get("releases"), list):
42
+ return False
43
+ rows = [row for row in manifest["releases"] if isinstance(row, dict)
44
+ and row.get("version") == pin["version"] and row.get("channel") == pin["channel"]
45
+ and (row.get("dart_sdk_arch") or "x64") == pin["architecture"]]
46
+ if len(rows) != 1:
47
+ return False
48
+ row = rows[0]
49
+ return (isinstance(row.get("archive"), str) and not row["archive"].startswith("/")
50
+ and ".." not in row["archive"] and "\\" not in row["archive"]
51
+ and bool(re.fullmatch(r"[a-fA-F0-9]{40}", str(row.get("hash", ""))))
52
+ and bool(re.fullmatch(r"[a-fA-F0-9]{64}", str(row.get("sha256", "")))))
53
+
54
+
55
+ def reuse(url: str, destination: Path, kind: str) -> bool:
56
+ selected = context() if kind == "manifest" else None
57
+ if not selected:
58
+ return False
59
+ path, pin = selected
60
+ try:
61
+ if not path.is_file() or path.stat().st_size > MAX_BYTES:
62
+ return False
63
+ record = json.loads(path.read_bytes())
64
+ if (not isinstance(record, dict) or record.get("schema") != 1 or record.get("url") != url
65
+ or record.get("pin") != pin or not matches(record.get("manifest"), pin)):
66
+ return False
67
+ destination.write_text(json.dumps(record["manifest"]))
68
+ flutter_download.publish(destination, kind)
69
+ print('::notice title=flutter_manifest_cache::{"schema":"gowalk-cicd/flutter-manifest-cache.v1",'
70
+ '"event":"hit"}', file=sys.stderr)
71
+ return True
72
+ except (OSError, ValueError, TypeError):
73
+ return False
74
+
75
+
76
+ def remember(url: str, destination: Path, kind: str) -> None:
77
+ selected = context() if kind == "manifest" else None
78
+ if not selected or destination.stat().st_size > MAX_BYTES:
79
+ return
80
+ path, pin = selected
81
+ temporary = None
82
+ try:
83
+ manifest = json.loads(destination.read_bytes())
84
+ if not matches(manifest, pin):
85
+ return
86
+ content = json.dumps({"schema": 1, "url": url, "pin": pin, "manifest": manifest}).encode()
87
+ if len(content) > MAX_BYTES:
88
+ return
89
+ path.parent.mkdir(mode=0o700, exist_ok=True)
90
+ with tempfile.NamedTemporaryFile(dir=path.parent, prefix="incoming-", delete=False) as output:
91
+ temporary = Path(output.name)
92
+ output.write(content)
93
+ os.replace(temporary, path)
94
+ except (OSError, ValueError, TypeError):
95
+ pass # This optional cache cannot turn a completed provider read into a failed setup.
96
+ finally:
97
+ if temporary:
98
+ try:
99
+ temporary.unlink(missing_ok=True)
100
+ except OSError:
101
+ pass
102
+
103
+
104
+ def prepare() -> None:
105
+ pin = {"version": os.environ.get("GOWALK_FLUTTER_VERSION", ""),
106
+ "channel": os.environ.get("GOWALK_FLUTTER_CHANNEL", ""),
107
+ "architecture": os.environ.get("GOWALK_FLUTTER_ARCHITECTURE", "").lower()}
108
+ with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
109
+ if os.environ.get("GOWALK_FLUTTER_VERSION_FILE") or not valid_pin(pin):
110
+ output.write("enabled=false\n")
111
+ return
112
+ namespace = {"pin": pin, "os": os.environ["RUNNER_OS"],
113
+ "origin": os.environ.get("FLUTTER_STORAGE_BASE_URL", "https://storage.googleapis.com")}
114
+ digest = hashlib.sha256(json.dumps(namespace, sort_keys=True).encode()).hexdigest()
115
+ path = Path(os.environ["RUNNER_TEMP"]).resolve(strict=True) / "gowalk-flutter-manifests" / f"{digest}.json"
116
+ output.write(f"enabled=true\nkey=flutter-manifest-v1-{digest}\npath={path}\npin={json.dumps(pin)}\n")
117
+
118
+
119
+ if __name__ == "__main__":
120
+ prepare()
@@ -0,0 +1,103 @@
1
+ """Exact pinned setup survives a manifest outage after a verified resolution; misses stay proxied."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import subprocess
6
+ import tempfile
7
+ import unittest
8
+
9
+ from flutter_manifest_fixture import HOST, official_setup, tls_proxy
10
+
11
+ ROOT = Path(__file__).parent
12
+
13
+
14
+ class ReleaseCacheTests(unittest.TestCase):
15
+ @classmethod
16
+ def setUpClass(cls):
17
+ cls.source = official_setup()
18
+
19
+ def setUp(self):
20
+ scratch = tempfile.TemporaryDirectory()
21
+ self.addCleanup(scratch.cleanup)
22
+ self.root = Path(scratch.name)
23
+ self.setup = self.root / "setup.sh"
24
+ self.setup.write_bytes(self.source)
25
+ self.cache = self.root / "gowalk-flutter-manifests" / ("a" * 64 + ".json")
26
+
27
+ def invoke(self, server, cert, *, version="3.44.0", bound=True):
28
+ pin = {"version": version, "channel": "stable", "architecture": "x64"}
29
+ env = {**os.environ, "RUNNER_TEMP": str(self.root), "RUNNER_OS": "Linux", "RUNNER_ARCH": "X64",
30
+ "RUNNER_TOOL_CACHE": str(self.root / "tools"), "GITHUB_OUTPUT": str(self.root / "output"),
31
+ "PUB_CACHE": str(self.root / "pub"), "FLUTTER_STORAGE_BASE_URL": "https://" + HOST,
32
+ "BASH_ENV": str(ROOT / "flutter_manifest_env.sh"), "CURL_CA_BUNDLE": str(cert),
33
+ "GOWALK_FLUTTER_MANIFEST_HELPER": str(ROOT / "flutter_manifest.py"),
34
+ "GOWALK_FLUTTER_RELEASE_CACHE": str(self.cache), "GOWALK_FLUTTER_RELEASE_PIN": json.dumps(pin),
35
+ "HTTPS_PROXY": f"http://127.0.0.1:{server.server_address[1]}", "NO_PROXY": "", "no_proxy": ""}
36
+ env.pop("https_proxy", None)
37
+ if not bound:
38
+ env.pop("HTTPS_PROXY")
39
+ (self.root / "output").write_text("")
40
+ return subprocess.run(["bash", str(self.setup), "-p", "-n", version, "-a", "x64",
41
+ "-k", "same-:version:-:hash:", "stable"], env=env,
42
+ capture_output=True, text=True, timeout=15)
43
+
44
+ def test_warm_resolution_preserves_sdk_identity_without_another_manifest_connection(self):
45
+ with tls_proxy(self.root, ["ok", "origin_auth"]) as (server, cert):
46
+ cold = self.invoke(server, cert)
47
+ self.assertEqual(cold.returncode, 0, cold.stderr)
48
+ identity = (self.root / "output").read_text()
49
+ warm = self.invoke(server, cert)
50
+ self.assertEqual(warm.returncode, 0, warm.stderr)
51
+ self.assertEqual((server.connects, len(server.requests)), (1, 1))
52
+ self.assertEqual((self.root / "output").read_text(), identity)
53
+ self.assertIn("flutter_manifest_cache", warm.stderr)
54
+
55
+ def test_changed_pin_does_not_reuse_another_versions_manifest(self):
56
+ with tls_proxy(self.root, ["ok", "origin_auth"]) as (server, cert):
57
+ self.assertEqual(self.invoke(server, cert).returncode, 0)
58
+ changed = self.invoke(server, cert, version="3.44.1")
59
+ self.assertNotEqual(changed.returncode, 0)
60
+ self.assertEqual(server.connects, 2)
61
+ self.assertIn("http_status=401", changed.stderr)
62
+
63
+ def test_invalid_cache_is_recovered_through_the_assigned_proxy(self):
64
+ self.cache.parent.mkdir()
65
+ self.cache.write_text("truncated JSON")
66
+ with tls_proxy(self.root, ["ok"]) as (server, cert):
67
+ result = self.invoke(server, cert)
68
+ self.assertEqual(result.returncode, 0, result.stderr)
69
+ self.assertEqual(server.connects, 1)
70
+ self.assertEqual(json.loads(self.cache.read_text())["pin"]["version"], "3.44.0")
71
+
72
+ def test_warm_cache_never_bypasses_the_required_account_pin(self):
73
+ with tls_proxy(self.root, ["ok"]) as (server, cert):
74
+ self.assertEqual(self.invoke(server, cert).returncode, 0)
75
+ result = self.invoke(server, cert, bound=False)
76
+ self.assertNotEqual(result.returncode, 0)
77
+ self.assertEqual(server.connects, 1)
78
+
79
+ def test_wrong_origin_or_invalid_release_metadata_requires_a_fresh_provider_read(self):
80
+ for change in ["url", "sha256"]:
81
+ with self.subTest(change=change), tls_proxy(self.root, ["ok", "ok"]) as (server, cert):
82
+ self.cache.unlink(missing_ok=True)
83
+ self.assertEqual(self.invoke(server, cert).returncode, 0)
84
+ record = json.loads(self.cache.read_text())
85
+ if change == "url":
86
+ record["url"] = "https://another.fixture.invalid/releases_linux.json"
87
+ else:
88
+ record["manifest"]["releases"][0]["sha256"] = "invalid"
89
+ self.cache.write_text(json.dumps(record))
90
+ self.assertEqual(self.invoke(server, cert).returncode, 0)
91
+ self.assertEqual(server.connects, 2)
92
+
93
+ def test_wildcard_selection_still_resolves_a_fresh_manifest(self):
94
+ with tls_proxy(self.root, ["ok", "origin_auth"]) as (server, cert):
95
+ self.assertEqual(self.invoke(server, cert, version="3.44.x").returncode, 0)
96
+ result = self.invoke(server, cert, version="3.44.x")
97
+ self.assertNotEqual(result.returncode, 0)
98
+ self.assertEqual(server.connects, 2)
99
+ self.assertFalse(self.cache.exists())
100
+
101
+
102
+ if __name__ == "__main__":
103
+ unittest.main()
@@ -0,0 +1,49 @@
1
+ """Metadata cache selectors cannot freeze moving requests or cross platforms/origins."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+
10
+ SCRIPT = Path(__file__).with_name("flutter_release_cache.py")
11
+
12
+
13
+ class CacheSelectionTests(unittest.TestCase):
14
+ def prepare(self, root, **changes):
15
+ output = root / "outputs"
16
+ output.write_text("")
17
+ env = {**os.environ, "GITHUB_OUTPUT": str(output), "RUNNER_TEMP": str(root), "RUNNER_OS": "Linux",
18
+ "GOWALK_FLUTTER_VERSION": "3.44.0", "GOWALK_FLUTTER_VERSION_FILE": "",
19
+ "GOWALK_FLUTTER_CHANNEL": "stable", "GOWALK_FLUTTER_ARCHITECTURE": "X64",
20
+ "FLUTTER_STORAGE_BASE_URL": "https://storage.googleapis.com", **changes}
21
+ result = subprocess.run([sys.executable, str(SCRIPT)], env=env, capture_output=True, timeout=10)
22
+ self.assertEqual((result.returncode, result.stdout, result.stderr), (0, b"", b""))
23
+ return dict(line.split("=", 1) for line in output.read_text().splitlines())
24
+
25
+ def test_exact_identity_is_stable_and_other_selections_have_separate_keys(self):
26
+ with tempfile.TemporaryDirectory() as directory:
27
+ root = Path(directory)
28
+ first = self.prepare(root)
29
+ self.assertEqual(first, self.prepare(root))
30
+ self.assertEqual(first["enabled"], "true")
31
+ self.assertEqual(json.loads(first["pin"])["architecture"], "x64")
32
+ for change in [{"RUNNER_OS": "macOS"}, {"GOWALK_FLUTTER_ARCHITECTURE": "ARM64"},
33
+ {"GOWALK_FLUTTER_CHANNEL": "beta"}, {"GOWALK_FLUTTER_VERSION": "3.44.1"},
34
+ {"FLUTTER_STORAGE_BASE_URL": "https://another.fixture.invalid"}]:
35
+ with self.subTest(change=change):
36
+ self.assertNotEqual(first["key"], self.prepare(root, **change)["key"])
37
+
38
+ def test_moving_or_file_resolved_versions_do_not_enable_metadata_reuse(self):
39
+ with tempfile.TemporaryDirectory() as directory:
40
+ root = Path(directory)
41
+ for change in [{"GOWALK_FLUTTER_VERSION": ""}, {"GOWALK_FLUTTER_VERSION": "3.x"},
42
+ {"GOWALK_FLUTTER_CHANNEL": "master"},
43
+ {"GOWALK_FLUTTER_VERSION_FILE": "pubspec.yaml"}]:
44
+ with self.subTest(change=change):
45
+ self.assertEqual(self.prepare(root, **change), {"enabled": "false"})
46
+
47
+
48
+ if __name__ == "__main__":
49
+ unittest.main()
@@ -9,6 +9,22 @@ ROOT = Path(__file__).resolve().parents[2]
9
9
 
10
10
 
11
11
  class BootstrapWiringTests(unittest.TestCase):
12
+ def assert_protected_setup(self, resolve, install):
13
+ for step in (resolve, install):
14
+ self.assertEqual(step["uses"], "subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2")
15
+ expected_env = {
16
+ "BASH_ENV": "${{ steps.transport.outputs.manifest_env }}",
17
+ "GOWALK_FLUTTER_MANIFEST_HELPER": "${{ steps.transport.outputs.manifest_helper }}",
18
+ "GOWALK_FLUTTER_RELEASE_CACHE": "${{ steps.release-metadata.outputs.path }}",
19
+ "GOWALK_FLUTTER_RELEASE_PIN": "${{ steps.release-metadata.outputs.pin }}"}
20
+ if step is install:
21
+ expected_env["GOWALK_FLUTTER_ARCHIVE_CACHE"] = (
22
+ "${{ inputs.cache == 'true' && format('{0}/gowalk-flutter-archives', runner.temp) || '' }}")
23
+ self.assertEqual(step["env"], expected_env)
24
+ self.assertEqual(step["with"]["cache"], "false")
25
+ self.assertEqual(step["with"]["pub-cache"], "false")
26
+ self.assertNotIn("continue-on-error", step)
27
+
12
28
  def test_each_flutter_sdk_setup_has_a_prior_mandatory_account_guard(self):
13
29
  checked = 0
14
30
  for name in ("deploy.yml", "deploy-web.yml"):
@@ -34,18 +50,7 @@ class BootstrapWiringTests(unittest.TestCase):
34
50
  self.assertTrue(scope["continue-on-error"])
35
51
  self.assertIn("--snapshot", scope["run"])
36
52
  self.assertEqual(resolve["with"]["dry-run"], "true")
37
- for step in (resolve, install):
38
- self.assertEqual(step["uses"], "subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2")
39
- expected_env = {
40
- "BASH_ENV": "${{ steps.transport.outputs.manifest_env }}",
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)
46
- self.assertEqual(step["with"]["cache"], "false")
47
- self.assertEqual(step["with"]["pub-cache"], "false")
48
- self.assertNotIn("continue-on-error", step)
53
+ self.assert_protected_setup(resolve, install)
49
54
  self.assertNotIn("BASH_ENV", setup.get("env", {}))
50
55
  for step in (guard, scope, sdk_cache, pub_cache, save):
51
56
  self.assertNotIn("BASH_ENV", step.get("env", {}))
@@ -1 +1 @@
1
- 1.0.98
1
+ 1.0.100
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.98",
3
+ "version": "1.0.100",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -142,7 +142,8 @@ jobs:
142
142
  - name: Prepare Flutter iOS project
143
143
  if: ${{ steps.flutter.outputs.enabled == 'true' }}
144
144
  shell: >-
145
- python3 .github/actions/android-app/scripts/play_store_proxy.py
145
+ python3 .github/actions/swift-app/scripts/native_bundle.py
146
+ --folder ios --proxy-helper .github/actions/android-app/scripts/play_store_proxy.py
146
147
  -- bash --noprofile --norc -e -o pipefail {0}
147
148
  env:
148
149
  ONBOARDING_PUBLIC_ORIGIN: ${{ vars.ONBOARDING_PUBLIC_ORIGIN }}