Skip to content

github-actions

Interactive menu for the GitHub Actions toolchain (scan, fetch versions, preview, apply).

Metadata

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

Code

python
#!/usr/bin/env python3
"""
@title github-actions
@description Interactive menu for the GitHub Actions toolchain (scan, fetch versions, preview, apply).
@author ropean, Claude Sonnet (Anthropic)
@version 1.0.0

  1  Scan repos        -> github-scan-actions.json
  2  Fetch versions    -> github-actions-versions.json
  3  Preview updates   (dry-run, no writes)
  4  Apply updates     (writes workflow files)
  --
  0  Run all: 1 -> 2 -> 3 -> confirm -> 4

@example
    python github-actions.py [git_dir]
"""

import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path

SCRIPTS_DIR = Path(__file__).resolve().parent
PYTHON = sys.executable

SCAN_JSON = SCRIPTS_DIR / "github-scan-actions.json"
VERSIONS_JSON = SCRIPTS_DIR / "github-actions-versions.json"

SCAN_SCRIPT = SCRIPTS_DIR / "github-scan-actions.py"
FETCH_SCRIPT = SCRIPTS_DIR / "github-fetch-versions.py"
FIX_SCRIPT = SCRIPTS_DIR / "github-fix-actions.py"


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


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


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


def run(cmd: list[str]) -> int:
    """Run a command, streaming output. Returns exit code."""
    print(c("dim", f"\n> {' '.join(str(x) for x in cmd)}\n"))
    result = subprocess.run(cmd)
    return result.returncode


def ask_git_dir(default: str) -> str:
    """Prompt for git root; return default on empty input."""
    try:
        val = input(f"Git root [{default}]: ").strip()
    except (EOFError, KeyboardInterrupt):
        val = ""
    return val if val else default


def confirm(prompt: str = "Proceed?") -> bool:
    try:
        ans = input(f"{prompt} [Y/n] ").strip().lower()
    except (EOFError, KeyboardInterrupt):
        ans = ""
    return ans not in ("n", "no")


def ask_version_mode(default_exact: bool = False) -> bool:
    """Prompt for version pinning mode. Returns True for exact mode."""
    default_choice = "2" if default_exact else "1"
    try:
        print("Version mode:")
        print(f"  1  major  {'(default)' if not default_exact else ''}".rstrip())
        print(f"  2  exact  {'(default)' if default_exact else ''}".rstrip())
        ans = input(f"Choose [1-2] ({default_choice}): ").strip().lower()
    except (EOFError, KeyboardInterrupt):
        ans = ""

    if not ans:
        return default_exact
    if ans in ("1", "m", "major", "major-only"):
        return False
    if ans in ("2", "e", "exact", "full"):
        return True

    fallback = "exact" if default_exact else "major"
    print(c("yellow", f"  Invalid mode: {ans!r}; using {fallback}."))
    return default_exact


def default_git_dir() -> str:
    defaults = {
        "win32": r"D:\Git",
        "darwin": "~/Git",
        "linux": "~/Git",
    }
    return defaults.get(sys.platform, "~/Git")


def file_status(path: Path) -> str:
    if path.exists():
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
            count = len(data) if isinstance(data, (list, dict)) else "?"
            ts = datetime.fromtimestamp(path.stat().st_mtime).strftime("%m-%d %H:%M")
            return c("green", f"{count} entries  [{ts}]")
        except Exception:
            return c("yellow", "exists")
    return c("dim", "not found")


def print_menu(git_dir: str) -> None:
    print()
    print(c("bold", "  GitHub Actions 工具集"))
    print(f"  {'-' * 54}")
    print(f"  git root : {c('cyan', git_dir)}")
    print(f"  scan     : {file_status(SCAN_JSON)}")
    print(f"  versions : {file_status(VERSIONS_JSON)}")
    print(f"  {'-' * 54}")
    print(f"  {c('bold', '1')}  扫描仓库,提取 action 清单    github-scan-actions.json")
    print(f"  {c('bold', '2')}  查询 GitHub 最新版本          github-actions-versions.json")
    print(f"  {c('bold', '3')}  预览更新  {c('yellow', '(dry-run,不写文件)')}")
    print(f"  {c('bold', '4')}  执行更新  {c('green', '(写入 workflow 文件)')}")
    print(f"  {'-' * 54}")
    print(f"  {c('bold', '0')}  串联执行  1  2  预览  确认  写入")
    print(f"  {c('bold', 'q')}  退出")
    print(f"  {'-' * 54}")


def do_scan(git_dir: str) -> int:
    return run([PYTHON, str(SCAN_SCRIPT), "--list", "-o", str(SCAN_JSON), git_dir])


def do_fetch() -> int:
    return run([PYTHON, str(FETCH_SCRIPT), "-i", str(SCAN_JSON), "-o", str(VERSIONS_JSON)])


def do_fix(git_dir: str, dry_run: bool, exact: bool = True) -> int:
    cmd = [PYTHON, str(FIX_SCRIPT), "-i", str(VERSIONS_JSON), git_dir]
    if exact:
        cmd.append("--exact")
    if dry_run:
        cmd.append("--dry-run")
    return run(cmd)


def do_all(git_dir: str) -> None:
    exact = ask_version_mode(default_exact=False)
    mode_label = "exact" if exact else "major"

    print(c("bold", "\n  串联执行"))
    print(c("dim", f"  version mode: {mode_label}"))

    rc = do_scan(git_dir)
    if rc != 0:
        print(c("yellow", "\n[!] 扫描失败,中止。"))
        return

    rc = do_fetch()
    if rc != 0:
        print(c("yellow", "\n[!] 版本查询失败,中止。"))
        return

    print(c("bold", "\n  预览 (dry-run)"))
    do_fix(git_dir, dry_run=True, exact=exact)

    print()
    if not confirm("确认写入所有变更?"):
        print("已取消。")
        return

    do_fix(git_dir, dry_run=False, exact=exact)


def main() -> None:
    git_dir = sys.argv[1] if len(sys.argv) > 1 else default_git_dir()

    print_menu(git_dir)
    try:
        choice = input("  选择 [0-4 / q]: ").strip().lower()
    except (EOFError, KeyboardInterrupt):
        print("\n再见。")
        return

    if choice == "q":
        print("再见。")
    elif choice == "1":
        git_dir = ask_git_dir(git_dir)
        do_scan(git_dir)
    elif choice == "2":
        do_fetch()
    elif choice == "3":
        git_dir = ask_git_dir(git_dir)
        exact = ask_version_mode(default_exact=False)
        do_fix(git_dir, dry_run=True, exact=exact)
    elif choice == "4":
        git_dir = ask_git_dir(git_dir)
        exact = ask_version_mode(default_exact=False)
        if confirm(f"将写入 {git_dir} 下所有 workflow 文件,确认?"):
            do_fix(git_dir, dry_run=False, exact=exact)
        else:
            print("已取消。")
    elif choice == "0":
        git_dir = ask_git_dir(git_dir)
        do_all(git_dir)
    else:
        print(c("yellow", "  无效选项。"))


if __name__ == "__main__":
    main()

File Information

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

View on GitHub