Created
March 31, 2026 14:36
-
-
Save VeckoTheGecko/734b10cc78742b354c01055660ddf625 to your computer and use it in GitHub Desktop.
Check a projects GitHub Actions SHA pins match the versions
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 | |
| """In a project root, run to verify that pinned action SHAs match their version tag comments. | |
| When GHA's are pinned like the following | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| it will parse the line and make sure the comment `v7.0.0` does indeed point to the SHA. | |
| """ | |
| import re | |
| import sys | |
| import urllib.request | |
| import json | |
| from pathlib import Path | |
| # Matches: owner/repo/optional-subpath@SHA # vX.Y.Z | |
| USES_RE = re.compile( | |
| r'uses:\s+([\w.-]+/[\w./.-]+)@([0-9a-f]{40})\s+#\s*(v[\w.]+)' | |
| ) | |
| def gh_api(path: str) -> dict: | |
| url = f"https://api.github.com/{path.lstrip('/')}" | |
| req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) | |
| with urllib.request.urlopen(req) as resp: | |
| return json.loads(resp.read()) | |
| def resolve_tag_sha(repo: str, tag: str) -> str: | |
| """Return the commit SHA for a tag, dereferencing annotated tags.""" | |
| # repo may be "owner/repo/subpath" — the actual GH repo is just the first two parts | |
| gh_repo = "/".join(repo.split("/")[:2]) | |
| data = gh_api(f"repos/{gh_repo}/git/ref/tags/{tag}") | |
| obj = data["object"] | |
| if obj["type"] == "tag": | |
| # Annotated tag — dereference to commit | |
| tag_data = gh_api(f"repos/{gh_repo}/git/tags/{obj['sha']}") | |
| return tag_data["object"]["sha"] | |
| return obj["sha"] | |
| def check_file(path: Path) -> list[str]: | |
| errors = [] | |
| for lineno, line in enumerate(path.read_text().splitlines(), 1): | |
| m = USES_RE.search(line) | |
| if not m: | |
| continue | |
| action, pinned_sha, tag = m.groups() | |
| try: | |
| expected_sha = resolve_tag_sha(action, tag) | |
| except Exception as e: | |
| errors.append(f"{path}:{lineno}: could not resolve {action}@{tag}: {e}") | |
| continue | |
| if pinned_sha != expected_sha: | |
| errors.append( | |
| f"{path}:{lineno}: MISMATCH for {action}@{tag}\n" | |
| f" file has: {pinned_sha}\n" | |
| f" tag points to: {expected_sha}" | |
| ) | |
| else: | |
| print(f" OK {action}@{tag} ({pinned_sha[:12]}…)") | |
| return errors | |
| def main(): | |
| workflows = list(Path(".github/workflows").glob("*.yml")) | |
| if not workflows: | |
| print("No workflow files found.", file=sys.stderr) | |
| sys.exit(1) | |
| all_errors = [] | |
| for wf in sorted(workflows): | |
| print(f"\nChecking {wf}…") | |
| all_errors.extend(check_file(wf)) | |
| if all_errors: | |
| print("\nFAILURES:") | |
| for e in all_errors: | |
| print(e) | |
| sys.exit(1) | |
| else: | |
| print("\nAll pinned SHAs verified.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment