From f521f146c94e50c57d52e0276aee6924c71d0c06 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Wed, 24 Dec 2025 16:11:48 +0000 Subject: [PATCH] Add workflow diagnostics tooling --- README.md | 22 +++++ requirements.txt | 1 + tools/workflow_doctor.py | 208 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 requirements.txt create mode 100644 tools/workflow_doctor.py diff --git a/README.md b/README.md index a47fc4e..a0694e0 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,25 @@ Built in Unreal Engine 5, inspired by classic community mods. ## Documentation See [DESIGN_BRIEF.md](DESIGN_BRIEF.md) for comprehensive game design documentation. + +## GitHub Actions diagnostics +This repository ships with a lightweight "workflow doctor" to help ChatGPT/Codex-style agents quickly understand and triage GitHub Actions issues without running the workflows themselves. + +### Setup +1. Install the dependency: + ```bash + pip install -r requirements.txt + ``` + +### Usage +- Inspect the repository from its root: + ```bash + python tools/workflow_doctor.py + ``` + +- Only print actionable diagnostics (skip verbose summaries): + ```bash + python tools/workflow_doctor.py --quiet + ``` + +When no workflow files are present, the tool reports that the `.github/workflows` directory is empty so that agents don't waste time chasing nonexistent CI definitions. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..19e91bc --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyyaml>=6.0.1 diff --git a/tools/workflow_doctor.py b/tools/workflow_doctor.py new file mode 100644 index 0000000..5adfe0d --- /dev/null +++ b/tools/workflow_doctor.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Workflow Doctor: GitHub Actions diagnostics for agents. + +This tool inspects workflows under .github/workflows and surfaces common +configuration problems that frequently trip up automation agents. +""" +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +import re +import sys +from typing import Any, Dict, Iterable, List, Tuple + +import yaml + +WORKFLOWS_ROOT = Path(".github/workflows") + + +@dataclass +class Diagnostic: + workflow: str + level: str + message: str + + +PIN_PATTERNS: Tuple[Tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"@[0-9a-f]{40}$"), "commit SHA"), + (re.compile(r"@v?\d+(\.\d+){0,2}$"), "version tag"), +) +FLOATING_REFS = {"main", "master", "latest", "HEAD"} + + +def load_workflow(path: Path) -> Dict[str, Any]: + try: + with path.open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + except yaml.YAMLError as exc: # type: ignore[attr-defined] + raise ValueError(f"{path}: YAML parsing failed ({exc})") from exc + + +def find_workflow_files() -> List[Path]: + if not WORKFLOWS_ROOT.exists(): + return [] + return sorted( + [path for path in WORKFLOWS_ROOT.iterdir() if path.suffix in {".yml", ".yaml"}] + ) + + +def list_triggers(on_field: Any) -> List[str]: + if on_field is None: + return [] + if isinstance(on_field, list): + return [str(item) for item in on_field] + if isinstance(on_field, dict): + return [str(key) for key in on_field.keys()] + return [str(on_field)] + + +def list_jobs(data: Dict[str, Any]) -> List[str]: + jobs = data.get("jobs", {}) + if isinstance(jobs, dict): + return list(jobs.keys()) + return [] + + +def walk_uses(value: Any) -> Iterable[str]: + if isinstance(value, dict): + for key, child in value.items(): + if key == "uses" and isinstance(child, str): + yield child + else: + yield from walk_uses(child) + elif isinstance(value, list): + for item in value: + yield from walk_uses(item) + + +def classify_reference(uses_value: str) -> str: + if "@" not in uses_value: + return "unversioned" + reference = uses_value.split("@", 1)[1] + if reference in FLOATING_REFS: + return "floating" + for pattern, label in PIN_PATTERNS: + if pattern.search(uses_value): + return label + if re.match(r"^[0-9a-f]{7,}$", reference): + return "short SHA" + return "custom tag" + + +def diagnose_permissions(data: Dict[str, Any], workflow_name: str) -> List[Diagnostic]: + diagnostics: List[Diagnostic] = [] + if "permissions" not in data: + diagnostics.append( + Diagnostic( + workflow=workflow_name, + level="warning", + message=( + "Missing top-level 'permissions'. Explicit permissions reduce " + "unexpected write access for the GITHUB_TOKEN." + ), + ) + ) + return diagnostics + + +def diagnose_uses(data: Dict[str, Any], workflow_name: str) -> Tuple[List[Diagnostic], List[str]]: + diagnostics: List[Diagnostic] = [] + uses_entries = list(walk_uses(data)) + for uses_value in uses_entries: + classification = classify_reference(uses_value) + if classification in {"unversioned", "floating"}: + diagnostics.append( + Diagnostic( + workflow=workflow_name, + level="warning", + message=( + f"'{uses_value}' is {classification}; pin actions to a tag or SHA " + "to prevent supply-chain surprises." + ), + ) + ) + return diagnostics, uses_entries + + +def summarize_workflow(path: Path) -> Tuple[str, Dict[str, Any], List[Diagnostic], List[str]]: + data = load_workflow(path) + name = data.get("name", path.stem) + diagnostics: List[Diagnostic] = [] + diagnostics.extend(diagnose_permissions(data, name)) + uses_diags, uses_entries = diagnose_uses(data, name) + diagnostics.extend(uses_diags) + return name, data, diagnostics, uses_entries + + +def render_summary(name: str, path: Path, data: Dict[str, Any], uses_entries: List[str]) -> str: + triggers = list_triggers(data.get("on")) + jobs = list_jobs(data) + lines = [f"=== Workflow: {name} ({path}) ==="] + lines.append(f"Triggers: {', '.join(triggers) if triggers else 'None detected'}") + lines.append(f"Jobs: {', '.join(jobs) if jobs else 'None detected'}") + if uses_entries: + lines.append("Action references:") + for uses_value in sorted(set(uses_entries)): + lines.append(f" - {uses_value} ({classify_reference(uses_value)})") + else: + lines.append("Action references: none found") + return "\n".join(lines) + + +def main(argv: List[str]) -> int: + parser = argparse.ArgumentParser( + description=( + "Inspect GitHub workflow files for common issues (missing permissions, " + "unversioned actions, and trigger/job summaries)." + ) + ) + parser.add_argument( + "--workdir", + type=Path, + default=Path.cwd(), + help="Repository root containing .github/workflows (defaults to cwd).", + ) + parser.add_argument( + "--quiet", + action="store_true", + help="Only emit diagnostics; skip workflow summaries.", + ) + args = parser.parse_args(argv) + + root = args.workdir + global WORKFLOWS_ROOT + WORKFLOWS_ROOT = root / ".github" / "workflows" + + workflow_files = find_workflow_files() + if not workflow_files: + print(f"No workflow files found under {WORKFLOWS_ROOT}.") + return 0 + + collected_diags: List[Diagnostic] = [] + for workflow_path in workflow_files: + try: + name, data, diagnostics, uses_entries = summarize_workflow(workflow_path) + except ValueError as exc: + print(f"ERROR: {exc}") + continue + + if not args.quiet: + print(render_summary(name, workflow_path.relative_to(root), data, uses_entries)) + print() + collected_diags.extend(diagnostics) + + if collected_diags: + print("Diagnostics:") + for diag in collected_diags: + print(f"- [{diag.level.upper()}] {diag.workflow}: {diag.message}") + else: + print("No issues detected. Workflows look healthy!") + + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))