Skip to content

Instantly share code, notes, and snippets.

@VeckoTheGecko
Created March 31, 2026 14:36
Show Gist options
  • Select an option

  • Save VeckoTheGecko/734b10cc78742b354c01055660ddf625 to your computer and use it in GitHub Desktop.

Select an option

Save VeckoTheGecko/734b10cc78742b354c01055660ddf625 to your computer and use it in GitHub Desktop.
Check a projects GitHub Actions SHA pins match the versions
#!/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