diff --git a/scripts/ci/generate_workflows.py b/scripts/ci/generate_workflows.py index 17f4b44fc..838ae62e3 100644 --- a/scripts/ci/generate_workflows.py +++ b/scripts/ci/generate_workflows.py @@ -10,8 +10,20 @@ Usage:: - python3 scripts/ci/generate_workflows.py --write # regenerate YAML - python3 scripts/ci/generate_workflows.py --check # fail on drift + python3 scripts/ci/generate_workflows.py --write # regenerate YAML + python3 scripts/ci/generate_workflows.py --check # fail on drift + python3 scripts/ci/generate_workflows.py --sync-registry # adopt bumped pins + +``--sync-registry`` runs generation in reverse. Dependabot can edit a pinned +action in the generated workflow (which lives under ``.github/workflows``) but +cannot edit ``.github/ci/actions.toml`` (its source of truth), so such a bump +leaves the generated file drifting from the registry and ``--check`` fails. This +mode reads the bumped pin back out of the managed files, updates the registry to +match, and then regenerates exactly as ``--write`` does, which also rewrites the +generated workflow YAML and synchronizes the composite action pins under +``.github/actions`` to the registry. It is the deterministic equivalent of the +manual registry sync a maintainer would otherwise push on every actions bump. +Every path it reads or writes is validated to stay within the repository. The renderer is intentionally deterministic and dependency free. It emits a ``DO NOT EDIT`` banner, enforces full SHA pinned actions resolved from the @@ -27,9 +39,12 @@ from __future__ import annotations import argparse +import json +import os import re import sys import tomllib +import urllib.request from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] @@ -114,12 +129,33 @@ def _composite_action_files() -> list[Path]: raise GenerationError( f"missing composite actions directory: {COMPOSITE_ACTIONS_DIR}" ) + if COMPOSITE_ACTIONS_DIR.is_symlink(): + raise GenerationError( + f"refusing to follow a symlinked composite actions directory: " + f"{_display_path(COMPOSITE_ACTIONS_DIR)}" + ) + boundary = COMPOSITE_ACTIONS_DIR.resolve() paths = { path for pattern in ("*/action.yml", "*/action.yaml") for path in COMPOSITE_ACTIONS_DIR.glob(pattern) } - return sorted(paths) + result: list[Path] = [] + for path in sorted(paths): + # Containment: refuse any symlinked action file, and any path that + # resolves outside the composite actions directory, so neither the pin + # reader nor the writer can be made to follow a link out of the tree. + if path.is_symlink(): + raise GenerationError( + f"refusing to follow a symlinked composite action file: {path}" + ) + if not path.resolve().is_relative_to(boundary): + raise GenerationError( + f"composite action path escapes " + f"{_display_path(COMPOSITE_ACTIONS_DIR)}: {path}" + ) + result.append(path) + return result def _display_path(path: Path) -> str: @@ -219,6 +255,226 @@ def sync_composite_action_pins(actions: dict[str, str]) -> list[Path]: return changed +# Anchored to a real step line: optional indent, an optional "- " list dash, then +# "uses:". A commented-out line ("# - uses: ...@sha # v9.0.0") starts with "#" +# after the indent and therefore never matches, so it is not treated as a pin +# source. Use with .match (start-anchored). +_PIN_LINE_RE = re.compile( + r"\s*(?:-\s+)?uses:\s*(?P[A-Za-z0-9._/-]+@[0-9a-f]{40})(?:\s*#\s*(?P.+?))?\s*$" +) +_SECTION_RE = re.compile(r"^\[(?P[^\]]+)\]\s*$") +# A trailing comment that looks like a resolvable version tag (e.g. "v7.0.1"). +# Non-version comments such as "stable" are not resolvable to a single commit and +# are left unverified. +_VERSION_COMMENT_RE = re.compile(r"^v\d+(?:\.\d+)*$") +# Version comments adopted into the registry are written as a TOML string, so +# constrain them to the shape of a real tag ("v7.0.1", "stable") and refuse +# anything that could break out of the quotes rather than embedding it. +_SAFE_COMMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._+:/-]*$") + + +def _registry_key_by_action_name(actions: dict[str, str]) -> dict[str, str]: + """Map a bare action name (owner/repo) to its registry key.""" + mapping: dict[str, str] = {} + for key, value in actions.items(): + reference = value.split(" #", 1)[0] + action_name = reference.split("@", 1)[0] + mapping[action_name] = key + return mapping + + +def _managed_pin_sources() -> list[Path]: + """Files whose action pins must track the registry. + + These are the generated workflow outputs plus the hand-authored composite + actions. Every registry entry is, by the registry's own contract, referenced + by at least one of these, so a Dependabot bump of any registered action lands + in one of them. + """ + sources = list(build_outputs().keys()) + sources.extend(_composite_action_files()) + return sources + + +def collect_registry_updates( + actions: dict[str, str], +) -> dict[str, tuple[str, str | None]]: + """Return registry updates implied by pins in the managed files. + + Scans the generated workflows and composite actions for pinned references + whose action name is in the registry, and reports any that differ from the + registry. This is the reverse of generation: it reads a Dependabot bump back + out of a file Dependabot can edit so the registry can adopt it. + + Returns a map of registry key to (new_uses, new_comment). Raises + GenerationError if two managed files disagree on the pin for the same action. + """ + key_by_name = _registry_key_by_action_name(actions) + observed: dict[str, tuple[str, str | None, str]] = {} + + for path in _managed_pin_sources(): + if not path.exists(): + continue + rel = _display_path(path) + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise GenerationError(f"cannot read {rel}: {exc}") from exc + for line in text.splitlines(): + match = _PIN_LINE_RE.match(line) + if not match: + continue + ref = match.group("ref") + key = key_by_name.get(ref.split("@", 1)[0]) + if key is None: + continue + comment = match.group("comment") + comment = comment.strip() if comment else None + if comment is not None and not _SAFE_COMMENT_RE.match(comment): + raise GenerationError( + f"refusing to adopt unsafe version comment for " + f"'{ref.split('@', 1)[0]}' from {rel}: {comment!r}" + ) + prior = observed.get(key) + if prior is not None and (prior[0], prior[1]) != (ref, comment): + raise GenerationError( + f"conflicting pins for '{ref.split('@', 1)[0]}': " + f"{prior[2]} has '{prior[0]}' but {rel} has '{ref}'" + ) + observed[key] = (ref, comment, rel) + + updates: dict[str, tuple[str, str | None]] = {} + for key, (ref, comment, _rel) in observed.items(): + current = actions[key] + current_uses = current.split(" #", 1)[0] + current_comment = ( + current.split(" #", 1)[1].strip() if " #" in current else None + ) + if (ref, comment) != (current_uses, current_comment): + updates[key] = (ref, comment) + return updates + + +def _rewrite_actions_toml(updates: dict[str, tuple[str, str | None]]) -> None: + """Rewrite the uses/comment of the named registry entries in place. + + Operates line by line so the registry file keeps its layout and comments. + """ + if ACTIONS_PATH.is_symlink(): + raise GenerationError( + f"refusing to write through a symlinked registry path: " + f"{_display_path(ACTIONS_PATH)}" + ) + try: + lines = ACTIONS_PATH.read_text(encoding="utf-8").splitlines(keepends=True) + except (OSError, UnicodeError) as exc: + raise GenerationError(f"cannot read {_display_path(ACTIONS_PATH)}: {exc}") from exc + + current_key: str | None = None + out: list[str] = [] + for line in lines: + section = _SECTION_RE.match(line) + if section: + current_key = section.group("key") + out.append(line) + continue + if current_key in updates: + uses, comment = updates[current_key] + stripped = line.lstrip() + indent = line[: len(line) - len(stripped)] + newline = "\n" if line.endswith("\n") else "" + if stripped.startswith("uses ="): + out.append(f'{indent}uses = "{uses}"{newline}') + continue + if stripped.startswith("comment ="): + if comment is None: + continue + out.append(f'{indent}comment = "{comment}"{newline}') + continue + out.append(line) + + try: + ACTIONS_PATH.write_text("".join(out), encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise GenerationError( + f"cannot write {_display_path(ACTIONS_PATH)}: {exc}" + ) from exc + + +def _resolve_tag_commit_sha(action_name: str, version: str, *, token: str | None) -> str | None: + """Resolve ``owner/repo@version`` to the 40-hex commit SHA that tag points to, + via the GitHub API. Returns the lowercased SHA, or None if it cannot be + resolved (network/auth error, missing tag, malformed response). + """ + url = f"https://api.github.com/repos/{action_name}/commits/{version}" + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "agt-generate-workflows", + "X-GitHub-Api-Version": "2022-11-28", + } + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=15) as resp: # fixed https GitHub host + payload = json.load(resp) + except Exception: + return None + sha = str(payload.get("sha", "")).lower() + return sha if re.fullmatch(r"[0-9a-f]{40}", sha) else None + + +def verify_pin_versions( + updates: dict[str, tuple[str, str | None]], *, token: str | None +) -> None: + """Confirm each adopted pin's SHA is the commit its version comment resolves to. + + Fails closed: a version-tagged pin whose SHA does not match, or cannot be + resolved, is refused, so a bumped SHA that does not correspond to its ``# vX`` + comment is never written into the registry. Non-version comments (for example + "stable") are not resolvable to a single commit and are left unverified. + """ + for _key, (uses, comment) in sorted(updates.items()): + if not comment or not _VERSION_COMMENT_RE.match(comment): + continue + action_name, _sep, sha = uses.partition("@") + sha = sha.lower() + resolved = _resolve_tag_commit_sha(action_name, comment, token=token) + if resolved is None: + raise GenerationError( + f"cannot verify {action_name}@{comment} resolves to a commit " + f"(network/token/tag); refusing to adopt {sha}" + ) + if resolved != sha: + raise GenerationError( + f"pin mismatch for {action_name}: {comment} resolves to " + f"{resolved}, not {sha}" + ) + + +def sync_registry_from_tree(*, verify: bool = True) -> list[str]: + """Adopt pins bumped in managed files into the registry. + + When ``verify`` is true (the default), each adopted version-tagged pin is + checked against the GitHub API so a SHA that does not resolve to its ``# vX`` + comment is refused rather than written in. Returns the sorted registry keys + that were updated. + """ + if ACTIONS_PATH.is_symlink(): + raise GenerationError( + f"refusing to read or write a symlinked registry path: " + f"{_display_path(ACTIONS_PATH)}" + ) + actions = _load_actions(ACTIONS_PATH) + updates = collect_registry_updates(actions) + if updates and verify: + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + verify_pin_versions(updates, token=token) + if updates: + _rewrite_actions_toml(updates) + return sorted(updates) + + def _indent(text: str, spaces: int) -> list[str]: pad = " " * spaces return [f"{pad}{line}" if line else "" for line in text.splitlines()] @@ -374,12 +630,24 @@ def build_outputs(actions: dict[str, str] | None = None) -> dict[Path, str]: raise GenerationError("manifest defines no [[workflow]] entries") outputs: dict[Path, str] = {} seen_ids: set[str] = set() + repo_root = REPO_ROOT.resolve() for workflow in workflows: wid = workflow.get("id", "") if wid in seen_ids: raise GenerationError(f"duplicate workflow id: {wid}") seen_ids.add(wid) - outputs[REPO_ROOT / workflow["output"]] = render_workflow(workflow, actions) + content = render_workflow(workflow, actions) + out_path = REPO_ROOT / workflow["output"] + # Containment: the manifest output must resolve beneath the repo, so a + # traversal like .github/workflows/../../../x.yml cannot make the + # generator (or --sync-registry, which reads these paths) touch a file + # outside the tree. + if not out_path.resolve().is_relative_to(repo_root): + raise GenerationError( + f"workflow '{workflow.get('name', wid)}' output escapes the " + f"repository: {workflow['output']}" + ) + outputs[out_path] = content return outputs @@ -388,8 +656,31 @@ def main(argv: list[str] | None = None) -> int: group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--write", action="store_true", help="write generated workflow YAML") group.add_argument("--check", action="store_true", help="fail if committed YAML drifts") + group.add_argument( + "--sync-registry", + action="store_true", + help="adopt action pins bumped in the generated workflows or composite " + "actions into actions.toml, then regenerate", + ) + parser.add_argument( + "--no-verify-pins", + action="store_true", + help="with --sync-registry, skip the GitHub API check that each adopted " + "SHA resolves to its version comment (offline/emergency use only)", + ) args = parser.parse_args(argv) + if args.sync_registry: + try: + updated = sync_registry_from_tree(verify=not args.no_verify_pins) + except GenerationError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + for key in updated: + print(f"synced registry entry: {key}") + if not updated: + print("registry already matches the pins in the managed files") + try: actions = _load_actions(ACTIONS_PATH) outputs = build_outputs(actions) @@ -397,7 +688,7 @@ def main(argv: list[str] | None = None) -> int: print(f"error: {exc}", file=sys.stderr) return 2 - if args.write: + if args.write or args.sync_registry: try: synced = sync_composite_action_pins(actions) except GenerationError as exc: diff --git a/tests/ci/test_generate_workflows.py b/tests/ci/test_generate_workflows.py index c458a189b..6854771b8 100644 --- a/tests/ci/test_generate_workflows.py +++ b/tests/ci/test_generate_workflows.py @@ -195,6 +195,287 @@ def test_unknown_toolchain_is_rejected(): gen.render_workflow(workflow, actions) +def test_sync_registry_adopts_a_bumped_pin(tmp_path, monkeypatch): + # A Dependabot-shaped bump: the generated workflow carries a new SHA and + # version comment, the registry still has the old pin. Sync adopts it. + new_sha = "a" * 40 + registry = tmp_path / "actions.toml" + registry.write_text( + f'[checkout]\nuses = "actions/checkout@{"b" * 40}"\ncomment = "v7.0.1"\n', + encoding="utf-8", + ) + generated = tmp_path / "policy-engine-ci.yml" + generated.write_text( + "jobs:\n x:\n steps:\n" + f" - uses: actions/checkout@{new_sha} # v7.1.0\n", + encoding="utf-8", + ) + composite = tmp_path / "composite" + composite.mkdir() + monkeypatch.setattr(gen, "ACTIONS_PATH", registry) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", composite) + monkeypatch.setattr( + gen, "build_outputs", lambda *_a: {generated: generated.read_text("utf-8")} + ) + + assert gen.sync_registry_from_tree(verify=False) == ["checkout"] + updated = registry.read_text(encoding="utf-8") + assert f'uses = "actions/checkout@{new_sha}"' in updated + assert 'comment = "v7.1.0"' in updated + + +def test_sync_registry_is_a_noop_when_already_in_sync(tmp_path, monkeypatch): + sha = "c" * 40 + registry = tmp_path / "actions.toml" + registry.write_text( + f'[checkout]\nuses = "actions/checkout@{sha}"\ncomment = "v7.1.0"\n', + encoding="utf-8", + ) + original = registry.read_text(encoding="utf-8") + generated = tmp_path / "policy-engine-ci.yml" + generated.write_text( + f" - uses: actions/checkout@{sha} # v7.1.0\n", encoding="utf-8" + ) + composite = tmp_path / "composite" + composite.mkdir() + monkeypatch.setattr(gen, "ACTIONS_PATH", registry) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", composite) + monkeypatch.setattr( + gen, "build_outputs", lambda *_a: {generated: generated.read_text("utf-8")} + ) + + assert gen.sync_registry_from_tree() == [] + assert registry.read_text(encoding="utf-8") == original + + +def test_sync_registry_fails_closed_on_conflicting_pins(tmp_path, monkeypatch): + registry = tmp_path / "actions.toml" + registry.write_text( + f'[checkout]\nuses = "actions/checkout@{"b" * 40}"\ncomment = "v7.0.1"\n', + encoding="utf-8", + ) + generated = tmp_path / "policy-engine-ci.yml" + generated.write_text( + f" - uses: actions/checkout@{'a' * 40} # v7.1.0\n", encoding="utf-8" + ) + composite = tmp_path / "composite" + action_dir = composite / "x" + action_dir.mkdir(parents=True) + (action_dir / "action.yml").write_text( + "runs:\n using: composite\n steps:\n" + f" - uses: actions/checkout@{'d' * 40} # v7.2.0\n", + encoding="utf-8", + ) + monkeypatch.setattr(gen, "ACTIONS_PATH", registry) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", composite) + monkeypatch.setattr( + gen, "build_outputs", lambda *_a: {generated: generated.read_text("utf-8")} + ) + + with pytest.raises(gen.GenerationError, match="conflicting pins"): + gen.sync_registry_from_tree() + + +def test_sync_registry_refuses_unsafe_comment(tmp_path, monkeypatch): + # A version comment that could break out of the TOML string is refused, + # not embedded, so a crafted managed file cannot corrupt actions.toml. + registry = tmp_path / "actions.toml" + original = f'[checkout]\nuses = "actions/checkout@{"b" * 40}"\ncomment = "v7.0.1"\n' + registry.write_text(original, encoding="utf-8") + generated = tmp_path / "policy-engine-ci.yml" + generated.write_text( + f' - uses: actions/checkout@{"a" * 40} # v1" evil = "x\n', + encoding="utf-8", + ) + composite = tmp_path / "composite" + composite.mkdir() + monkeypatch.setattr(gen, "ACTIONS_PATH", registry) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", composite) + monkeypatch.setattr( + gen, "build_outputs", lambda *_a: {generated: generated.read_text("utf-8")} + ) + + with pytest.raises(gen.GenerationError, match="unsafe version comment"): + gen.sync_registry_from_tree() + assert registry.read_text(encoding="utf-8") == original + + +def test_sync_registry_preserves_registry_layout_and_comments(tmp_path, monkeypatch): + # Only the bumped entry changes; header comments, blank lines, and the other + # entries are left byte for byte intact. + registry = tmp_path / "actions.toml" + registry.write_text( + "# header comment\n\n" + f'[checkout]\nuses = "actions/checkout@{"b" * 40}"\ncomment = "v7.0.1"\n\n' + f'[setup-node]\nuses = "actions/setup-node@{"e" * 40}"\ncomment = "v7.0.0"\n', + encoding="utf-8", + ) + generated = tmp_path / "policy-engine-ci.yml" + generated.write_text( + f" - uses: actions/checkout@{'a' * 40} # v7.1.0\n" + f" - uses: actions/setup-node@{'e' * 40} # v7.0.0\n", + encoding="utf-8", + ) + composite = tmp_path / "composite" + composite.mkdir() + monkeypatch.setattr(gen, "ACTIONS_PATH", registry) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", composite) + monkeypatch.setattr( + gen, "build_outputs", lambda *_a: {generated: generated.read_text("utf-8")} + ) + + assert gen.sync_registry_from_tree(verify=False) == ["checkout"] + text = registry.read_text(encoding="utf-8") + assert text.startswith("# header comment\n\n") + assert f'actions/setup-node@{"e" * 40}' in text + assert 'comment = "v7.0.0"' in text + assert f'actions/checkout@{"a" * 40}' in text + + +def test_sync_registry_refuses_symlinked_registry_path(tmp_path, monkeypatch): + # A symlinked actions.toml must not be written through. + real = tmp_path / "real_actions.toml" + real.write_text( + f'[checkout]\nuses = "actions/checkout@{"b" * 40}"\ncomment = "v7.0.1"\n', + encoding="utf-8", + ) + link = tmp_path / "actions.toml" + link.symlink_to(real) + generated = tmp_path / "policy-engine-ci.yml" + generated.write_text( + f" - uses: actions/checkout@{'a' * 40} # v7.1.0\n", encoding="utf-8" + ) + composite = tmp_path / "composite" + composite.mkdir() + monkeypatch.setattr(gen, "ACTIONS_PATH", link) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", composite) + monkeypatch.setattr( + gen, "build_outputs", lambda *_a: {generated: generated.read_text("utf-8")} + ) + + with pytest.raises(gen.GenerationError, match="symlink"): + gen.sync_registry_from_tree() + assert real.read_text(encoding="utf-8").count("b" * 40) == 1 + + +def test_intra_repo_symlinked_composite_file_is_rejected(tmp_path, monkeypatch): + # A symlinked action file is refused even when it resolves to another file + # inside the composite directory. + composite = tmp_path / "composite" + (composite / "real").mkdir(parents=True) + target = composite / "real" / "action.yml" + target.write_text("runs:\n using: composite\n steps: []\n", encoding="utf-8") + (composite / "linky").mkdir() + (composite / "linky" / "action.yml").symlink_to(target) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", composite) + + with pytest.raises(gen.GenerationError, match="symlink"): + gen._composite_action_files() + + +def test_symlinked_composite_actions_dir_is_rejected(tmp_path, monkeypatch): + real_dir = tmp_path / "real_actions_dir" + real_dir.mkdir() + link = tmp_path / "composite" + link.symlink_to(real_dir, target_is_directory=True) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", link) + + with pytest.raises(gen.GenerationError, match="symlink"): + gen._composite_action_files() + + +def test_composite_action_symlink_escape_is_rejected(tmp_path, monkeypatch): + # A composite action that is a symlink out of the actions directory is + # refused, so neither the reader nor the writer follows it out of the tree. + outside = tmp_path / "outside" + outside.mkdir() + (outside / "action.yml").write_text( + "runs:\n using: composite\n steps:\n" + f" - uses: actions/checkout@{'a' * 40} # v1.0.0\n", + encoding="utf-8", + ) + composite = tmp_path / "composite" + composite.mkdir() + (composite / "evil").symlink_to(outside, target_is_directory=True) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", composite) + + with pytest.raises(gen.GenerationError, match="escapes"): + gen._composite_action_files() + + +def test_build_outputs_rejects_output_path_traversal(monkeypatch): + actions = gen._load_actions(gen.ACTIONS_PATH) + evil = { + "workflow": [ + { + "id": "x", + "name": "x", + "output": ".github/workflows/../../../tmp/escape.yml", + "job": [{"id": "a", "step": [{"name": "n", "run": "echo hi"}]}], + } + ] + } + monkeypatch.setattr(gen, "_load_toml", lambda _p: evil) + with pytest.raises(gen.GenerationError, match="escapes the repository"): + gen.build_outputs(actions) + + +def test_pin_line_regex_ignores_commented_uses(): + # A commented-out step is not a pin source (anchored regex). + assert gen._PIN_LINE_RE.match(" # - uses: actions/checkout@" + "a" * 40 + " # v9.9.9") is None + assert gen._PIN_LINE_RE.match(" - uses: actions/checkout@" + "a" * 40 + " # v7.0.1") is not None + assert gen._PIN_LINE_RE.match(" uses: actions/setup-python@" + "b" * 40 + " # v7.0.0") is not None + + +def test_sync_ignores_commented_pin_but_adopts_real_one(tmp_path, monkeypatch): + registry = tmp_path / "actions.toml" + registry.write_text( + f'[checkout]\nuses = "actions/checkout@{"b" * 40}"\ncomment = "v7.0.1"\n', + encoding="utf-8", + ) + generated = tmp_path / "policy-engine-ci.yml" + # A commented-out bogus bump plus the real pin that matches the registry. + generated.write_text( + f" # - uses: actions/checkout@{'f' * 40} # v9.9.9\n" + f" - uses: actions/checkout@{'b' * 40} # v7.0.1\n", + encoding="utf-8", + ) + composite = tmp_path / "composite" + composite.mkdir() + monkeypatch.setattr(gen, "ACTIONS_PATH", registry) + monkeypatch.setattr(gen, "COMPOSITE_ACTIONS_DIR", composite) + monkeypatch.setattr(gen, "build_outputs", lambda *_a: {generated: generated.read_text("utf-8")}) + # The commented bogus line is ignored, so no update and no conflict. + assert gen.sync_registry_from_tree(verify=False) == [] + + +def test_verify_pin_versions_accepts_matching_sha(monkeypatch): + monkeypatch.setattr(gen, "_resolve_tag_commit_sha", lambda name, ver, *, token: "a" * 40) + gen.verify_pin_versions({"checkout": ("actions/checkout@" + "a" * 40, "v7.1.0")}, token=None) + + +def test_verify_pin_versions_rejects_mismatch(monkeypatch): + monkeypatch.setattr(gen, "_resolve_tag_commit_sha", lambda name, ver, *, token: "c" * 40) + with pytest.raises(gen.GenerationError, match="pin mismatch"): + gen.verify_pin_versions({"checkout": ("actions/checkout@" + "a" * 40, "v7.1.0")}, token=None) + + +def test_verify_pin_versions_fails_closed_when_unresolvable(monkeypatch): + monkeypatch.setattr(gen, "_resolve_tag_commit_sha", lambda name, ver, *, token: None) + with pytest.raises(gen.GenerationError, match="cannot verify"): + gen.verify_pin_versions({"checkout": ("actions/checkout@" + "a" * 40, "v9.9.9")}, token=None) + + +def test_verify_pin_versions_skips_non_version_comment(monkeypatch): + def _boom(*_a, **_k): + raise AssertionError("resolver must not be called for a non-version comment") + + monkeypatch.setattr(gen, "_resolve_tag_commit_sha", _boom) + gen.verify_pin_versions( + {"rust-toolchain": ("dtolnay/rust-toolchain@" + "a" * 40, "stable")}, token=None + ) + + def test_unknown_action_key_is_rejected(): actions = gen._load_actions(gen.ACTIONS_PATH) workflow = {