Something went wrong. Try again.
READ-ONLY MIRROR of the previous soteria repo
Something went wrong. Try again.
Python
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320#!/usr/bin/env python3"""GitHub Actions pinning script for the codebase.
This script pins every external GitHub Action to an immutable commit SHA, whilerecording the tracked major version as a trailing annotation comment on the`uses:` line:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #[actionpin: v6]
The annotation grammar is `#[actionpin: vMAJOR]`. The action path and pinned SHAare read from the `uses:` line itself.
Commands:- renew-pins Repin each action to the latest release within its current major. Self-bootstrapping: an un-annotated `uses: org/repo@vN` line is pinned and annotated on first run. Supports --dry-run.- upgrade-versions Bump each action's major version if a newer major release exists, then pin to the latest release in that new major. Supports --dry-run.
Remote lookups use the `gh` CLI (authenticated), so both commands require `gh`to be installed and logged in."""
import argparseimport reimport subprocessimport sysfrom pathlib import Path
from soteria_utils import *
# Directory scanned (recursively) for workflow / action YAML filesSCAN_DIR = ".github"
# Regex to match the trailing annotation on a `uses:` line: #[actionpin: vMAJOR]TAG_PATTERN = re.compile(r"#\s*\[actionpin:\s*(v\d+)\s*\]")
# Regex to match a `uses:` line; captures (indent, path, ref). Handles both# "uses: x@ref" and "- uses: x@ref". The ref capture stops at whitespace, so a# trailing annotation comment is excluded.USES_PATTERN = re.compile(r"^(\s*)(?:-\s*)?uses:\s*([^@\s]+)@(\S+)")
# Regex to recognise a clean version tag (vX, vX.Y, vX.Y.Z), no pre-release suffixVERSION_TAG_PATTERN = re.compile(r"^v(\d+)(?:\.(\d+))?(?:\.(\d+))?$")
def run_command(cmd: list[str]) -> str: """Run a command and return its stdout. Exits the program on failure.""" try: result = subprocess.run( cmd, capture_output=True, text=True, check=False, ) if result.returncode != 0: error(f"Command failed: {' '.join(cmd)}") error(f"Error output: {result.stderr.strip()}") sys.exit(1) return result.stdout.strip() except FileNotFoundError: error(f"Command not found: {cmd[0]}") if cmd[0] == "gh": info("Install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.") sys.exit(1)
def is_external(path: str) -> bool: """A `uses:` path is external if it is not a local (./) action reference.""" return not path.startswith("./")
def repo_of(path: str) -> str: """Return the org/repo of an action path, stripping any subdir.
e.g. actions/cache/restore -> actions/cache """ return "/".join(path.split("/")[:2])
def parse_version(tag: str) -> tuple[int, int, int] | None: """Parse a clean version tag into a (major, minor, patch) tuple, or None.""" match = VERSION_TAG_PATTERN.match(tag) if not match: return None major = int(match.group(1)) minor = int(match.group(2)) if match.group(2) else 0 patch = int(match.group(3)) if match.group(3) else 0 return major, minor, patch
# Per-run cache of repo -> list of (version_tuple, tag_name)_tags_cache: dict[str, list[tuple[tuple[int, int, int], str]]] = {}# Per-run cache of (repo, tag) -> commit SHA_sha_cache: dict[tuple[str, str], str] = {}
def get_version_tags(repo: str) -> list[tuple[tuple[int, int, int], str]]: """Return all clean version tags of a repo as (version_tuple, tag_name).""" if repo not in _tags_cache: out = run_command( ["gh", "api", "--paginate", f"repos/{repo}/tags", "--jq", ".[].name"] ) tags = [] for name in out.splitlines(): name = name.strip() version = parse_version(name) if version is not None: tags.append((version, name)) _tags_cache[repo] = tags return _tags_cache[repo]
def latest_in_major(repo: str, major: int) -> tuple[tuple[int, int, int], str] | None: """Return the (version_tuple, tag_name) of the latest release in a major.""" candidates = [t for t in get_version_tags(repo) if t[0][0] == major] if not candidates: return None return max(candidates, key=lambda t: t[0])
def max_major(repo: str) -> int | None: """Return the highest major version available for a repo.""" tags = get_version_tags(repo) if not tags: return None return max(t[0][0] for t in tags)
def resolve_sha(repo: str, tag: str) -> str: """Resolve a tag to its underlying commit SHA (dereferences annotated tags).""" key = (repo, tag) if key not in _sha_cache: _sha_cache[key] = run_command( ["gh", "api", f"repos/{repo}/commits/{tag}", "--jq", ".sha"] ) return _sha_cache[key]
def find_yaml_files(root: Path) -> list[Path]: """Return all YAML files under the scan directory, sorted.""" scan_root = root / SCAN_DIR files = sorted(scan_root.rglob("*.yml")) + sorted(scan_root.rglob("*.yaml")) return sorted(set(files))
def process_file( file_path: Path, file_rel: Path, upgrade: bool, dry_run: bool, progress: Progress,) -> bool: """Renew (or upgrade) pins in a single file.
Returns True if the file was modified (or would be, in dry-run mode). """ lines = file_path.read_text().splitlines(keepends=True) changed = False
for i, line in enumerate(lines): uses_match = USES_PATTERN.match(line) if not uses_match: continue _, path, ref = uses_match.groups() if not is_external(path): continue
progress.advance() repo = repo_of(path)
# Determine the current major being tracked. tag_match = TAG_PATTERN.search(line) if tag_match is not None: current_major = int(tag_match.group(1)[1:]) current_sha = ref else: # No annotation yet: derive the major from the `@vN` ref (bootstrap). version = parse_version(ref) if version is None: warn( f"{file_rel}:{i + 1} - {path}@{ref} is not annotated and its ref " f"is not a version tag; skipping (pin it manually first)." ) continue current_major = version[0] current_sha = ref
# Pick the target major. if upgrade: top = max_major(repo) if top is None: warn(f"{file_rel}:{i + 1} - no version tags found for {repo}; skipping.") continue target_major = top else: target_major = current_major
latest = latest_in_major(repo, target_major) if latest is None: warn( f"{file_rel}:{i + 1} - no v{target_major}.x release found for {repo}; " f"skipping." ) continue (_, latest_tag) = latest new_sha = resolve_sha(repo, latest_tag)
major_changed = target_major != current_major sha_changed = new_sha != current_sha
if not major_changed and not sha_changed: continue
action_kind = "UPGRADE" if major_changed else "RENEW" detail = ( f"{path}: v{current_major} ({current_sha[:8]}) -> " f"v{target_major} {latest_tag} ({new_sha[:8]})" )
if dry_run: info(f"{action_kind} {file_rel}:{i + 1} - {detail}") changed = True continue
# Apply: rewrite the ref to the new SHA and (re)set the trailing annotation. base = TAG_PATTERN.sub("", line).rstrip() base = base.replace(f"@{ref}", f"@{new_sha}", 1) lines[i] = f"{base} #[actionpin: v{target_major}]\n"
info(f"{action_kind} {file_rel}:{i + 1} - {detail}") changed = True
if changed and not dry_run: file_path.write_text("".join(lines))
return changed
def count_external_uses(files: list[Path]) -> int: """Count external `uses:` lines across the given files (progress bar total).""" total = 0 for file_path in files: for line in file_path.read_text().splitlines(): match = USES_PATTERN.match(line) if match and is_external(match.group(2)): total += 1 return total
def cmd_pins(root: Path, upgrade: bool, dry_run: bool) -> int: """Renew or upgrade pins across all scanned files.""" files = find_yaml_files(root) label = "Upgrading versions" if upgrade else "Renewing pins"
any_change = False with Progress(label, count_external_uses(files)) as progress: for file_path in files: file_rel = file_path.relative_to(root) if process_file(file_path, file_rel, upgrade, dry_run, progress): any_change = True
if not any_change: success("All pins are already up to date.") elif dry_run: info("Dry run: no files were modified.") else: success("Pins updated.") return 0
def main() -> int: parser = argparse.ArgumentParser( description="GitHub Actions SHA-pinning script for Soteria", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""Examples: %(prog)s renew-pins Repin to the latest release within each major %(prog)s renew-pins --dry-run Show what renewing would change %(prog)s upgrade-versions Bump majors where a newer major exists %(prog)s upgrade-versions --dry-run Show available major upgrades
Annotation format (trailing comment on the `uses:` line): uses: actions/checkout@<sha> #[actionpin: v6]""", )
subparsers = parser.add_subparsers(dest="command", required=True)
renew_parser = subparsers.add_parser( "renew-pins", help="Repin to the latest release within each current major" ) renew_parser.add_argument( "--dry-run", action="store_true", help="Show changes without writing files" )
upgrade_parser = subparsers.add_parser( "upgrade-versions", help="Bump majors when a newer major release exists" ) upgrade_parser.add_argument( "--dry-run", action="store_true", help="Show changes without writing files" )
args = parser.parse_args()
root = Path(__file__).parent.parent
if args.command == "renew-pins": return cmd_pins(root, upgrade=False, dry_run=args.dry_run) elif args.command == "upgrade-versions": return cmd_pins(root, upgrade=True, dry_run=args.dry_run)
return 1
if __name__ == "__main__": sys.exit(main())