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 +10 -0
- package/README.md +10 -0
- package/action/.daemux-version +1 -1
- package/action/scripts/native_bundle.py +112 -0
- package/action/scripts/native_pods.py +6 -11
- package/action/scripts/test_native_bundle.py +97 -0
- package/action/scripts/test_native_dependency_wiring.py +9 -0
- package/action/scripts/test_native_pods.py +29 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/flutter-setup/action.yml +37 -0
- package/android-action/scripts/flutter_manifest.py +4 -0
- package/android-action/scripts/flutter_release_cache.py +120 -0
- package/android-action/scripts/test_flutter_release_cache.py +103 -0
- package/android-action/scripts/test_flutter_release_cache_scope.py +49 -0
- package/android-action/scripts/test_google_bootstrap_wiring.py +17 -12
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- package/templates/deploy.yml +2 -1
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,
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1
|
+
1.0.100
|
package/package.json
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -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/
|
|
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 }}
|