Created
March 25, 2026 14:14
-
-
Save flodolo/d16a959869f2cb54fe72cb7eb828e451 to your computer and use it in GitHub Desktop.
Pin actions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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