Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions scripts/pin-git-references.py
Original file line number Diff line number Diff line change
@@ -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())
52 changes: 52 additions & 0 deletions scripts/pin-git-references.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading