gowalk-cicd 1.0.98 → 1.0.99

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
@@ -249,6 +249,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
249
249
  dependency download or Google proxy requirement. Initial local lock resolution may use the
250
250
  explicit `--before-pods --initial-pods-lock` guard; CI never uses that development exception.
251
251
  These checks cover the declared native dependency graph, not arbitrary custom build-script traffic.
252
+ `native_bundle.py` honors the selected project's committed Gemfile/lock, falling back to
253
+ the repository root; missing gems install through the same relay with a frozen bundle.
254
+ Both Flutter configuration and the Pods helper use that bundle in their child process,
255
+ preserving Podfile.lock and the parent job environment. Deliver the workflow wrapper and
256
+ action helper together; action-only updates cannot repair earlier custom configuration steps.
252
257
  - Version stamping is per-build-system: Flutter takes `--build-number`, Gradle
253
258
  has no equivalent, so `set_gradle_version()` rewrites the literal in the
254
259
  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.99
@@ -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.99
@@ -1 +1 @@
1
- 1.0.98
1
+ 1.0.99
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.98",
3
+ "version": "1.0.99",
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 }}