#!/usr/bin/env python3 """ Generate docs/ci/workflow-status.md — a live dashboard of every GitHub Actions workflow in this repo, grouped by role, with disabled / manual-only / stale items surfaced at the top. Usage: pdm run python scripts/generate_workflow_status.py # Generate pdm run python scripts/generate_workflow_status.py --output PATH # Custom path pdm run python scripts/generate_workflow_status.py --check-structure # CI mode: exit 1 # if any workflow # file is missing # a row in the # current dashboard pdm run python scripts/generate_workflow_status.py --verbose # Log API calls Why two modes: Live data (last_run timestamps, badge status) cannot be byte-equality --check'd against committed output — every regeneration changes the timestamps. Instead, --check-structure does a fast, deterministic check that every .github/workflows/*.yml file has a row in the dashboard. Live regeneration is on demand. GitHub API: Uses the `gh` CLI. Requires `gh auth login` with `repo, workflow` scopes. Owner/repo is read at runtime from `gh repo view`. API cost: ~1 call per workflow (62) + caller-resolution (~30 extra) ≈ 100 calls per full run. Well within the 5000/hr authenticated rate limit. """ import argparse import json import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional import yaml REPO_ROOT = Path(__file__).resolve().parent.parent WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" DEFAULT_OUTPUT = REPO_ROOT / "docs" / "ci" / "workflow-status.md" BEGIN_MARKER = "" END_MARKER = "" NEW_FILE_DAYS = 14 STALE_MULTIPLIER = 2 # last_success > N * cron_period_days → stale RATE_LIMIT_WARN = 200 RATE_LIMIT_ABORT = 50 GATE_ROOTS = {"release.yml", "release-gate.yml", "ci-gate.yml"} # Job names recognized as the release-gate "summary"-shaped roll-up that # nests other jobs. We list candidates by the named role they fill so we # don't depend on the exact YAML key. RELEASE_GATE_FILE = "release-gate.yml" RELEASE_FILE = "release.yml" CI_GATE_FILE = "ci-gate.yml" # ============================================================================ # gh CLI helpers # ============================================================================ def run_gh(args: list[str], verbose: bool = False) -> str: """Run `gh ` and return stdout. Raises on nonzero exit.""" if verbose: print(f" gh {' '.join(args)}", file=sys.stderr) result = subprocess.run( ["gh", *args], capture_output=True, text=True, check=False ) if result.returncode != 0: raise RuntimeError( f"gh {' '.join(args)} failed (exit {result.returncode}):\n" f"{result.stderr.strip()}" ) return result.stdout def gh_json(args: list[str], verbose: bool = False) -> Any: """Run `gh` returning a single JSON value (object/array). Use `gh_json_stream` for `--jq` streamed output.""" out = run_gh(args, verbose=verbose) return json.loads(out) if out.strip() else None def gh_json_stream(args: list[str], verbose: bool = False) -> list[Any]: """Run `gh api ... --jq` whose output is a stream of JSON objects (one per line, NDJSON). Returns a list.""" out = run_gh(args, verbose=verbose).strip() if not out: return [] return [json.loads(line) for line in out.splitlines() if line.strip()] def preflight(verbose: bool = False) -> tuple[str, str]: """Verify gh auth and rate limits; return (owner, repo).""" # Auth check. auth = subprocess.run( ["gh", "auth", "status"], capture_output=True, text=True, check=False ) if auth.returncode != 0: print( "FAIL: `gh` is not authenticated. Run `gh auth login` and retry.", file=sys.stderr, ) sys.exit(2) # Rate limit check. rl = gh_json( ["api", "rate_limit", "--jq", ".resources.core"], verbose=verbose ) remaining = rl["remaining"] limit = rl["limit"] if remaining < RATE_LIMIT_ABORT: print( f"FAIL: gh API rate limit too low: {remaining}/{limit}. " f"Wait until the reset window before regenerating.", file=sys.stderr, ) sys.exit(2) if remaining < RATE_LIMIT_WARN: print( f"WARN: gh API rate limit low: {remaining}/{limit}. " f"Continuing but consider waiting for reset.", file=sys.stderr, ) # Repo slug. repo = gh_json(["repo", "view", "--json", "owner,name"], verbose=verbose) return repo["owner"]["login"], repo["name"] # ============================================================================ # Workflow YAML parsing # ============================================================================ USES_LINE_RE = re.compile( r"""^(?P\s*) (?P\#\s*)? uses:\s*(?P\.\/\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml)\s*$ """, re.VERBOSE, ) JOB_KEY_RE = re.compile(r"^ (?:#\s*)?(?P[A-Za-z0-9_-]+):\s*$") def parse_workflow(path: Path) -> dict[str, Any]: """Extract the structured info we need from one workflow file. Returns: { "file": "release-gate.yml", "path": Path(...), "name": "Release Gate", "on": {...}, # raw dict from the `on:` block "uses_calls": [ # parsed line-by-line so we keep { # comment status (ground truth) AND "job_key": "nuclei-scan", # job key context "callee": "nuclei.yml", "commented": True, "line": 177, }, ... ], } """ text = path.read_text(encoding="utf-8") # Use yaml.safe_load on the file but with FullLoader behavior: the # `on:` key gets coerced to True (boolean) by safe_load when YAML 1.1 # legacy boolean parsing kicks in. The simpler workaround is to load # then look for both 'on' and True. data = yaml.safe_load(text) if data is None: raise ValueError(f"{path.name}: empty YAML") on_block = data.get("on", data.get(True, {})) name = data.get("name", path.stem) uses_calls = _parse_uses_lines(text) return { "file": path.name, "path": path, "name": name, "on": on_block if isinstance(on_block, dict) else {on_block: None}, "raw_text": text, "uses_calls": uses_calls, } def _parse_uses_lines(text: str) -> list[dict[str, Any]]: """Walk lines once to capture (job_key, callee, commented?, line_no). Job context: track the most recent top-level `:` under the `jobs:` mapping. Lines inside a step's `uses:` (indented further than a job key) get the most recent job key as context. """ calls: list[dict[str, Any]] = [] in_jobs = False current_job: Optional[str] = None for i, line in enumerate(text.splitlines(), start=1): # Track entering/leaving the top-level `jobs:` mapping. if re.match(r"^jobs:\s*$", line): in_jobs = True current_job = None continue if in_jobs: if line and not line.startswith(" ") and not line.startswith("#"): # New top-level key; out of `jobs:` block. in_jobs = False current_job = None continue m = JOB_KEY_RE.match(line) if m: current_job = m.group("key") m = USES_LINE_RE.match(line) if m: target = m.group("target") callee = target.rsplit("/", 1)[-1] calls.append( { "job_key": current_job or "", "callee": callee, "commented": bool(m.group("comment")), "line": i, } ) return calls # ============================================================================ # Call graph # ============================================================================ def build_call_graph( workflows: dict[str, dict[str, Any]], ) -> tuple[dict[str, list[dict[str, Any]]], dict[str, set[str]]]: """Return (callers_of, transitive_root_callers). callers_of[file] = list of {caller, job_key, commented, line} dicts — every direct caller (including commented-out ones, which we use to detect "disabled"). transitive_root_callers[file] = set of root-caller filenames; subset of GATE_ROOTS, plus possibly the file itself if it has no callers. """ callers_of: dict[str, list[dict[str, Any]]] = {f: [] for f in workflows} for caller_file, wf in workflows.items(): for call in wf["uses_calls"]: callee = call["callee"] if callee in callers_of: callers_of[callee].append( { "caller": caller_file, "job_key": call["job_key"], "commented": call["commented"], "line": call["line"], } ) # BFS upward to find which roots ultimately invoke this workflow. transitive: dict[str, set[str]] = {} for f in workflows: roots: set[str] = set() seen: set[str] = set() stack = [f] while stack: cur = stack.pop() if cur in seen: continue seen.add(cur) active_callers = [c for c in callers_of[cur] if not c["commented"]] if not active_callers: if cur in GATE_ROOTS: roots.add(cur) continue for c in active_callers: if c["caller"] in GATE_ROOTS: roots.add(c["caller"]) stack.append(c["caller"]) transitive[f] = roots return callers_of, transitive # ============================================================================ # GitHub run history # ============================================================================ def fetch_workflow_meta( owner: str, repo: str, file: str, verbose: bool = False ) -> Optional[dict[str, Any]]: """Return {id, state, last_run, recent_runs}. Returns None if the workflow isn't registered with GitHub (e.g. very new file not yet picked up). """ try: meta = gh_json( [ "api", f"repos/{owner}/{repo}/actions/workflows/{file}", ], verbose=verbose, ) except RuntimeError as e: if "404" in str(e) or "Not Found" in str(e): return None raise runs_list = gh_json_stream( [ "api", f"repos/{owner}/{repo}/actions/workflows/{file}/runs?per_page=5", "--jq", ".workflow_runs[] | {created_at, conclusion}", ], verbose=verbose, ) last = runs_list[0] if runs_list else None return { "id": meta["id"], "state": meta.get("state", "active"), "last_run": last["created_at"] if last else None, "recent_runs": runs_list, } def fetch_last_gated_run( owner: str, repo: str, caller_file: str, job_keys: set[str], verbose: bool = False, ) -> Optional[dict[str, Any]]: """Find the most recent run of caller_file that included any of `job_keys` as a job, and return that job's status.""" runs_list = gh_json_stream( [ "api", f"repos/{owner}/{repo}/actions/workflows/{caller_file}/runs?per_page=5", "--jq", ".workflow_runs[] | {created_at, event, conclusion, status, id}", ], verbose=verbose, ) latest_match: Optional[dict[str, Any]] = None latest_success: Optional[dict[str, Any]] = None for run in runs_list: run_id = run["id"] try: jobs = gh_json_stream( [ "api", f"repos/{owner}/{repo}/actions/runs/{run_id}/jobs" "?per_page=100", "--jq", ".jobs[] | {name, conclusion, status, completed_at}", ], verbose=verbose, ) except RuntimeError: continue # Reusable workflows show as ` / ` in the # caller run's jobs list. Match prefix against any of our keys. for job in jobs: jname = job["name"] head = jname.split(" / ", 1)[0] if head not in job_keys: continue entry = { "caller": caller_file, "caller_run_id": run_id, "job_name": jname, "conclusion": job["conclusion"], "status": job["status"], "completed_at": job["completed_at"] or run["created_at"], "run_created": run["created_at"], } if latest_match is None: latest_match = entry if latest_success is None and job["conclusion"] == "success": latest_success = entry if latest_match and latest_success: break if latest_match and latest_success: break if latest_match is None: return None # Stash the most-recent-success entry alongside the activity entry # so the caller can use whichever it needs without a second API # round-trip. `latest_success` is `None` if none of the inspected # caller runs included a successful invocation of this workflow. latest_match["latest_success"] = latest_success return latest_match # ============================================================================ # Classification # ============================================================================ def file_added_at(path: Path) -> Optional[str]: """ISO date of the commit that introduced this file, or None.""" try: out = subprocess.run( [ "git", "log", "--diff-filter=A", "--format=%aI", "--", str(path), ], capture_output=True, text=True, check=False, cwd=REPO_ROOT, ) if out.returncode != 0: return None lines = [ ln.strip() for ln in out.stdout.strip().splitlines() if ln.strip() ] return lines[-1] if lines else None except Exception: return None def cron_cadence_days(cron: str) -> Optional[float]: """Best-effort: convert a cron string to its rough cadence in days. Recognises: `* * * * *` → 1/1440 day (1 min); we cap at 1 hour minimum `M H * * *` → 1 day `M H * * D` → 7 days `M H D * *` → 30 days `M H D M *` → 365 days """ parts = cron.strip().split() if len(parts) != 5: return None minute, hour, dom, month, dow = parts if month != "*": return 365.0 if dom != "*": return 30.0 if dow != "*": return 7.0 if hour != "*": return 1.0 if minute != "*": return 1.0 / 24 return 1.0 / 1440 def get_schedules(on_block: dict) -> list[str]: """Extract cron strings from an on: block.""" sched = on_block.get("schedule") if not sched: return [] out = [] if isinstance(sched, list): for entry in sched: if isinstance(entry, dict) and "cron" in entry: out.append(entry["cron"]) return out def trigger_summary(on_block: dict) -> str: """Compact human-readable trigger description.""" if not on_block: return "—" parts: list[str] = [] keys = list(on_block.keys()) for k in keys: if k == "schedule": crons = get_schedules(on_block) if crons: parts.append(f"schedule({', '.join(crons)})") elif k == "pull_request": parts.append("PR") elif k == "pull_request_target": parts.append("PR-target") elif k == "push": v = on_block[k] if isinstance(v, dict) and "branches" in v: parts.append(f"push:{','.join(v['branches'])}") else: parts.append("push") elif k == "workflow_call": parts.append("workflow_call") elif k == "workflow_dispatch": parts.append("manual") elif k == "repository_dispatch": parts.append("repo_dispatch") else: parts.append(str(k)) return ", ".join(parts) def primary_role( workflow_file: str, on_block: dict, callers: list[dict[str, Any]], transitive_roots: set[str], ) -> str: """Bucket every workflow into exactly one display group. Order matters: workflow can satisfy multiple, but lands in the first matching group. """ active_callers = [c for c in callers if not c["commented"]] # Disabled supersedes everything else. if callers and not active_callers: return "disabled" # Manual-only by design. only_dispatch = set(on_block.keys()) - {True} == { "workflow_dispatch" } or list(on_block.keys()) == ["workflow_dispatch"] if only_dispatch and not active_callers: return "manual" # Gate buckets. if ( RELEASE_GATE_FILE in transitive_roots and workflow_file != RELEASE_GATE_FILE ): # The release-gate.yml schedule is daily. return "gate-daily" if ( RELEASE_FILE in transitive_roots and RELEASE_GATE_FILE not in transitive_roots and workflow_file not in {RELEASE_FILE, RELEASE_GATE_FILE} ): return "gate-release" if ( CI_GATE_FILE in transitive_roots and RELEASE_GATE_FILE not in transitive_roots and workflow_file not in {CI_GATE_FILE, RELEASE_FILE, RELEASE_GATE_FILE} ): return "gate-release" # Direct trigger buckets. has_schedule = "schedule" in on_block has_pr = "pull_request" in on_block or "pull_request_target" in on_block has_push = "push" in on_block has_repo_dispatch = "repository_dispatch" in on_block if has_repo_dispatch and not has_pr and not has_push and not has_schedule: return "repo-dispatch" if has_schedule: return "schedule" if has_pr or has_push: return "pr-push" return "other" def classify_staleness( workflow: dict[str, Any], callers: list[dict[str, Any]], last_success_iso: Optional[str], file_added_iso: Optional[str], now: datetime, ) -> Optional[str]: """Return 'disabled' / 'manual-only' / 'stale' / None. `last_success_iso` is the most recent successful run timestamp from any source (direct or gated), already computed by the caller across the recent_runs list — checking only the most-recent-run conclusion misses the case where last week failed but the week before passed. """ on_block = workflow["on"] state = workflow.get("state", "active") active_callers = [c for c in callers if not c["commented"]] # 1. Disabled: caller commented OR GitHub UI state non-active. # Treat "missing" as new (not yet registered with GitHub) — handled # by the new-file check below instead of mis-flagging as disabled. if callers and not active_callers: return "disabled" if state not in ("active", "missing"): return "disabled" # 2. Manual-only: workflow_dispatch only AND no caller. only_dispatch = list(on_block.keys()) == ["workflow_dispatch"] if only_dispatch and not active_callers: return "manual-only" # 3. New: skip stale check. if file_added_iso: added = datetime.fromisoformat(file_added_iso) if (now - added).days < NEW_FILE_DAYS: return None # 4. Stale: scheduled trigger AND no successful run within # STALE_MULTIPLIER * cadence (or 60d, whichever larger). crons = get_schedules(on_block) if not crons: return None cadences = [c for c in (cron_cadence_days(x) for x in crons) if c] if not cadences: return None threshold_days = max(60.0, STALE_MULTIPLIER * min(cadences)) if last_success_iso is None: return "stale" last_success = datetime.fromisoformat( last_success_iso.replace("Z", "+00:00") ) if (now - last_success).days > threshold_days: return "stale" return None # ============================================================================ # Markdown rendering # ============================================================================ GROUP_ORDER = [ ("disabled", "⚠ Disabled workflows"), ("stale", "⚠ Stale (scheduled but no recent successful run)"), ("manual", "ℹ Manual-only by design"), ( "gate-daily", "Release-blocking gates — daily (release-gate cron 02:00 UTC)", ), ("gate-release", "Release gates — release-time only"), ("schedule", "Scheduled (own cron)"), ("pr-push", "PR / push checks"), ("repo-dispatch", "Repository-dispatch publishers"), ("other", "Other"), ] # Coarse calendar-day buckets for "Last activity"-type columns. Goal: # regenerations only produce a diff when a workflow drifts between # buckets — exact timestamps would change every run and drown the signal. # Boundaries are inclusive on the high side. The smallest bucket is # 30 days so daily/weekly healthy workflows never wobble across # bucket boundaries between regenerations. ACTIVITY_BUCKETS = [ (30, "last 30 days"), (90, "1-3 months ago"), (180, "3-6 months ago"), ] def relative_bucket(iso: Optional[str], now: datetime) -> str: """Return a coarse bucket label, or 'never'. Uses calendar-day delta (no time-of-day jitter) so two regenerations on the same date produce identical labels.""" if not iso: return "never" dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) days = (now.date() - dt.date()).days for threshold, label in ACTIVITY_BUCKETS: if days <= threshold: return label return "long ago" def badge_md( owner: str, repo: str, file: str, event_filter: Optional[str] = None ) -> str: """`[![badge](svg)](runs)` — live status, links to runs page. `event_filter` (e.g. "schedule") appends `?event=...` to the badge URL, which makes GitHub render the status of the most recent run *for that event* rather than the most recent run regardless of trigger. Useful for workflows that fire on multiple events (e.g. PR + cron) where we want the badge to reflect the cron health specifically. Verified effective by SHA-comparing badge bodies for workflows with multi-event run history. """ badge = ( f"https://github.com/{owner}/{repo}/actions/workflows/{file}/badge.svg" ) runs = f"https://github.com/{owner}/{repo}/actions/workflows/{file}" if event_filter: badge = f"{badge}?event={event_filter}" runs = f"{runs}?query=event%3A{event_filter}" return f"[![status]({badge})]({runs})" def render( workflows: list[dict[str, Any]], owner: str, repo: str, now: datetime, ) -> str: """Render the generated section. Caller wraps with markers.""" lines: list[str] = [] by_group: dict[str, list[dict[str, Any]]] = {k: [] for k, _ in GROUP_ORDER} for wf in workflows: by_group.setdefault(wf["primary_role"], []).append(wf) # Aggregated health banner. Counts only change when a workflow shifts # between {disabled, stale, manual, active}, so this line is as # diff-stable as the per-row buckets. total = len(workflows) n_disabled = len(by_group.get("disabled", [])) n_stale = len(by_group.get("stale", [])) n_manual = len(by_group.get("manual", [])) n_active = total - n_disabled - n_stale - n_manual parts = [f"**{total} workflows:**"] if n_disabled: parts.append(f"{n_disabled} disabled") if n_stale: parts.append(f"{n_stale} stale") if n_manual: parts.append(f"{n_manual} manual-only") parts.append(f"{n_active} active") lines.append(" ".join(parts[:1]) + " " + " · ".join(parts[1:])) lines.append("") for key, heading in GROUP_ORDER: rows = by_group.get(key, []) lines.append(f"## {heading}") lines.append("") if not rows: lines.append("_None._") lines.append("") continue if key == "disabled": lines.append("| Workflow | Disabled where | Last direct run |") lines.append("|---|---|---|") for wf in sorted(rows, key=lambda w: w["file"]): where = ( "; ".join( f"`{c['caller']}:{c['line']}` (commented)" for c in wf["callers_of"] if c["commented"] ) or f"GitHub UI state = `{wf.get('state', '?')}`" ) lines.append( f"| `{wf['file']}` | {where} | " f"{relative_bucket(wf['last_direct_run_iso'], now)} |" ) lines.append("") continue if key == "manual": lines.append("| Workflow | Last manual run | Trigger |") lines.append("|---|---|---|") for wf in sorted(rows, key=lambda w: w["file"]): lines.append( f"| `{wf['file']}` | " f"{relative_bucket(wf['last_direct_run_iso'], now)} | " f"{trigger_summary(wf['on'])} |" ) lines.append("") continue if key == "stale": lines.append("| Workflow | Cron | Last successful run |") lines.append("|---|---|---|") for wf in sorted(rows, key=lambda w: w["file"]): crons = ", ".join(get_schedules(wf["on"])) or "—" last_success = wf.get("last_success_iso") lines.append( f"| `{wf['file']}` | `{crons}` | " f"{relative_bucket(last_success, now)} |" ) lines.append("") continue # Generic active groups. # For the "schedule" group (own cron, often combined with PR # triggers like gitleaks/fuzz/codeql), filter the badge to the # scheduled event so the rendered status reflects cron health, # not whichever PR ran last. event_filter = "schedule" if key == "schedule" else None lines.append("| Workflow | Last activity | Trigger | Live badge |") lines.append("|---|---|---|---|") for wf in sorted(rows, key=lambda w: w["file"]): primary_iso = _pick_activity(wf, key) lines.append( f"| `{wf['file']}` | " f"{relative_bucket(primary_iso, now)} | " f"{trigger_summary(wf['on'])} | " f"{badge_md(owner, repo, wf['file'], event_filter)} |" ) lines.append("") return "\n".join(lines) def _pick_activity(wf: dict[str, Any], group: str) -> Optional[str]: """Choose which run timestamp to display per group.""" if group in ("gate-daily", "gate-release"): gated = wf.get("last_gated") if gated: return gated.get("completed_at") or gated.get("run_created") return wf["last_direct_run_iso"] # ============================================================================ # File assembly with markers # ============================================================================ PAGE_TEMPLATE = """\ # Workflow Status > **Live status of every GitHub Actions workflow in this repo.** > Auto-generated by [`scripts/generate_workflow_status.py`](../../scripts/generate_workflow_status.py). > Do not edit between the generated markers — regenerate with > `pdm run python scripts/generate_workflow_status.py`. Anything outside > the markers is preserved on regeneration. ## How to read this page - **Live badges** (right column on active gates) re-render on every page view and reflect the current head-of-default-branch status from GitHub. Click one to land on that workflow's runs page. The badge is the source of truth for current status — there is intentionally no static status column, because that would flip every regeneration as the most-recent run cycles through `success → skipped → in_progress`. - **Last activity** uses coarse calendar buckets — `last 30 days`, `1-3 months ago`, `3-6 months ago`, `long ago`, `never`. Exact dates would change every regeneration; buckets only change when a workflow drifts, which is the signal worth seeing in version-bump diffs. - **Disabled** = a caller has the `uses:` line commented out, or the workflow is disabled in the GitHub UI. **Stale** = scheduled trigger but no successful run within 2× its cron cadence (and ≥60 days). The three top sections are the action items. - Reusable workflows (those triggered only by `workflow_call:`) show their **gated** run — the most recent run of their parent (release.yml, release-gate.yml, ci-gate.yml) that included them — not their own empty direct-run history. {begin_marker} {generated} {end_marker} _Regenerate with `pdm run python scripts/generate_workflow_status.py`._ """ def assemble(generated: str, now: datetime) -> str: # `now` accepted for API symmetry; no timestamp is rendered into # the file. Git history is authoritative for "when" — embedding a # date here would cause a single-line diff on every regeneration # even when nothing else drifted. del now return PAGE_TEMPLATE.format( begin_marker=BEGIN_MARKER, generated=generated.strip(), end_marker=END_MARKER, ) def merge_with_existing(new_full: str, existing_path: Path) -> str: """Preserve everything outside the marker pair. Inside the markers (including the timestamp line) is fully owned by the generator.""" if not existing_path.exists(): return new_full existing = existing_path.read_text(encoding="utf-8") if BEGIN_MARKER not in existing or END_MARKER not in existing: return new_full if existing.index(BEGIN_MARKER) > existing.index(END_MARKER): # Markers out of order (mid-merge-conflict, manual editing # mistake, etc.) — bail to a clean overwrite rather than try # to splice and produce interleaved garbage. return new_full new_inner = _between(new_full, BEGIN_MARKER, END_MARKER) head = existing.split(BEGIN_MARKER, 1)[0] tail = existing.split(END_MARKER, 1)[1] return f"{head}{BEGIN_MARKER}{new_inner}{END_MARKER}{tail}" def _between(text: str, start: str, end: str) -> str: return text.split(start, 1)[1].split(end, 1)[0] # ============================================================================ # Modes # ============================================================================ def cmd_generate(output: Path, verbose: bool) -> int: print("Preflight: checking gh auth and rate limit…", file=sys.stderr) owner, repo = preflight(verbose=verbose) print(f"Target: {owner}/{repo}", file=sys.stderr) paths = sorted(WORKFLOWS_DIR.glob("*.yml")) if not paths: print(f"No workflows found in {WORKFLOWS_DIR}", file=sys.stderr) return 1 print(f"Discovered {len(paths)} workflows", file=sys.stderr) workflows: dict[str, dict[str, Any]] = {} for p in paths: try: workflows[p.name] = parse_workflow(p) except Exception as e: print(f"WARN: failed to parse {p.name}: {e}", file=sys.stderr) callers_of, transitive_roots = build_call_graph(workflows) now = datetime.now(timezone.utc) # Pre-compute the (caller_file -> {job_keys for each callee}) map. job_keys_for: dict[tuple[str, str], set[str]] = {} for caller_file, wf in workflows.items(): for call in wf["uses_calls"]: if call["commented"]: continue key = (caller_file, call["callee"]) job_keys_for.setdefault(key, set()).add(call["job_key"]) print("Fetching workflow metadata + runs…", file=sys.stderr) for i, (file, wf) in enumerate(workflows.items(), start=1): if verbose: print(f"[{i}/{len(workflows)}] {file}", file=sys.stderr) meta = fetch_workflow_meta(owner, repo, file, verbose=verbose) wf["state"] = meta["state"] if meta else "missing" wf["id"] = meta["id"] if meta else None wf["last_direct_run_iso"] = meta["last_run"] if meta else None wf["callers_of"] = callers_of[file] wf["transitive_roots"] = transitive_roots[file] # Resolve gated activity. wf["last_gated"] = None for caller_file in wf["transitive_roots"]: if caller_file == file: continue keys = job_keys_for.get((caller_file, file), set()) if not keys: # Walk one level deeper: a transitive root reaches `file` # via some intermediate caller — collect any job keys for # `file` from any active caller, then check the root for # those keys. for intermediate in workflows: keys |= job_keys_for.get((intermediate, file), set()) if not keys: continue gated = fetch_last_gated_run( owner, repo, caller_file, keys, verbose=verbose ) if gated: cur = wf["last_gated"] if cur is None or (gated["completed_at"] or "") > ( cur["completed_at"] or "" ): wf["last_gated"] = gated # File-added timestamp for new-file exception. wf["file_added_iso"] = file_added_at(wf["path"]) # Compute "last successful" by walking recent runs (not just the # most recent — a workflow that ran red yesterday and green a # week ago is not stale). last_success = None recent = (meta["recent_runs"] if meta else []) or [] for r in recent: if r.get("conclusion") == "success": last_success = r["created_at"] break # Pull the most-recent-success gated entry (computed alongside # the latest match by `fetch_last_gated_run`). This is needed # because the *latest* gated run is often `in_progress` during # an active release, which would otherwise mask a real success # from the previous release and trip the stale-flag false. gated = wf["last_gated"] or {} gated_success = gated.get("latest_success") or ( gated if gated.get("conclusion") == "success" else None ) if gated_success: cand = gated_success.get("completed_at") or gated_success.get( "run_created" ) if cand and (last_success is None or cand > last_success): last_success = cand wf["last_success_iso"] = last_success # Staleness verdict (None if active). Uses the success timestamp # we just computed, not the most-recent-run conclusion. wf["stale_verdict"] = classify_staleness( wf, callers_of[file], wf["last_success_iso"], wf["file_added_iso"], now, ) # Primary role for grouping. if wf["stale_verdict"] == "disabled": wf["primary_role"] = "disabled" elif wf["stale_verdict"] == "manual-only": wf["primary_role"] = "manual" elif wf["stale_verdict"] == "stale": wf["primary_role"] = "stale" else: wf["primary_role"] = primary_role( file, wf["on"], callers_of[file], transitive_roots[file], ) generated = render(list(workflows.values()), owner, repo, now) full = assemble(generated, now) output.parent.mkdir(parents=True, exist_ok=True) final = merge_with_existing(full, output) output.write_text(final, encoding="utf-8") counts = {k: 0 for k, _ in GROUP_ORDER} for wf in workflows.values(): counts[wf["primary_role"]] = counts.get(wf["primary_role"], 0) + 1 print( f"Wrote {output} ({len(workflows)} workflows: " + ", ".join(f"{n} {k}" for k, n in counts.items() if n) + ")" ) return 0 def cmd_check_structure(output: Path) -> int: """Verify every workflow file has a row in the dashboard.""" if not output.exists(): print( f"FAIL: {output} does not exist. Generate it with " f"`pdm run python scripts/generate_workflow_status.py`.", file=sys.stderr, ) return 1 text = output.read_text(encoding="utf-8") missing = [] for p in sorted(WORKFLOWS_DIR.glob("*.yml")): if f"`{p.name}`" not in text: missing.append(p.name) if missing: print( f"FAIL: {output} is missing rows for {len(missing)} workflow(s):", file=sys.stderr, ) for m in missing: print(f" - {m}", file=sys.stderr) print( "Regenerate with `pdm run python scripts/generate_workflow_status.py`.", file=sys.stderr, ) return 1 print( f"OK: {output} has rows for all {len(list(WORKFLOWS_DIR.glob('*.yml')))} workflows." ) return 0 def main() -> int: parser = argparse.ArgumentParser( description=( "Generate docs/ci/workflow-status.md from .github/workflows/*.yml." ) ) parser.add_argument( "--output", "-o", type=Path, default=DEFAULT_OUTPUT, help=f"Output path (default: {DEFAULT_OUTPUT.relative_to(REPO_ROOT)})", ) parser.add_argument( "--check-structure", action="store_true", help=( "Verify every workflow file has a row in the dashboard. " "Exits 1 if any is missing. No API calls." ), ) parser.add_argument( "--verbose", action="store_true", help="Print one line per gh API call.", ) args = parser.parse_args() if args.check_structure: return cmd_check_structure(args.output) return cmd_generate(args.output, args.verbose) if __name__ == "__main__": sys.exit(main())