Skip to content

github-scan-actions

Scan GitHub Actions workflows across repos and summarize uses: references.

Metadata

  • Author: ropean, Claude Sonnet (Anthropic)
  • Version: 1.0.0

Code

python
#!/usr/bin/env python3
"""
@title github-scan-actions
@description Scan GitHub Actions workflows across repos and summarize `uses:` references.
@author ropean, Claude Sonnet (Anthropic)
@version 1.0.0

@example
    python github-scan-actions.py [git_dir] [-o FILE] [-l]

Arguments:
    git_dir         Root directory containing your git repos (default: platform-dependent)
    -o / --output   Save JSON result to this file
    -l / --list     Output a flat sorted list of unique action names only (no counts/repos)
"""

import json
import re
import sys
import argparse
from collections import defaultdict
from pathlib import Path

SCRIPTS_DIR = Path(__file__).resolve().parent

# ════════════════════════════════════════════════════════════
# Platform defaults
# ════════════════════════════════════════════════════════════

PLATFORM_DEFAULTS = {
    "win32":  r"D:\Git",
    "darwin": "~/Git",
    "linux":  "~/Git",  # includes WSL
}


def default_git_dir() -> Path:
    raw = PLATFORM_DEFAULTS.get(sys.platform, "~/Git")
    return Path(raw).expanduser()


# ════════════════════════════════════════════════════════════
# Core logic
# ════════════════════════════════════════════════════════════

USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s+(.+)$", re.MULTILINE)


def scan_workflows(git_dir: Path) -> dict:
    """
    Scan all .github/workflows/*.yml and *.yaml files under git_dir.

    Returns a dict shaped like:
    {
      "action@version": {"count": int, "repos": ["repo/workflow.yml", ...]},
      ...
    }
    """
    summary: dict[str, dict] = defaultdict(lambda: {"count": 0, "repos": []})
    workflow_files = sorted({
        *git_dir.glob("*/.github/workflows/*.yml"),
        *git_dir.glob("*/.github/workflows/*.yaml"),
    })

    if not workflow_files:
        print(f"[warn] No workflow files found under: {git_dir}", file=sys.stderr)
        return {}

    for wf_path in workflow_files:
        try:
            label = str(wf_path.relative_to(git_dir))
        except ValueError:
            label = str(wf_path)

        try:
            text = wf_path.read_text(encoding="utf-8", errors="replace")
        except OSError as e:
            print(f"[warn] Cannot read {wf_path}: {e}", file=sys.stderr)
            continue

        for match in USES_RE.finditer(text):
            action = match.group(1).strip().split("#")[0].strip()
            if not action:
                continue
            summary[action]["count"] += 1
            summary[action]["repos"].append(label)

    return dict(sorted(summary.items()))


def build_report(git_dir: Path, summary: dict) -> dict:
    from datetime import datetime, timezone
    return {
        "scanned_at": datetime.now(timezone.utc).isoformat(),
        "git_dir": str(git_dir),
        "total_unique_actions": len(summary),
        "total_uses": sum(v["count"] for v in summary.values()),
        "actions": summary,
    }


# ════════════════════════════════════════════════════════════
# CLI
# ════════════════════════════════════════════════════════════

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Scan GitHub Actions `uses:` across all repos in a directory."
    )
    parser.add_argument(
        "git_dir",
        nargs="?",
        default=None,
        help=f"Root directory of your git repos (default: {default_git_dir()})",
    )
    parser.add_argument(
        "--output", "-o",
        metavar="FILE",
        default=None,
        help=f"Save JSON output to this file (default when omitted: print to stdout only)",
    )
    parser.add_argument(
        "--list", "-l",
        action="store_true",
        help="Output a flat sorted list of unique action names only (no counts/repos)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()

    git_dir = Path(args.git_dir).expanduser() if args.git_dir else default_git_dir()
    if not git_dir.is_dir():
        print(f"[error] Directory not found: {git_dir}", file=sys.stderr)
        sys.exit(1)

    print(f"Scanning: {git_dir} …", file=sys.stderr)

    summary = scan_workflows(git_dir)

    if args.list:
        json_text = json.dumps(sorted(summary.keys()), indent=2, ensure_ascii=False)
        print(f"[info] {len(summary)} unique actions.", file=sys.stderr)
    else:
        report = build_report(git_dir, summary)
        json_text = json.dumps(report, indent=2, ensure_ascii=False)
        workflow_count = len({r for v in summary.values() for r in v["repos"]})
        print(
            f"[info] {report['total_unique_actions']} unique actions, "
            f"{report['total_uses']} total uses across {workflow_count} workflow files.",
            file=sys.stderr,
        )

    print(json_text)

    if args.output:
        out_path = Path(args.output)
        out_path.write_text(json_text, encoding="utf-8")
        print(f"[saved] {out_path}", file=sys.stderr)


if __name__ == "__main__":
    main()

File Information

  • Filename: github-scan-actions.py
  • Category: python
  • Language: PYTHON

View on GitHub