diff --git a/scripts/pin-git-references.py b/scripts/pin-git-references.py new file mode 100755 index 0000000..13fdc90 --- /dev/null +++ b/scripts/pin-git-references.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Pin mutable `uses:` GitHub Actions references in workflows to a commit SHA.""" + +import re +import subprocess +import sys +from pathlib import Path + +USES_RE = re.compile(r"^(\s*-?\s*uses:\s*)([^@\s#]+)@([^\s#]+)(\s*(#.*)?)$") +SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def resolve_ref(repo: str, ref: str) -> str: + """Resolves `ref` (a tag, branch, or HEAD) in `repo` to a commit SHA, or "" if unresolvable.""" + url = f"https://github.com/{repo}.git" + refspecs = ["HEAD"] if ref == "HEAD" else [f"refs/tags/{ref}", f"refs/heads/{ref}"] + result = subprocess.run(["git", "ls-remote", url, *refspecs], capture_output=True, text=True) + lines = result.stdout.splitlines() + return lines[0].split()[0] if result.returncode == 0 and lines else "" + + +def process_workflow(path: Path, write: bool) -> bool: + """Rewrites or checks one workflow file. Returns False if a finding or an unresolvable ref was hit.""" + output = [] + ok = True + for line in path.read_text().splitlines(): + match = USES_RE.match(line) + if match: + prefix, repo, ref = match.group(1), match.group(2), match.group(3) + if not SHA_RE.match(ref): + sha = resolve_ref(repo, ref) + if not sha: + print(f"Unable to resolve {repo}@{ref}", file=sys.stderr) + return False + if write: + output.append(f"{prefix}{repo}@{sha} # {ref}") + else: + print(f"Mutable action reference: {repo}@{ref}", file=sys.stderr) + ok = False + continue + output.append(line) + + if write: + path.write_text("".join(f"{line}\n" for line in output)) + return ok + + +def main() -> int: + mode = "check" + arg = sys.argv[1] if len(sys.argv) > 1 else "--check" + if arg == "--write": + mode = "write" + elif arg in ("--help", "-h"): + print(f"Usage: {Path(sys.argv[0]).name} [--check|--write]") + return 0 + elif arg != "--check": + print(f"Usage: {Path(sys.argv[0]).name} [--check|--write]", file=sys.stderr) + return 2 + + workflows_dir = Path.cwd() / ".github" / "workflows" + workflows = sorted(p for p in workflows_dir.glob("*") if p.suffix in (".yml", ".yaml") and p.is_file()) + + status = 0 + for workflow in workflows: + if not process_workflow(workflow, write=mode == "write"): + status = 1 + + if mode == "check" and status == 0: + print("All Git dependencies and workflow actions are pinned.") + + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pin-git-references.sh b/scripts/pin-git-references.sh new file mode 100755 index 0000000..fce89c5 --- /dev/null +++ b/scripts/pin-git-references.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode=check +root_dir="$(pwd)" +case "${1:---check}" in + --check) ;; + --write) mode=write ;; + --help|-h) echo "Usage: ${0##*/} [--check|--write]"; exit 0 ;; + *) echo "Usage: ${0##*/} [--check|--write]" >&2; exit 2 ;; +esac + +resolve_ref() { local url="https://github.com/$1.git"; if [[ "$2" == HEAD ]]; then git ls-remote "$url" HEAD; else git ls-remote "$url" "refs/tags/$2" "refs/heads/$2"; fi | awk 'NR==1{print $1}'; } +release_tag() { git ls-remote --tags "https://github.com/$1.git" | awk -v s="$2" '$1==s{sub("refs/tags/","",$2);sub(/\^\{\}$/,"",$2);print $2;exit}'; } + +# args: file regex ref-group kind("action"|"dependency") +process() { + local file="$1" re="$2" g="$3" kind="$4" out line findings=0 + out=$(mktemp) + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ $re ]]; then + local prefix="${BASH_REMATCH[1]}" repo="${BASH_REMATCH[2]}" ref="${BASH_REMATCH[$g]:-HEAD}" + if [[ ! "$ref" =~ ^[0-9a-f]{40}$ ]]; then + local sha; sha=$(resolve_ref "$repo" "$ref") + if [[ -z "$sha" ]]; then + echo "Unable to resolve $repo@$ref" >&2; rm -f "$out"; return 1 + elif [[ "$mode" == write ]]; then + if [[ "$kind" == action ]]; then + printf '%s%s@%s # %s\n' "$prefix" "$repo" "$sha" "$ref" >>"$out" + else + local tag; tag=$(release_tag "$repo" "$sha") + printf '%s@%s # %s\n' "$prefix" "$sha" "${tag:-No matching upstream release tag}" >>"$out" + fi + else + echo "Mutable $kind reference: $repo@$ref" >&2; findings=1 + fi + continue + fi + fi + printf '%s\n' "$line" >>"$out" + done <"$file" + [[ "$mode" == write ]] && mv "$out" "$file" || rm -f "$out" + return "$findings" +} + +status=0 +while IFS= read -r wf; do + process "$wf" '^([[:space:]]*-?[[:space:]]*uses:[[:space:]]*)([^@[:space:]#]+)@([^[:space:]#]+)([[:space:]]*(#.*)?)$' 3 action || status=1 +done < <(find "$root_dir/.github/workflows" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort) + +[[ "$mode" == check && "$status" -eq 0 ]] && echo "All Git dependencies and workflow actions are pinned." +exit "$status" \ No newline at end of file