From ba75cf300abe5f3bd5fe642359d30018e161ea88 Mon Sep 17 00:00:00 2001 From: Anay Dhawan Date: Fri, 31 Jul 2026 18:23:41 +0530 Subject: [PATCH] feat: scheduled soft-fail registry health check Weekly cron that pings all 39 showpiece refs plus the 5 library sites and reports what no longer resolves. Never runs on push or pull_request: validate.yml stays the only PR gate, so a contributor is never blocked because someone else's site is down. On failures it opens a registry-health issue, or comments on the existing one rather than filing a new issue every week. When everything recovers it comments and closes. 429 is classified separately from a dead ref, since upstream throttling is a different problem from a component that no longer exists. Closes #3 --- .github/workflows/health-check.yml | 63 +++++++++++++++ .gitignore | 4 + scripts/health-check.py | 122 +++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 .github/workflows/health-check.yml create mode 100644 scripts/health-check.py diff --git a/.github/workflows/health-check.yml b/.github/workflows/health-check.yml new file mode 100644 index 0000000..ba64c61 --- /dev/null +++ b/.github/workflows/health-check.yml @@ -0,0 +1,63 @@ +name: registry health + +# Deliberately NOT wired to push or pull_request. validate.yml is the only PR gate; +# this job must never block a contributor because someone else's site is down. +on: + schedule: + # Mondays 06:00 UTC. + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + health: + name: ping every registry ref + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Probe refs and library sites + id: probe + run: python3 scripts/health-check.py + + - name: Open, update or close the health issue + env: + GH_TOKEN: ${{ github.token }} + FAILURES: ${{ steps.probe.outputs.failures }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + gh label create registry-health \ + --description "Automated registry rot reports" \ + --color D93F0B 2>/dev/null || true + + existing=$(gh issue list --label registry-health --state open \ + --limit 1 --json number --jq '.[0].number // empty') + + { + cat health-report.md + echo "" + echo "[Workflow run]($RUN_URL)" + } > body.md + + if [ "${FAILURES:-0}" -gt 0 ]; then + if [ -n "$existing" ]; then + gh issue comment "$existing" --body-file body.md + echo "Updated existing issue #$existing" + else + gh issue create \ + --title "Registry health: $FAILURES target(s) failing" \ + --label registry-health \ + --body-file body.md + fi + elif [ -n "$existing" ]; then + gh issue comment "$existing" \ + --body "Every showpiece ref and library site resolved on the latest run. Closing. [Workflow run]($RUN_URL)" + gh issue close "$existing" + echo "Closed recovered issue #$existing" + else + echo "All green, nothing to report." + fi diff --git a/.gitignore b/.gitignore index 98dc5d3..1c3dd37 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ pnpm-debug.log* *.local scratch/ tmp/ + +# Generated by scripts/health-check.py, never committed +health-report.md +body.md diff --git a/scripts/health-check.py b/scripts/health-check.py new file mode 100644 index 0000000..0981eb2 --- /dev/null +++ b/scripts/health-check.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Soft-fail registry health check. + +Pings every showpiece ref and every code-library site, then writes a markdown +report. It never decides what to do about failures; the workflow does that. Run +it locally with no arguments to see the current state of the registry: + + python3 scripts/health-check.py + +Exit code is 0 unless the check itself could not run. A dead upstream registry +is data, not a script failure, which is what makes this safe to schedule. +""" + +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +from collections import Counter + +TIMEOUT = 25 +DELAY = 0.4 # be a polite guest on other people's registries +UA = "components-skill-health-check/1.0 (+https://github.com/AnayDhawan/Components)" +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# `npx shadcn@latest add ""` and `fetch page via webfetch/playwright` +URL_RE = re.compile(r'https?://[^\s"\'<>)]+') + + +def extract_url(ref): + m = URL_RE.search(ref or "") + return m.group(0) if m else None + + +def probe(url): + """Return (status, detail). status is one of ok / rate-limited / dead / error.""" + req = urllib.request.Request(url, headers={"User-Agent": UA}) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + return "ok", f"HTTP {r.status}" + except urllib.error.HTTPError as e: + # 429 is upstream throttling, not a missing component. Worth reporting, + # but it is a different problem from a ref that no longer exists. + if e.code == 429: + return "rate-limited", "HTTP 429" + return "dead", f"HTTP {e.code}" + except urllib.error.URLError as e: + return "dead", f"{type(e.reason).__name__}: {e.reason}" + except Exception as e: # noqa: BLE001 - a check that crashes is a broken check + return "error", f"{type(e).__name__}: {e}" + + +def main(): + with open(os.path.join(ROOT, "components.json"), encoding="utf-8") as f: + data = json.load(f) + + targets = [] + for entry in data.get("showpiece", []): + url = extract_url(entry.get("ref")) + if url: + targets.append(("showpiece", f"{entry['library']}/{entry['name']}", url)) + else: + targets.append(("showpiece", f"{entry['library']}/{entry['name']}", None)) + for lib in data.get("code_libraries", []): + if lib.get("site"): + targets.append(("library site", lib["name"], lib["site"])) + + results = [] + for kind, name, url in targets: + if url is None: + results.append((kind, name, "-", "error", "no URL found in ref")) + continue + status, detail = probe(url) + results.append((kind, name, url, status, detail)) + print(f"{status:<13} {name:<40} {detail}", flush=True) + time.sleep(DELAY) + + counts = Counter(r[3] for r in results) + bad = [r for r in results if r[3] != "ok"] + + lines = [ + "# Registry health check", + "", + f"Checked **{len(results)}** targets: " + f"{counts.get('ok', 0)} ok, {counts.get('rate-limited', 0)} rate-limited, " + f"{counts.get('dead', 0)} dead, {counts.get('error', 0)} error.", + "", + ] + if bad: + lines += ["| Kind | Entry | Status | Detail | URL |", "|---|---|---|---|---|"] + for kind, name, url, status, detail in bad: + lines.append(f"| {kind} | `{name}` | **{status}** | {detail} | {url} |") + lines += [ + "", + "`rate-limited` may be transient; `dead` means the ref no longer resolves " + "and any user asking for that showpiece gets a failure.", + ] + else: + lines.append("Every showpiece ref and library site resolved.") + + report = "\n".join(lines) + "\n" + with open(os.path.join(ROOT, "health-report.md"), "w", encoding="utf-8") as f: + f.write(report) + + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as f: + f.write(report) + + out = os.environ.get("GITHUB_OUTPUT") + if out: + with open(out, "a", encoding="utf-8") as f: + f.write(f"failures={len(bad)}\n") + + print(f"\n{len(bad)} failing target(s).") + return 0 + + +if __name__ == "__main__": + sys.exit(main())