7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
1106 lines
38 KiB
Python
1106 lines
38 KiB
Python
#!/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 = "<!-- BEGIN GENERATED -->"
|
||
END_MARKER = "<!-- END GENERATED -->"
|
||
|
||
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 <args...>` 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<indent>\s*)
|
||
(?P<comment>\#\s*)?
|
||
uses:\s*(?P<target>\.\/\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml)\s*$
|
||
""",
|
||
re.VERBOSE,
|
||
)
|
||
JOB_KEY_RE = re.compile(r"^ (?:#\s*)?(?P<key>[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 `<key>:` 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 `<job_key> / <inner-job-name>` 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:
|
||
"""`[](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"[]({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())
|