fix(ci): follow the guard roster and Flutter pin to their current homes

Two workflow guards had drifted from the code they describe, so
`scripts/ci_guard_checks.sh` failed on a clean tree.

The Flutter release-tag pin moved out of build.yml into the shared
setup-flutter-git composite action, but the checker read that action
from a fixed repository path while its test mutated a workflow fixture.
The mutation could not reach the checker, so the rejection test asserted
against an unmodified run. The checker now resolves the action beside
the workflow it is given, and the test materialises a `.github` tree so
the pin is genuinely exercised.

The script-test roster likewise moved into ci_guard_checks.sh, which
discovers `scripts/test_*.py` by glob; the dispatch guard still expected
each one to be named explicitly in ci_checks.sh and ci.yml. It now reads
that glob and checks both aggregates delegate to the shared roster.
This commit is contained in:
edde746
2026-07-26 23:05:17 +02:00
parent fcda012468
commit 0c58b2dc55
3 changed files with 44 additions and 14 deletions
+4 -2
View File
@@ -10,13 +10,15 @@ from workflow_yaml import iter_uses_references, job_block
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
DEFAULT_WORKFLOW = ROOT / ".github/workflows/build.yml" 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_VERSION = "3.44.0"
FLUTTER_COMMIT = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" FLUTTER_COMMIT = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
if len(sys.argv) > 2: if len(sys.argv) > 2:
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [workflow-path]") 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 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") text = WORKFLOW.read_text(encoding="utf-8")
errors: list[str] = [] errors: list[str] = []
+18 -6
View File
@@ -11,13 +11,21 @@ import unittest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
CHECKER = ROOT / "scripts/check_build_workflow.py" CHECKER = ROOT / "scripts/check_build_workflow.py"
WORKFLOW = ROOT / ".github/workflows/build.yml" WORKFLOW = ROOT / ".github/workflows/build.yml"
SETUP_FLUTTER_GIT = ROOT / ".github/actions/setup-flutter-git/action.yml"
class BuildWorkflowGuardTest(unittest.TestCase): 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: 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") 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( return subprocess.run(
[sys.executable, str(CHECKER), str(fixture)], [sys.executable, str(CHECKER), str(fixture)],
cwd=ROOT, cwd=ROOT,
@@ -29,6 +37,9 @@ class BuildWorkflowGuardTest(unittest.TestCase):
def _workflow(self) -> str: def _workflow(self) -> str:
return WORKFLOW.read_text(encoding="utf-8") 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: def test_locked_root_signer_passes(self) -> None:
result = self._run(self._workflow()) result = self._run(self._workflow())
@@ -36,16 +47,17 @@ class BuildWorkflowGuardTest(unittest.TestCase):
self.assertIn("architecture matrix checks passed", result.stdout) self.assertIn("architecture matrix checks passed", result.stdout)
def test_windows_arm_flutter_without_release_tag_is_rejected(self) -> None: def test_windows_arm_flutter_without_release_tag_is_rejected(self) -> None:
workflow = self._workflow().replace( action = self._action().replace(
"git -C $root fetch --depth 1 origin refs/tags/3.44.0:refs/tags/3.44.0", 'git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"',
"git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42", "git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42",
1, 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.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: def test_mutable_download_in_signing_step_is_rejected(self) -> None:
workflow = self._workflow().replace( workflow = self._workflow().replace(
+22 -6
View File
@@ -7,6 +7,7 @@ from contextlib import redirect_stderr, redirect_stdout
from dataclasses import replace from dataclasses import replace
import io import io
from pathlib import Path from pathlib import Path
import re
import shlex import shlex
import subprocess import subprocess
import sys import sys
@@ -23,10 +24,14 @@ ROOT_DIR = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = ROOT_DIR / "scripts" SCRIPTS_DIR = ROOT_DIR / "scripts"
LOCAL_SCRIPT_TEST_DISPATCHER = SCRIPTS_DIR / "ci_checks.sh" LOCAL_SCRIPT_TEST_DISPATCHER = SCRIPTS_DIR / "ci_checks.sh"
CI_SCRIPT_TEST_DISPATCHER = ROOT_DIR / ".github/workflows/ci.yml" 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" E2E_WORKFLOW = ROOT_DIR / ".github/workflows/e2e.yml"
SCRIPT_TEST_DISPATCHERS = ( SCRIPT_TEST_DISPATCHERS = (
LOCAL_SCRIPT_TEST_DISPATCHER, LOCAL_SCRIPT_TEST_DISPATCHER,
CI_SCRIPT_TEST_DISPATCHER, CI_SCRIPT_TEST_DISPATCHER,
GUARD_SCRIPT_TEST_DISPATCHER,
SCRIPTS_DIR / "ci_website_checks.sh", SCRIPTS_DIR / "ci_website_checks.sh",
) )
REGRESSION_FLOWS_DIR = ROOT_DIR / ".maestro/regression_flows" 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]: def _dispatched_script_tests(path: Path) -> list[str]:
dispatched = [] dispatched = []
for line in path.read_text(encoding="utf-8").splitlines(): 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: try:
command = shlex.split(line.strip(), comments=True) command = shlex.split(stripped, comments=True)
except ValueError: except ValueError:
continue continue
if len(command) < 2 or Path(command[0]).name not in {"python", "python3"}: 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()) self.assertSetEqual(dispatched, _executable_script_tests())
def test_real_jellyfin_fixture_test_is_in_local_and_ci_guards(self) -> None: 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): for dispatcher in (LOCAL_SCRIPT_TEST_DISPATCHER, CI_SCRIPT_TEST_DISPATCHER):
with self.subTest(dispatcher=dispatcher): with self.subTest(dispatcher=dispatcher):
self.assertEqual( self.assertIn(
_dispatched_script_tests(dispatcher).count( f"bash {GUARD_SCRIPT_TEST_DISPATCHER.relative_to(ROOT_DIR).as_posix()}",
"test_maestro_real_jellyfin.py" dispatcher.read_text(encoding="utf-8"),
),
1,
) )
self.assertEqual(
_dispatched_script_tests(GUARD_SCRIPT_TEST_DISPATCHER).count("test_maestro_real_jellyfin.py"),
1,
)
class ParseConfigTests(unittest.TestCase): class ParseConfigTests(unittest.TestCase):
def test_basic_defaults(self) -> None: def test_basic_defaults(self) -> None: