Skip to content

Instantly share code, notes, and snippets.

@flodolo
Created March 25, 2026 14:14
Show Gist options
  • Select an option

  • Save flodolo/d16a959869f2cb54fe72cb7eb828e451 to your computer and use it in GitHub Desktop.

Select an option

Save flodolo/d16a959869f2cb54fe72cb7eb828e451 to your computer and use it in GitHub Desktop.
Pin actions
#!/usr/bin/env python3
"""
Pin GitHub Actions in workflow files to the latest release commit SHA.
For each `uses: owner/repo@<ref>` line found in .github/workflows/*.yml/yaml,
the script fetches the latest GitHub release for that action and replaces
the ref with the full commit SHA, leaving a comment with the release tag:
uses: actions/checkout@v4 -> uses: actions/checkout@<sha> # v4.2.2
Usage:
python pin_actions.py /path/to/repo [--dry-run] [--token TOKEN]
Requirements:
pip install requests
"""
import argparse
import os
import re
import sys
from functools import lru_cache
from pathlib import Path
try:
import requests
except ImportError:
sys.exit("Error: 'requests' library is required. Run: pip install requests")
# ---------------------------------------------------------------------------
# Regex to match a `uses:` directive inside a workflow YAML line.
# Captures (uses_keyword_with_spaces, action_ref, current_ref).
# The optional trailing comment is consumed but not captured.
# ---------------------------------------------------------------------------
_USES_RE = re.compile(
r"(uses:\s+)"
r"([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_./-]+)?)"
r"@"
r"([a-f0-9]{40}|[^\s#\n]+)"
r"(?:\s*#[^\n]*)?"
)
# Module-level headers dict populated in main() before any API calls
_headers: dict[str, str] = {}
# ---------------------------------------------------------------------------
# GitHub API helpers
# ---------------------------------------------------------------------------
def _get(url: str) -> requests.Response:
try:
return requests.get(url, headers=_headers, timeout=15)
except requests.exceptions.RequestException as exc:
r = requests.Response()
r.status_code = 503
r._content = b""
print(f" [warn] network error for {url}: {exc}", file=sys.stderr)
return r
@lru_cache(maxsize=None)
def _latest_release(owner: str, repo: str) -> tuple[str | None, str | None]:
"""Return (tag_name, commit_sha) for the latest release of owner/repo."""
# Step 1: resolve the tag name
r = _get(f"https://api.github.com/repos/{owner}/{repo}/releases/latest")
if r.status_code == 200:
tag = r.json()["tag_name"]
elif r.status_code in (404, 410):
# Repo has no formal releases — fall back to the most recent tag
r2 = _get(f"https://api.github.com/repos/{owner}/{repo}/tags")
tags = r2.json() if r2.status_code == 200 else []
if not tags:
print(
f" [skip] {owner}/{repo}: no releases or tags found",
file=sys.stderr,
)
return None, None
tag = tags[0]["name"]
else:
print(f" [skip] {owner}/{repo}: HTTP {r.status_code}", file=sys.stderr)
return None, None
# Step 2: resolve tag → commit SHA (handles lightweight & annotated tags)
sha = _resolve_tag(owner, repo, tag)
if not sha:
print(
f" [skip] {owner}/{repo}: could not resolve SHA for tag {tag!r}",
file=sys.stderr,
)
return None, None
return tag, sha
def _resolve_tag(owner: str, repo: str, tag: str) -> str | None:
"""Dereference a tag name to its underlying commit SHA."""
r = _get(f"https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag}")
if r.status_code != 200:
return None
obj = r.json()["object"]
if obj["type"] == "commit":
return obj["sha"]
if obj["type"] == "tag":
# Annotated tag: the ref points to a tag object, not a commit directly
r2 = _get(obj["url"])
if r2.status_code == 200:
return r2.json()["object"]["sha"]
return None
# ---------------------------------------------------------------------------
# File processing
# ---------------------------------------------------------------------------
def _process_file(path: Path, dry_run: bool) -> int:
"""Update one workflow file in place. Returns the number of lines changed."""
text = path.read_text()
new_lines: list[str] = []
changes = 0
header_printed = False
for line in text.splitlines(keepends=True):
# Skip YAML comment lines
if line.lstrip().startswith("#"):
new_lines.append(line)
continue
m = _USES_RE.search(line)
if not m:
new_lines.append(line)
continue
action_ref: str = m.group(2)
# Skip local actions (./...) and Docker actions
if action_ref.startswith(".") or action_ref.startswith("docker://"):
new_lines.append(line)
continue
# Parse owner/repo from the first two path components
parts = action_ref.split("/")
owner, repo = parts[0], parts[1]
tag, sha = _latest_release(owner, repo)
if not tag or not sha:
new_lines.append(line)
continue
old_ref: str = m.group(3)
# Skip if already pinned to the correct SHA with the right tag comment
if old_ref == sha and f"# {tag}" in line:
new_lines.append(line)
continue
# Rebuild the line: keep everything up to the start of the old ref,
# append the new SHA + tag comment, then restore the line ending.
eol = "\n" if line.endswith("\n") else ""
new_line = line.rstrip("\n")[: m.start(3)] + f"{sha} # {tag}" + eol
if not header_printed:
print(f" {path.name}")
header_printed = True
print(f" {action_ref}: {old_ref!r} -> {sha[:8]}... ({tag})")
new_lines.append(new_line)
changes += 1
if changes and not dry_run:
path.write_text("".join(new_lines))
return changes
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Pin GitHub Actions to the latest release SHA. "
"Reads GITHUB_TOKEN (or GH_TOKEN) from the environment, "
"or use --token."
)
)
parser.add_argument("repo_path", help="Path to the repository root")
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would change without writing files",
)
parser.add_argument(
"--token",
metavar="TOKEN",
help="GitHub personal access token (overrides env vars)",
)
args = parser.parse_args()
# Build auth headers
token = args.token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
_headers["Accept"] = "application/vnd.github.v3+json"
if token:
_headers["Authorization"] = f"Bearer {token}"
else:
print(
"Warning: no GitHub token found. Unauthenticated requests are "
"limited to 60/hr.\nSet GITHUB_TOKEN or pass --token.",
file=sys.stderr,
)
repo = Path(args.repo_path).resolve()
if not repo.is_dir():
sys.exit(f"Error: {repo} is not a directory")
workflows_dir = repo / ".github" / "workflows"
if not workflows_dir.is_dir():
sys.exit(f"Error: no .github/workflows directory found in {repo}")
workflow_files = sorted(workflows_dir.glob("*.yml")) + sorted(
workflows_dir.glob("*.yaml")
)
if not workflow_files:
sys.exit("No .yml/.yaml workflow files found.")
label = " [dry-run]" if args.dry_run else ""
print(f"Found {len(workflow_files)} workflow file(s) in {workflows_dir}{label}\n")
print("--- Pinning action versions ---")
total = sum(_process_file(wf, args.dry_run) for wf in workflow_files)
noun = "change" if total == 1 else "changes"
suffix = " (dry-run, no files modified)" if args.dry_run else ""
print(f"\nTotal: {total} {noun}{suffix}.")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment