diff --git a/.github/actions/ai-agent-runner/action.yml b/.github/actions/ai-agent-runner/action.yml index af7c4fc31..76c38e18e 100644 --- a/.github/actions/ai-agent-runner/action.yml +++ b/.github/actions/ai-agent-runner/action.yml @@ -71,7 +71,7 @@ runs: using: "composite" steps: - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 diff --git a/.github/actions/contributor-check/action.yml b/.github/actions/contributor-check/action.yml index 59ad7564e..f64dfd084 100644 --- a/.github/actions/contributor-check/action.yml +++ b/.github/actions/contributor-check/action.yml @@ -39,7 +39,7 @@ runs: using: "composite" steps: - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" diff --git a/.github/workflows/ci-generation-check.yml b/.github/workflows/ci-generation-check.yml index 8bee69e00..993c9f030 100644 --- a/.github/workflows/ci-generation-check.yml +++ b/.github/workflows/ci-generation-check.yml @@ -3,6 +3,8 @@ name: ci-generation-check # Guards the deterministic CI workflow generator. When the manifest under # .github/ci or the generator script changes, regenerate and fail on any drift # so the committed YAML under .github/workflows always matches its source. +# Also validates that the hand authored composite actions under +# .github/actions/*/action.yml keep their pins in sync with the same registry. # This workflow is intentionally hand authored because it is the meta check # that guards generation; it is not itself generated. @@ -14,6 +16,7 @@ on: paths: - '.github/ci/**' - '.github/workflows/**' + - '.github/actions/**' - 'scripts/ci/generate_workflows.py' - 'tests/ci/**' diff --git a/scripts/ci/generate_workflows.py b/scripts/ci/generate_workflows.py index 4f033a2da..f618e0b10 100644 --- a/scripts/ci/generate_workflows.py +++ b/scripts/ci/generate_workflows.py @@ -19,6 +19,12 @@ optional proposal agent may edit the manifest, but it never writes YAML and the deterministic ``--check`` job is the gate, so generation stays reproducible and reviewable. + +The hand authored composite actions under ``.github/actions/*/action.yml`` are +not produced by this script, but they still pin actions such as setup-python +or setup-node directly, so ``--check``/``--write`` also validate and sync those +pins against the same registry. Otherwise they become a second, unreviewed +place those versions live and can silently drift from it. """ from __future__ import annotations @@ -32,6 +38,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] MANIFEST_PATH = REPO_ROOT / ".github" / "ci" / "workflows.toml" ACTIONS_PATH = REPO_ROOT / ".github" / "ci" / "actions.toml" +COMPOSITE_ACTIONS_DIR = REPO_ROOT / ".github" / "actions" OPA_VERSION = "0.70.0" OPA_LINUX_AMD64_SHA256 = "00d114b94fdb1606a48cccdfc73c9ccdc62c38721150131ae578d5ff3df5c084" @@ -83,6 +90,78 @@ def _load_actions(path: Path) -> dict[str, str]: return registry +def _composite_action_files() -> list[Path]: + """Hand authored composite actions under .github/actions/*/action.yml. + + These are not produced by this generator, but they still pin actions such + as setup-python or setup-node directly, so they must stay in sync with the + registry or the pin becomes a second, unreviewed source of truth. + """ + if not COMPOSITE_ACTIONS_DIR.exists(): + return [] + return sorted(COMPOSITE_ACTIONS_DIR.glob("*/action.yml")) + + +def _registry_by_action_name(actions: dict[str, str]) -> dict[str, str]: + return {value.split("@", 1)[0]: value for value in actions.values()} + + +def _display_path(path: Path) -> str: + try: + return str(path.relative_to(REPO_ROOT)) + except ValueError: + return str(path) + + +# Matches a `uses:` step reference with or without a leading `- ` list marker, +# e.g. " - uses: actions/checkout@SHA # v1" or " uses: actions/x@SHA". +USES_LINE_RE = re.compile(r"^(?P\s*(?:-\s+)?uses:\s*)(?P\S.*?)\s*$") + + +def check_composite_action_pins(actions: dict[str, str]) -> list[str]: + """Return one message per composite action pin that drifted from the registry.""" + registry_by_name = _registry_by_action_name(actions) + drifted: list[str] = [] + for path in _composite_action_files(): + rel = _display_path(path) + for line in path.read_text(encoding="utf-8").splitlines(): + match = USES_LINE_RE.match(line) + if not match: + continue + value = match.group("value") + name = value.split("@", 1)[0] + canonical = registry_by_name.get(name) + if canonical is not None and value != canonical: + drifted.append( + f"{rel}: '{name}' pinned to '{value}', registry expects '{canonical}'" + ) + return drifted + + +def sync_composite_action_pins(actions: dict[str, str]) -> list[Path]: + """Rewrite composite action `uses:` lines to match the registry. Returns changed files.""" + registry_by_name = _registry_by_action_name(actions) + changed: list[Path] = [] + for path in _composite_action_files(): + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + touched = False + for index, line in enumerate(lines): + match = USES_LINE_RE.match(line) + if not match: + continue + value = match.group("value") + name = value.split("@", 1)[0] + canonical = registry_by_name.get(name) + if canonical is not None and value != canonical: + newline = "\n" if line.endswith("\n") else "" + lines[index] = f"{match.group('prefix')}{canonical}{newline}" + touched = True + if touched: + path.write_text("".join(lines), encoding="utf-8") + changed.append(path) + return changed + + def _indent(text: str, spaces: int) -> list[str]: pad = " " * spaces return [f"{pad}{line}" if line else "" for line in text.splitlines()] @@ -255,6 +334,7 @@ def main(argv: list[str] | None = None) -> int: try: outputs = build_outputs() + actions = _load_actions(ACTIONS_PATH) except GenerationError as exc: print(f"error: {exc}", file=sys.stderr) return 2 @@ -264,6 +344,8 @@ def main(argv: list[str] | None = None) -> int: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") print(f"wrote {path.relative_to(REPO_ROOT)}") + for path in sync_composite_action_pins(actions): + print(f"synced {path.relative_to(REPO_ROOT)}") return 0 drifted: list[str] = [] @@ -273,6 +355,7 @@ def main(argv: list[str] | None = None) -> int: drifted.append(f"{rel} (missing, run --write)") elif path.read_text(encoding="utf-8") != content: drifted.append(f"{rel} (out of date, run --write)") + drifted.extend(check_composite_action_pins(actions)) if drifted: print("error: generated workflows are out of date:", file=sys.stderr) for item in drifted: diff --git a/tests/ci/test_generate_workflows.py b/tests/ci/test_generate_workflows.py index c2db9cb45..4f9392867 100644 --- a/tests/ci/test_generate_workflows.py +++ b/tests/ci/test_generate_workflows.py @@ -103,6 +103,52 @@ def test_check_mode_passes_on_committed_tree(): assert gen.main(["--check"]) == 0 +def test_composite_action_pins_match_registry(): + actions = gen._load_actions(gen.ACTIONS_PATH) + assert gen.check_composite_action_pins(actions) == [] + + +def test_composite_action_drift_is_detected(tmp_path, monkeypatch): + action_dir = tmp_path / "stale-action" + action_dir.mkdir() + (action_dir / "action.yml").write_text( + "runs:\n" + " using: composite\n" + " steps:\n" + " - uses: actions/checkout@0000000000000000000000000000000000000000 # v0.0.0\n", + encoding="utf-8", + ) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", tmp_path) + actions = gen._load_actions(gen.ACTIONS_PATH) + drift = gen.check_composite_action_pins(actions) + assert len(drift) == 1 + assert "stale-action/action.yml" in drift[0] + assert "actions/checkout" in drift[0] + + +def test_composite_action_sync_rewrites_only_the_pin_line(tmp_path, monkeypatch): + action_dir = tmp_path / "stale-action" + action_dir.mkdir() + original = ( + "name: Stale\n" + "runs:\n" + " using: composite\n" + " steps:\n" + " - uses: actions/checkout@0000000000000000000000000000000000000000 # v0.0.0\n" + " with:\n" + " fetch-depth: 0\n" + ) + (action_dir / "action.yml").write_text(original, encoding="utf-8") + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", tmp_path) + actions = gen._load_actions(gen.ACTIONS_PATH) + changed = gen.sync_composite_action_pins(actions) + assert len(changed) == 1 + rewritten = (action_dir / "action.yml").read_text(encoding="utf-8") + assert f"uses: {actions['checkout']}\n" in rewritten + assert "with:\n fetch-depth: 0\n" in rewritten + assert gen.check_composite_action_pins(actions) == [] + + def test_unpinned_action_is_rejected(tmp_path): bad = tmp_path / "actions.toml" bad.write_text('[checkout]\nuses = "actions/checkout@v4"\ncomment = "v4"\n', encoding="utf-8")