github-fetch-versions
Fetch the latest major version for each GitHub Action via the GitHub API.
Metadata
- Author: ropean, Claude Sonnet (Anthropic)
- Version: 1.0.0
- Dependencies: GITHUB_TOKEN environment variable (optional, raises rate limit)
Code
python
#!/usr/bin/env python3
"""
@title github-fetch-versions
@description Fetch the latest major version for each GitHub Action via the GitHub API.
@author ropean, Claude Sonnet (Anthropic)
@version 1.0.0
Reads a JSON list of action refs (e.g. from github-scan-actions.py --list),
queries the GitHub API concurrently, and writes a name→version JSON map.
@example
python github-fetch-versions.py [-i FILE] [-o FILE] [--threads N]
Arguments:
-i / --input Input JSON file (default: github-scan-actions.json in script dir)
-o / --output Output JSON file (default: github-actions-versions.json in script dir)
--threads N Concurrent API requests (default: 8)
@requires GITHUB_TOKEN environment variable (optional, raises rate limit)
"""
import json
import os
import re
import sys
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from urllib import request as urllib_request, error as urllib_error
SCRIPTS_DIR = Path(__file__).resolve().parent
DEFAULT_INPUT = SCRIPTS_DIR / "github-scan-actions.json"
DEFAULT_OUTPUT = SCRIPTS_DIR / "github-actions-versions.json"
API_BASE = "https://api.github.com"
# ════════════════════════════════════════════════════════════
# Filtering
# ════════════════════════════════════════════════════════════
def should_skip(ref: str) -> bool:
"""Skip local actions and reusable workflow references."""
if ref.startswith("./"):
return True
if "/.github/workflows/" in ref:
return True
return False
def base_name(ref: str) -> str:
"""Strip @version suffix and sub-path → owner/repo.
"github/codeql-action/init@v3" → "github/codeql-action"
"actions/checkout@v4" → "actions/checkout"
"""
path = ref.split("@")[0]
parts = path.split("/")
return "/".join(parts[:2])
# ════════════════════════════════════════════════════════════
# Version extraction
# ════════════════════════════════════════════════════════════
def normalize_version(tag: str) -> str:
"""
"v4.3.5" → "v4.3.5"
"4.3.5" → "v4.3.5"
"v4" → "v4"
"stable" → "stable" (non-semver kept as-is)
"""
m = re.match(r"^v?(\d+.*)$", tag)
if m:
return f"v{m.group(1)}"
return tag
# ════════════════════════════════════════════════════════════
# GitHub API
# ════════════════════════════════════════════════════════════
def build_headers(token: str | None) -> dict:
h = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "github-fetch-versions",
}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _get_json(url: str, headers: dict) -> tuple[object, str | None]:
"""Fetch URL → (parsed_json, error_str). Returns (None, err) on failure."""
try:
req = urllib_request.Request(url, headers=headers)
with urllib_request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read()), None
except urllib_error.HTTPError as e:
return None, f"HTTP {e.code}"
except Exception as e:
return None, str(e)
def fetch_latest_version(
owner_repo: str, headers: dict
) -> tuple[str, str | None, str | None]:
"""Returns (owner_repo, version_or_None, error_or_None)."""
owner, repo = owner_repo.split("/", 1)
# 1. Try /releases/latest
data, err = _get_json(f"{API_BASE}/repos/{owner}/{repo}/releases/latest", headers)
if data is not None:
tag = data.get("tag_name", "") # type: ignore[union-attr]
if tag:
return owner_repo, normalize_version(tag), None
# 2. Fallback: /tags (first result is most recent)
if err and not err.startswith("HTTP 404"):
return owner_repo, None, err
data, err = _get_json(f"{API_BASE}/repos/{owner}/{repo}/tags", headers)
if data and isinstance(data, list) and data:
tag = data[0].get("name", "")
if tag:
return owner_repo, normalize_version(tag), None
return owner_repo, None, err or "no releases or tags found"
# ════════════════════════════════════════════════════════════
# CLI
# ════════════════════════════════════════════════════════════
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Fetch latest major versions for GitHub Actions from the GitHub API."
)
p.add_argument(
"-i", "--input",
default=str(DEFAULT_INPUT),
metavar="FILE",
help=f"Input JSON list of action refs (default: {DEFAULT_INPUT.name})",
)
p.add_argument(
"-o", "--output",
default=str(DEFAULT_OUTPUT),
metavar="FILE",
help=f"Output JSON name→version map (default: {DEFAULT_OUTPUT.name})",
)
p.add_argument(
"--threads",
type=int,
default=8,
metavar="N",
help="Number of concurrent API requests (default: 8)",
)
return p.parse_args()
def check_token() -> str | None:
token = os.environ.get("GITHUB_TOKEN")
if token:
print("[auth] Using GITHUB_TOKEN from environment.", file=sys.stderr)
return token
print(
"[warn] GITHUB_TOKEN not set. Unauthenticated requests are limited to "
"60/hour and may fail on large lists.",
file=sys.stderr,
)
try:
ans = input("Continue without token? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
ans = ""
if ans in ("n", "no"):
print("Aborted.", file=sys.stderr)
sys.exit(0)
return None
def main() -> None:
args = parse_args()
# ── Load input ───────────────────────────────────────────
in_path = Path(args.input)
if not in_path.exists():
print(f"[error] Input file not found: {in_path}", file=sys.stderr)
print(
" Run: python github-scan-actions.py --list -o github-scan-actions.json",
file=sys.stderr,
)
sys.exit(1)
refs: list[str] = json.loads(in_path.read_text(encoding="utf-8"))
if not isinstance(refs, list):
print("[error] Input file must be a JSON array.", file=sys.stderr)
sys.exit(1)
# ── Deduplicate base names, skipping irrelevant refs ─────
names: list[str] = sorted(
{base_name(r) for r in refs if not should_skip(r)}
)
skipped = [r for r in refs if should_skip(r)]
print(f"[info] {len(refs)} refs → {len(names)} unique actions "
f"({len(skipped)} skipped).", file=sys.stderr)
if skipped:
for s in skipped:
print(f" skip: {s}", file=sys.stderr)
# ── Auth ─────────────────────────────────────────────────
token = check_token()
headers = build_headers(token)
# ── Fetch concurrently ───────────────────────────────────
results: dict[str, str] = {}
errors: dict[str, str] = {}
print(f"Fetching {len(names)} actions with {args.threads} threads …", file=sys.stderr)
with ThreadPoolExecutor(max_workers=args.threads) as pool:
futures = {pool.submit(fetch_latest_version, n, headers): n for n in names}
for fut in as_completed(futures):
name, version, err = fut.result()
if version:
results[name] = version
print(f" ✓ {name:<50} {version}", file=sys.stderr)
else:
errors[name] = err or "unknown error"
print(f" ✗ {name:<50} {err}", file=sys.stderr)
if errors:
print(f"\n[warn] {len(errors)} action(s) could not be resolved:", file=sys.stderr)
for name, err in errors.items():
print(f" {name}: {err}", file=sys.stderr)
# ── Write output ─────────────────────────────────────────
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
json_text = json.dumps(dict(sorted(results.items())), indent=2, ensure_ascii=False)
out_path.write_text(json_text, encoding="utf-8")
print(f"\n[saved] {out_path} ({len(results)} entries)", file=sys.stderr)
print(json_text)
if __name__ == "__main__":
main()File Information
- Filename:
github-fetch-versions.py - Category: python
- Language: PYTHON