Files
2026-07-13 13:22:34 +08:00

258 lines
7.9 KiB
Python

"""Validate GitHub Actions workflow and action files.
Complements `.github/policy.rego` with checks that need cross-file or remote
context.
"""
import json
import re
import subprocess
import sys
from collections import defaultdict
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
import yaml
# Matches a `uses:` line that references a remote action (not a local `./` path).
# Captures: owner/repo[/subpath] @ ref [ # comment ]
_USES_RE = re.compile(
r"""
^\s*-?\s*uses:\s+ # leading `- uses:` or `uses:`
(?P<action>[^@\s]+) # owner/repo[/subpath]
@
(?P<ref>[^\s#]+) # ref (SHA, tag, or branch)
(?:\s+\#\s*(?P<comment>\S+))? # optional # comment
""",
re.VERBOSE,
)
# A full 40-character hexadecimal SHA.
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
# Requires at least vMAJOR.MINOR.PATCH to avoid ambiguous moving tags like v4.
_VERSION_COMMENT_RE = re.compile(r"^v\d+\.\d+\.\d+(?:\.\d+)*$")
_CACHE_PATH = Path(".cache/action-pins.json")
def _load_cache() -> dict[str, bool]:
if _CACHE_PATH.exists():
try:
return json.loads(_CACHE_PATH.read_text()) # type: ignore[no-any-return]
except (json.JSONDecodeError, OSError):
pass
return {}
def _save_cache(cache: dict[str, bool]) -> None:
try:
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_CACHE_PATH.write_text(json.dumps(cache, indent=2, sort_keys=True))
except OSError:
pass
def _repo_from_action(action: str) -> str:
match action.split("/"):
case [owner, repo, *_]:
return f"{owner}/{repo}"
case _:
raise ValueError(f"Invalid action format: {action!r}")
def _verify_sha_tag(action: str, sha: str, tag: str, cache: dict[str, bool]) -> bool | None:
cache_key = f"{action}@{sha}#{tag}"
if cache_key in cache:
return cache[cache_key]
repo = _repo_from_action(action)
try:
result = _resolve_tag(repo, sha, tag)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
return None
cache[cache_key] = result
return result
def _resolve_tag(repo: str, sha: str, tag: str) -> bool:
output = subprocess.check_output(
["git", "ls-remote", "--tags", f"https://github.com/{repo}.git", tag],
text=True,
timeout=10,
)
return any(line.split()[0] == sha for line in output.splitlines() if line)
def _iter_files() -> Iterator[Path]:
root = Path(".github")
for pattern in (
"workflows/*.yml",
"workflows/*.yaml",
"actions/**/*.yml",
"actions/**/*.yaml",
):
yield from root.glob(pattern)
@dataclass(frozen=True, slots=True)
class ActionRef:
prefix: str
action: str
ref: str
comment: str | None
def _iter_actions(path: Path) -> Iterator[ActionRef]:
with path.open(encoding="utf-8") as f:
for lineno, line in enumerate(f, start=1):
if m := _USES_RE.match(line):
action = m.group("action")
if not action.startswith("./"):
prefix = f"{path}:{lineno}: {line.strip()!r}"
yield ActionRef(prefix, action, m.group("ref"), m.group("comment"))
def _check_action(a: ActionRef, cache: dict[str, bool]) -> str | None:
if not _SHA_RE.match(a.ref):
return f"{a.prefix}\n error: ref '{a.ref}' is not a 40-character SHA"
if not a.comment or not _VERSION_COMMENT_RE.match(a.comment):
return (
f"{a.prefix}\n error: missing or invalid version comment"
f" (expected '# vX.Y.Z', got {a.comment!r})"
)
verified = _verify_sha_tag(a.action, a.ref, a.comment, cache)
if verified is None:
return (
f"{a.prefix}\n error: could not verify SHA against tag '{a.comment}'"
f" for {_repo_from_action(a.action)} (GitHub API unavailable)"
)
if not verified:
return (
f"{a.prefix}\n error: SHA '{a.ref}' does not match tag '{a.comment}'"
f" for {_repo_from_action(a.action)}"
)
return None
_LITERAL_CHARS = re.compile(r"[A-Za-z0-9._/\-]")
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
# GitHub uses minimatch-style globs: `**` crosses `/`, `*` does not.
i = 0
n = len(pattern)
parts = ["^"]
while i < n:
c = pattern[i]
if c == "*" and i + 1 < n and pattern[i + 1] == "*":
i += 2
if i < n and pattern[i] == "/":
# `**/` matches zero or more path segments
parts.append("(?:.*/)?")
i += 1
else:
parts.append(".*")
elif c == "*":
parts.append("[^/]*")
i += 1
elif _LITERAL_CHARS.match(c):
parts.append(re.escape(c))
i += 1
else:
raise ValueError(
f"Unsupported character {c!r} at position {i} in pattern {pattern!r}."
" Extend _glob_to_regex if this is a valid GitHub path filter character."
)
parts.append("$")
return re.compile("".join(parts))
def _list_tracked_files() -> list[str]:
return subprocess.check_output(["git", "ls-files"], text=True).splitlines()
def _pattern_matches(pattern: str, files: list[str]) -> bool:
regex = _glob_to_regex(pattern.removeprefix("!"))
return any(regex.match(f) for f in files)
def _iter_path_patterns(path: Path) -> Iterator[tuple[str, str, str]]:
data = yaml.load(path.read_text(encoding="utf-8"), Loader=yaml.CSafeLoader)
if not isinstance(data, dict):
return
# PyYAML parses the literal `on:` key as the boolean True (YAML 1.1).
on = data.get("on", data.get(True))
if not isinstance(on, dict):
return
for event, cfg in on.items():
if not isinstance(cfg, dict):
continue
for key in ("paths", "paths-ignore"):
for pattern in cfg.get(key) or []:
yield str(event), key, pattern
def _iter_workflow_files() -> Iterator[Path]:
root = Path(".github/workflows")
for ext in ("*.yml", "*.yaml"):
yield from root.glob(ext)
def _check_paths() -> Iterator[str]:
files = _list_tracked_files()
for path in sorted(_iter_workflow_files()):
for event, key, pattern in _iter_path_patterns(path):
if not _pattern_matches(pattern, files):
yield (
f"{path}: [on.{event}.{key}] pattern {pattern!r} does not"
" match any tracked file"
)
def _check_version_consistency(all_action_refs: list[ActionRef]) -> Iterator[str]:
by_action: dict[str, list[ActionRef]] = defaultdict(list)
for action_ref in all_action_refs:
by_action[action_ref.action].append(action_ref)
for action, refs in sorted(by_action.items()):
versions = {(ref.ref, ref.comment) for ref in refs}
if len(versions) > 1:
lines = "\n".join(f" {ref.prefix}" for ref in sorted(refs, key=lambda r: r.prefix))
yield f"{action} is pinned to multiple versions:\n{lines}"
def main() -> int:
cache = _load_cache()
all_errors: list[str] = []
all_action_refs: list[ActionRef] = []
try:
for path in _iter_files():
for action_ref in _iter_actions(path):
if error := _check_action(action_ref, cache):
all_errors.append(error)
else:
all_action_refs.append(action_ref)
finally:
_save_cache(cache)
all_errors.extend(_check_version_consistency(all_action_refs))
all_errors.extend(_check_paths())
if all_errors:
print("check-actions: the following violations were found:\n", file=sys.stderr)
for err in all_errors:
print(err, file=sys.stderr)
print(f"\n{len(all_errors)} violation(s) found.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())