Skip to content

github-fix-actions

Batch-fix GitHub Actions workflows across repos using resolved version targets.

Metadata

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

Code

python
#!/usr/bin/env python3
"""
@title github-fix-actions
@description Batch-fix GitHub Actions workflows across repos using resolved version targets.
@author ropean, Claude Sonnet (Anthropic)
@version 1.0.0

Reads version targets from a JSON file (produced by github-fetch-versions.py)
and applies them to every workflow file found under the git root.

@example
    python github-fix-actions.py [git_dir] [-i FILE] [--dry-run] [-o REPORT]

Arguments:
    git_dir         Root directory containing your git repos (default: platform-dependent)
    -i / --input    Versions JSON file  (default: github-actions-versions.json in script dir)
    --dry-run       Preview changes without writing any files
    -o / --output   Save a Markdown report to this file
"""

import json
import os
import re
import sys
import argparse
from pathlib import Path
from datetime import datetime

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

# ════════════════════════════════════════════════════════════
#  USER CONFIG
# ════════════════════════════════════════════════════════════

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

# Top-level permissions to ensure exist in every workflow.
# Set to [] to disable this feature entirely.
REQUIRED_PERMISSIONS = [
    ("deployments", "write"),
    # ("contents", "read"),
]

# ════════════════════════════════════════════════════════════
#  END OF USER CONFIG
# ════════════════════════════════════════════════════════════

_SENSITIVE_KEY = re.compile(
    r"token|secret|password|passwd|pwd|api.?key|auth|credential|private.?key|cert",
    re.IGNORECASE,
)
_KV_LINE    = re.compile(r"^\s+(\w+)\s*:\s*(.+)$")
_EXPRESSION = re.compile(r"^\$\{\{")
_NON_SECRET = re.compile(r"^(true|false|yes|no|null|~|\d+|""|'')$", re.IGNORECASE)


# ── Terminal colors ───────────────────────────────────────

def _supports_color() -> bool:
    if not sys.stdout.isatty():
        return False
    if os.name == "nt":
        return "WT_SESSION" in os.environ or bool(os.environ.get("TERM"))
    return True


if _supports_color():
    _C = {
        "green":  "\033[32m",
        "yellow": "\033[33m",
        "cyan":   "\033[36m",
        "red":    "\033[31m",
        "bold":   "\033[1m",
        "dim":    "\033[2m",
        "reset":  "\033[0m",
    }
else:
    _C = {k: "" for k in ("green", "yellow", "cyan", "red", "bold", "dim", "reset")}


def c(color: str, text: str) -> str:
    return f"{_C[color]}{text}{_C['reset']}"


# ── Path helpers ─────────────────────────────────────────

def resolve_git_dir(arg: str | None) -> Path:
    if arg:
        p = Path(arg).expanduser()
    else:
        key = sys.platform if sys.platform in DEFAULT_GIT_DIRS else "linux"
        p = Path(DEFAULT_GIT_DIRS[key]).expanduser()
    # Skip resolve() for UNC paths (\\server\share) — it corrupts them on Windows
    if not str(p).startswith("\\\\"):
        p = p.resolve()
    if not p.is_dir():
        sys.exit(f"Error: directory not found: {p}")
    return p


def find_workflow_files(git_dir: Path):
    """Yield .yml/.yaml files under <git_dir>/*/.github/workflows/"""
    for repo in sorted(git_dir.iterdir()):
        if not repo.is_dir() or repo.name.startswith("."):
            continue
        wf_dir = repo / ".github" / "workflows"
        if not wf_dir.is_dir():
            continue
        for f in sorted(wf_dir.iterdir()):
            if f.suffix in (".yml", ".yaml"):
                yield f


# ── Version helpers ──────────────────────────────────────

def to_major_version(ver: str) -> str:
    """v6.0.2 → v6 | v3 → v3 | codeql-bundle-v2.25.1 → unchanged"""
    if not ver.startswith("v"):
        return ver
    return "v" + ver[1:].split(".")[0]


# ── Core transformations ─────────────────────────────────

def apply_package_upgrades(
    content: str, upgrades: list[tuple[str, str]]
) -> tuple[str, list[str]]:
    """Pin each action to its target version, regardless of current version."""
    changes: list[str] = []
    for action, target in upgrades:
        pattern = rf"({re.escape(action)}@)(\S+)"
        old_refs = set(re.findall(pattern, content))
        new_content, count = re.subn(pattern, lambda m, t=target: f"{m.group(1)}{t}", content)
        if count:
            for _, old_ver in old_refs:
                if old_ver != target:
                    changes.append(f"{action}@{old_ver}  →  @{target}")
            content = new_content
    return content, changes


def apply_permissions(content: str) -> tuple[str, list[str]]:
    """Ensure every key in REQUIRED_PERMISSIONS exists in the top-level permissions block."""
    if not REQUIRED_PERMISSIONS:
        return content, []

    lines = content.splitlines(keepends=True)
    changes: list[str] = []

    perm_start: int | None = None
    perm_end:   int | None = None
    jobs_line:  int | None = None

    for i, line in enumerate(lines):
        s = line.rstrip()
        if re.match(r"^permissions\s*:", s):
            perm_start = i
        if re.match(r"^jobs\s*:", s):
            jobs_line = i
        if (
            perm_start is not None
            and perm_end is None
            and i > perm_start
            and s
            and s[0] not in (" ", "\t", "#")
        ):
            perm_end = i

    if perm_start is not None and perm_end is None:
        perm_end = len(lines)

    # Skip scalar form: "permissions: read-all"
    if perm_start is not None:
        scalar = re.match(r"^permissions\s*:\s*(\S+)", lines[perm_start].rstrip())
        if scalar and scalar.group(1) not in ("", "{}"):
            return content, []

    if perm_start is None:
        if jobs_line is None:
            return content, []
        block = ["permissions:\n"]
        for key, val in REQUIRED_PERMISSIONS:
            block.append(f"  {key}: {val}\n")
        block.append("\n")
        lines = lines[:jobs_line] + block + lines[jobs_line:]
        desc = ", ".join(f"{k}: {v}" for k, v in REQUIRED_PERMISSIONS)
        changes.append(f"added permissions block  ({desc})")
    else:
        existing: set[str] = set()
        for i in range(perm_start + 1, perm_end):  # type: ignore[arg-type]
            m = re.match(r"^  ([\w-]+)\s*:", lines[i])
            if m:
                existing.add(m.group(1))

        missing = [(k, v) for k, v in REQUIRED_PERMISSIONS if k not in existing]
        if not missing:
            return content, []

        insert_at = perm_start + 1
        for i in range(perm_start + 1, perm_end):  # type: ignore[arg-type]
            if lines[i].strip():
                insert_at = i + 1

        for key, val in reversed(missing):
            lines.insert(insert_at, f"  {key}: {val}\n")

        for key, val in missing:
            changes.append(f"added permission: {key}: {val}")

    return "".join(lines), changes


# ── Secrets check ────────────────────────────────────────

def check_hardcoded_secrets(content: str) -> list[str]:
    """Return 'line N: KEY' for indented key-value pairs that look like hardcoded secrets."""
    findings = []
    for lineno, line in enumerate(content.splitlines(), 1):
        m = _KV_LINE.match(line)
        if not m:
            continue
        key, raw = m.group(1), m.group(2).strip()
        if not _SENSITIVE_KEY.search(key):
            continue
        val = raw.strip('"').strip("'")
        if _EXPRESSION.match(val) or _EXPRESSION.match(raw):
            continue
        if _NON_SECRET.match(val):
            continue
        if len(val) <= 8:
            continue
        findings.append(f"line {lineno}: {key}")
    return findings


# ── File processor ────────────────────────────────────────

def process_file(
    path: Path, upgrades: list[tuple[str, str]], dry_run: bool = False
) -> dict:
    original = path.read_text(encoding="utf-8")
    content  = original

    content, pkg_changes  = apply_package_upgrades(content, upgrades)
    content, perm_changes = apply_permissions(content)
    sec_warnings          = check_hardcoded_secrets(original)

    modified = content != original
    if modified and not dry_run:
        path.write_text(content, encoding="utf-8")

    return {
        "path":         path,
        "pkg_changes":  pkg_changes,
        "perm_changes": perm_changes,
        "sec_warnings": sec_warnings,
        "modified":     modified,
    }


# ── Report ────────────────────────────────────────────────

def save_report(results: list[dict], git_dir: Path, dry_run: bool, out: Path) -> None:
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    modified = [r for r in results if r["modified"]]
    skipped  = [r for r in results if not r["modified"]]

    md: list[str] = [
        "# GitHub Actions Fix Report",
        "",
        f"- **Date**: {ts}",
        f"- **Git dir**: `{git_dir}`",
        f"- **Mode**: {'dry-run (no files written)' if dry_run else 'applied'}",
        f"- **Total**: {len(results)} files scanned, "
        f"{len(modified)} modified, {len(skipped)} skipped",
        "",
        f"## Modified ({len(modified)} files)", "",
    ]
    for r in modified:
        rel = r["path"].relative_to(git_dir)
        md.append(f"### `{rel}`")
        if r["pkg_changes"]:
            md.append("**Package upgrades:**")
            for ch in r["pkg_changes"]:
                md.append(f"- {ch.strip()}")
        if r["perm_changes"]:
            md.append("**Permissions:**")
            for ch in r["perm_changes"]:
                md.append(f"- {ch.strip()}")
        md.append("")

    md += [f"## Skipped / already up to date ({len(skipped)} files)", ""]
    for r in skipped:
        md.append(f"- `{r['path'].relative_to(git_dir)}`")
    md.append("")

    warned = [r for r in results if r["sec_warnings"]]
    md += [f"## Potential hardcoded secrets ({len(warned)} files)", ""]
    if warned:
        for r in warned:
            rel = r["path"].relative_to(git_dir)
            for w in r["sec_warnings"]:
                md.append(f"- `{rel}` — {w}")
    else:
        md.append("None detected.")
    md.append("")

    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text("\n".join(md), encoding="utf-8")
    print(c("green", f"Report saved → {out}"))


# ── Entry point ───────────────────────────────────────────

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Batch-fix GitHub Actions workflows across repos."
    )
    parser.add_argument(
        "git_dir", nargs="?",
        help="Root directory of your git repos (default: platform-specific)",
    )
    parser.add_argument(
        "-i", "--input",
        default=str(SCRIPTS_DIR / "github-actions-versions.json"),
        metavar="FILE",
        help="Versions JSON file (default: github-actions-versions.json in script dir)",
    )
    parser.add_argument(
        "--dry-run", action="store_true",
        help="Preview changes without writing any files",
    )
    parser.add_argument(
        "--exact", action="store_true", default=False,
        help="Pin to the exact version (e.g. v6.0.2); default uses major only (e.g. v6). "
             "Only affects versions starting with 'v'.",
    )
    parser.add_argument(
        "-o", "--output",
        default=None,
        metavar="FILE",
        help="Save a Markdown report to this file",
    )
    return parser.parse_args()


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

    # ── Load versions ─────────────────────────────────────
    versions_path = Path(args.input)
    if not versions_path.exists():
        print(f"[error] Versions file not found: {versions_path}", file=sys.stderr)
        print(
            "  Run: python github-fetch-versions.py",
            file=sys.stderr,
        )
        sys.exit(1)

    versions: dict[str, str] = json.loads(versions_path.read_text(encoding="utf-8"))
    if args.exact:
        upgrades: list[tuple[str, str]] = list(versions.items())
    else:
        upgrades = [(action, to_major_version(ver)) for action, ver in versions.items()]
    print(f"[info] Loaded {len(upgrades)} version targets from {versions_path.name} "
          f"({'exact' if args.exact else 'major only'})",
          file=sys.stderr)

    # ── Setup ─────────────────────────────────────────────
    git_dir = resolve_git_dir(args.git_dir)
    dry_run = args.dry_run

    label = c("yellow", " [DRY RUN]") if dry_run else ""
    print(c("bold", f"\ngithub-fix-actions{label}"))
    print(f"Scanning: {c('cyan', str(git_dir))}\n")

    files = list(find_workflow_files(git_dir))
    if not files:
        sys.exit("No workflow files found.")

    # ── Process ───────────────────────────────────────────
    results: list[dict] = []
    for f in files:
        r = process_file(f, upgrades, dry_run=dry_run)
        results.append(r)
        rel = f.relative_to(git_dir)
        if r["modified"]:
            print(c("green", f"  ✓  {rel}"))
            for ch in r["pkg_changes"] + r["perm_changes"]:
                print(c("dim", f"       {ch.strip()}"))
        else:
            print(c("dim", f"  –  {rel}"))
        if dry_run:
            for w in r["sec_warnings"]:
                print(c("yellow", f"  ⚠  {rel}  ({w})"))

    # ── Summary ───────────────────────────────────────────
    modified = [r for r in results if r["modified"]]
    print(f"\n{'─' * 52}")
    print(f"  Scanned:  {len(results)} files")
    print(c("green",  f"  Modified: {len(modified)} files"))
    print(c("dim",    f"  Skipped:  {len(results) - len(modified)} files"))
    if dry_run:
        warned = [r for r in results if r["sec_warnings"]]
        if warned:
            total = sum(len(r["sec_warnings"]) for r in warned)
            print(c("yellow", f"  Secrets: {total} potential hardcoded value(s) in {len(warned)} file(s)"))
        print(c("yellow", "\n  [DRY RUN] No files were written."))

    if args.output:
        save_report(results, git_dir, dry_run, Path(args.output))


if __name__ == "__main__":
    main()

File Information

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

View on GitHub