diff --git a/scripts/check_build_workflow.py b/scripts/check_build_workflow.py index 08edb39d..8d8628a8 100644 --- a/scripts/check_build_workflow.py +++ b/scripts/check_build_workflow.py @@ -10,13 +10,15 @@ from workflow_yaml import iter_uses_references, job_block ROOT = Path(__file__).resolve().parents[1] DEFAULT_WORKFLOW = ROOT / ".github/workflows/build.yml" -# The shared bootstrap both windows-arm jobs call, and the pins it must keep. -SETUP_FLUTTER_GIT = ROOT / ".github/actions/setup-flutter-git/action.yml" FLUTTER_VERSION = "3.44.0" FLUTTER_COMMIT = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" if len(sys.argv) > 2: raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [workflow-path]") WORKFLOW = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_WORKFLOW +# The shared bootstrap both windows-arm jobs call, and the pins it must keep. +# Resolved beside the workflow rather than from ROOT so that checking a fixture +# tree exercises this rule instead of silently re-reading the real action. +SETUP_FLUTTER_GIT = WORKFLOW.parents[1] / "actions/setup-flutter-git/action.yml" text = WORKFLOW.read_text(encoding="utf-8") errors: list[str] = [] diff --git a/scripts/test_check_build_workflow.py b/scripts/test_check_build_workflow.py index 7c39748f..0d9b12d7 100755 --- a/scripts/test_check_build_workflow.py +++ b/scripts/test_check_build_workflow.py @@ -11,13 +11,21 @@ import unittest ROOT = Path(__file__).resolve().parents[1] CHECKER = ROOT / "scripts/check_build_workflow.py" WORKFLOW = ROOT / ".github/workflows/build.yml" +SETUP_FLUTTER_GIT = ROOT / ".github/actions/setup-flutter-git/action.yml" class BuildWorkflowGuardTest(unittest.TestCase): - def _run(self, workflow: str) -> subprocess.CompletedProcess[str]: + def _run(self, workflow: str, action: str | None = None) -> subprocess.CompletedProcess[str]: with tempfile.TemporaryDirectory(prefix="plezy-build-workflow-test-") as directory: - fixture = Path(directory) / "build.yml" + # The checker resolves the shared bootstrap beside the workflow, so + # the fixture has to mirror the real `.github` layout. + github = Path(directory) / ".github" + fixture = github / "workflows/build.yml" + fixture.parent.mkdir(parents=True) fixture.write_text(workflow, encoding="utf-8") + bootstrap = github / "actions/setup-flutter-git/action.yml" + bootstrap.parent.mkdir(parents=True) + bootstrap.write_text(action if action is not None else self._action(), encoding="utf-8") return subprocess.run( [sys.executable, str(CHECKER), str(fixture)], cwd=ROOT, @@ -29,6 +37,9 @@ class BuildWorkflowGuardTest(unittest.TestCase): def _workflow(self) -> str: return WORKFLOW.read_text(encoding="utf-8") + def _action(self) -> str: + return SETUP_FLUTTER_GIT.read_text(encoding="utf-8") + def test_locked_root_signer_passes(self) -> None: result = self._run(self._workflow()) @@ -36,16 +47,17 @@ class BuildWorkflowGuardTest(unittest.TestCase): self.assertIn("architecture matrix checks passed", result.stdout) def test_windows_arm_flutter_without_release_tag_is_rejected(self) -> None: - workflow = self._workflow().replace( - "git -C $root fetch --depth 1 origin refs/tags/3.44.0:refs/tags/3.44.0", + action = self._action().replace( + 'git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"', "git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42", 1, ) + self.assertNotEqual(action, self._action(), "fixture mutation no longer matches the action") - result = self._run(workflow) + result = self._run(self._workflow(), action) self.assertNotEqual(result.returncode, 0) - self.assertIn("refs/tags/3.44.0", result.stderr) + self.assertIn("refs/tags/${version}", result.stderr) def test_mutable_download_in_signing_step_is_rejected(self) -> None: workflow = self._workflow().replace( diff --git a/scripts/test_run_maestro.py b/scripts/test_run_maestro.py index 0d889d84..6e86b65f 100755 --- a/scripts/test_run_maestro.py +++ b/scripts/test_run_maestro.py @@ -7,6 +7,7 @@ from contextlib import redirect_stderr, redirect_stdout from dataclasses import replace import io from pathlib import Path +import re import shlex import subprocess import sys @@ -23,10 +24,14 @@ ROOT_DIR = Path(__file__).resolve().parent.parent SCRIPTS_DIR = ROOT_DIR / "scripts" LOCAL_SCRIPT_TEST_DISPATCHER = SCRIPTS_DIR / "ci_checks.sh" CI_SCRIPT_TEST_DISPATCHER = ROOT_DIR / ".github/workflows/ci.yml" +# The guard roster both dispatchers above delegate to. It discovers the script +# tests by glob, so a new scripts/test_*.py is picked up without being listed. +GUARD_SCRIPT_TEST_DISPATCHER = SCRIPTS_DIR / "ci_guard_checks.sh" E2E_WORKFLOW = ROOT_DIR / ".github/workflows/e2e.yml" SCRIPT_TEST_DISPATCHERS = ( LOCAL_SCRIPT_TEST_DISPATCHER, CI_SCRIPT_TEST_DISPATCHER, + GUARD_SCRIPT_TEST_DISPATCHER, SCRIPTS_DIR / "ci_website_checks.sh", ) REGRESSION_FLOWS_DIR = ROOT_DIR / ".maestro/regression_flows" @@ -67,8 +72,14 @@ def _executable_script_tests() -> set[str]: def _dispatched_script_tests(path: Path) -> list[str]: dispatched = [] for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + # `for guard_test in scripts/test_*.py; do` dispatches the whole roster. + loop = re.match(r"for\s+\w+\s+in\s+(scripts/test_[^;\s]*\.py)\s*;?\s*(?:do)?$", stripped) + if loop: + dispatched.extend(sorted(match.name for match in ROOT_DIR.glob(loop.group(1)))) + continue try: - command = shlex.split(line.strip(), comments=True) + command = shlex.split(stripped, comments=True) except ValueError: continue if len(command) < 2 or Path(command[0]).name not in {"python", "python3"}: @@ -115,15 +126,20 @@ class ScriptTestDispatchTests(unittest.TestCase): self.assertSetEqual(dispatched, _executable_script_tests()) def test_real_jellyfin_fixture_test_is_in_local_and_ci_guards(self) -> None: + # Both aggregates reach the roster through the shared guard script, so + # the fixture test is covered exactly when the glob picks it up. for dispatcher in (LOCAL_SCRIPT_TEST_DISPATCHER, CI_SCRIPT_TEST_DISPATCHER): with self.subTest(dispatcher=dispatcher): - self.assertEqual( - _dispatched_script_tests(dispatcher).count( - "test_maestro_real_jellyfin.py" - ), - 1, + self.assertIn( + f"bash {GUARD_SCRIPT_TEST_DISPATCHER.relative_to(ROOT_DIR).as_posix()}", + dispatcher.read_text(encoding="utf-8"), ) + self.assertEqual( + _dispatched_script_tests(GUARD_SCRIPT_TEST_DISPATCHER).count("test_maestro_real_jellyfin.py"), + 1, + ) + class ParseConfigTests(unittest.TestCase): def test_basic_defaults(self) -> None: