From 7aa3346c1de673d2228921d027399e62473c0204 Mon Sep 17 00:00:00 2001 From: pluja Date: Sat, 4 Jul 2026 18:00:33 +0300 Subject: [PATCH] ci: add PR lint, monthly health report, and PR template --- .github/PULL_REQUEST_TEMPLATE.md | 16 ++ .github/workflows/health-report.yml | 225 ++++++++++++++++++++++++++++ .github/workflows/pr-lint.yml | 124 +++++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/health-report.yml create mode 100644 .github/workflows/pr-lint.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1f6b025 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ + + +## Summary + + +Adds `` to the `
` section. + +## Checklist + +- [ ] Entry uses the format `- [Name](url) - description.` +- [ ] New section is added to the Table of Contents (Contents) +- [ ] Project has a clear privacy / own-your-data policy +- [ ] Project website loads no third-party trackers (only analytics from the Analytics section allowed) +- [ ] Source or repo link is provided +- [ ] License is stated +- [ ] Project is maintained, or flagged with 💀 if not diff --git a/.github/workflows/health-report.yml b/.github/workflows/health-report.yml new file mode 100644 index 0000000..6757b2a --- /dev/null +++ b/.github/workflows/health-report.yml @@ -0,0 +1,225 @@ +name: Health report + +# Monthly maintenance scan of README.md. Finds dead links and repos that look +# abandoned (archived, or no push in over 18 months), then opens one issue with +# the findings. Opens nothing when everything is healthy. + +on: + schedule: + - cron: '0 6 1 * *' # 06:00 UTC on the 1st of each month + workflow_dispatch: # allow manual runs for testing + +permissions: + contents: read + issues: write + +jobs: + health-report: + name: Scan links and repositories + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run lychee on README.md + uses: lycheeverse/lychee-action@v2 + with: + # Whole file, JSON output for the parser below. + # fail:false keeps the job green so we can build the report either way. + args: >- + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --retry-wait-time 2 + --timeout 20 + --exclude-mail + --accept '200..=299,403,429,999' + README.md + format: json + output: lychee/out.json + jobSummary: false + fail: false + failIfEmpty: false + + - name: Build report (dead links + stale repos) + id: scan + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python3 - <<'PY' + import datetime as dt + import json, os, re, urllib.error, urllib.request + + NOW = dt.datetime.now(dt.timezone.utc) + STALE_DAYS = 18 * 30 # ~18 months + TOKEN = os.environ.get("GITHUB_TOKEN", "") + + # ---- 1. Dead links, from the lychee JSON report ------------------------ + dead = [] + try: + with open("lychee/out.json") as fh: + data = json.load(fh) + emap = data.get("error_map") or data.get("fail_map") or {} + for entries in emap.values(): + for item in entries: + url = item.get("url", "") + status = item.get("status") or {} + text = status.get("text") or status.get("details") or "error" + dead.append((url, text)) + except FileNotFoundError: + print("::warning::lychee/out.json not found; skipping dead-link section") + except Exception as exc: + print(f"::warning::Could not parse lychee output ({exc})") + # De-duplicate while keeping order. + seen = set() + dead = [d for d in dead if not (d[0] in seen or seen.add(d[0]))] + + # ---- 2. Stale / archived repositories --------------------------------- + with open("README.md", encoding="utf-8") as fh: + readme = fh.read() + + # Capture host/owner/repo; any subpath (/tree/..., /blob/...) is ignored. + repo_re = re.compile( + r"https?://(?:www\.)?(github\.com|codeberg\.org)/" + r"([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)" + ) + # First path segments that are never a real owner. + RESERVED = { + "sponsors", "orgs", "topics", "about", "features", "marketplace", + "settings", "explore", "collections", "apps", "notifications", + "login", "join", "pricing", "security", "site", "contact", + "readme", "search", "new", "watching", "stars", + } + + repos = {} # (host, owner_lower, repo_lower) -> (host, owner, repo) + for host, owner, repo in repo_re.findall(readme): + if owner.lower() in RESERVED: + continue + repo = repo.rstrip(".") + if repo.endswith(".git"): + repo = repo[:-4] + if not repo: + continue + key = (host, owner.lower(), repo.lower()) + repos.setdefault(key, (host, owner, repo)) + + def fetch(url, token=None): + req = urllib.request.Request(url) + req.add_header("Accept", "application/json") + req.add_header("User-Agent", "awesome-privacy-health-report") + if token: + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=20) as resp: + return json.load(resp) + + def parse_ts(value): + if not value: + return None + try: + return dt.datetime.strptime( + value.replace("Z", "+0000"), "%Y-%m-%dT%H:%M:%S%z" + ) + except ValueError: + return None + + stale = [] # (slug, reason) + for host, owner, repo in sorted(repos.values()): + slug = f"{host}/{owner}/{repo}" + try: + if host == "github.com": + info = fetch( + f"https://api.github.com/repos/{owner}/{repo}", TOKEN + ) + last = parse_ts(info.get("pushed_at")) + else: # codeberg.org (Forgejo/Gitea API, public, no token) + info = fetch( + f"https://codeberg.org/api/v1/repos/{owner}/{repo}" + ) + last = parse_ts(info.get("updated_at")) + except urllib.error.HTTPError as exc: + if exc.code in (404, 410): + stale.append((slug, f"repository not found (HTTP {exc.code})")) + else: + print(f"::warning::{slug}: HTTP {exc.code}, skipped") + continue + except Exception as exc: + print(f"::warning::{slug}: {exc}, skipped") + continue + + reasons = [] + if info.get("archived"): + reasons.append("archived") + if last is not None: + age = (NOW - last).days + if age > STALE_DAYS: + months = age // 30 + reasons.append( + f"no push since {last.date()} (~{months} months)" + ) + if reasons: + stale.append((slug, "; ".join(reasons))) + + # ---- 3. Assemble the report ------------------------------------------- + today = NOW.date().isoformat() + lines = [f"# Health report {today}", ""] + lines.append("Automated monthly scan of `README.md`.") + lines.append("") + + lines.append(f"## Dead links ({len(dead)})") + if dead: + lines.append("") + for url, text in dead: + lines.append(f"- {url} - {text}") + else: + lines.append("") + lines.append("None found.") + lines.append("") + + lines.append(f"## Stale or archived repositories ({len(stale)})") + lines.append("") + lines.append( + "Archived, or no push in over 18 months. " + "Consider marking with the skull icon or delisting." + ) + if stale: + lines.append("") + for slug, reason in stale: + lines.append(f"- {slug} - {reason}") + else: + lines.append("") + lines.append("None found.") + lines.append("") + lines.append( + "_Some entries may be transient. Verify before acting._" + ) + + with open("health-report.md", "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + + has_findings = bool(dead or stale) + out = os.environ.get("GITHUB_OUTPUT") + if out: + with open(out, "a") as fh: + fh.write(f"has_findings={'true' if has_findings else 'false'}\n") + fh.write(f"date={today}\n") + print(f"Dead links: {len(dead)} | Stale repos: {len(stale)}") + PY + + - name: Ensure maintenance label exists + if: steps.scan.outputs.has_findings == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create maintenance \ + --color BFDADC \ + --description "Repository maintenance tasks" \ + --force || true + + - name: Open health report issue + if: steps.scan.outputs.has_findings == 'true' + uses: peter-evans/create-issue-from-file@v6 + with: + title: "🔗 Health report — ${{ steps.scan.outputs.date }}" + content-filepath: ./health-report.md + labels: maintenance diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml new file mode 100644 index 0000000..0536e8d --- /dev/null +++ b/.github/workflows/pr-lint.yml @@ -0,0 +1,124 @@ +name: PR Lint (links + format) + +# Runs on pull requests that touch the list. Two independent checks: +# 1. link-check - lychee looks for dead links in README.md. +# 2. format-lint - warns when a newly added entry has no description. + +on: + pull_request: + paths: + - 'README.md' + +permissions: + contents: read + +jobs: + link-check: + name: Check README links + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run lychee on README.md + uses: lycheeverse/lychee-action@v2 + with: + # Scope strictly to README.md. + # --accept whitelists transient/bot-block codes so the PR only hard + # fails on real dead links (404/410) and dead domains (DNS/connection + # errors, which lychee reports as errors and are not accepted here). + # lychee follows redirects, so a link that redirects to a live page + # counts as success. Retries soften short-lived timeouts. + args: >- + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --retry-wait-time 2 + --timeout 20 + --exclude-mail + --accept '100..=103,200..=299,403,429,500,502,503,504,999' + README.md + format: markdown + output: lychee/out.md + jobSummary: true + fail: true + failIfEmpty: false + + format-lint: + name: Lint new entries + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check added entries follow the house format + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + python3 - <<'PY' + import os, re, subprocess, sys + + base = os.environ.get("BASE_SHA", "") + head = os.environ.get("HEAD_SHA", "") + + # Only inspect lines this PR adds to README.md. + diff = "" + try: + diff = subprocess.run( + ["git", "diff", "--unified=0", base, head, "--", "README.md"], + capture_output=True, text=True, check=False, + ).stdout + except Exception as exc: + print(f"::warning::Could not compute diff, skipping format lint ({exc})") + + added = [ + line[1:] + for line in diff.splitlines() + if line.startswith("+") and not line.startswith("+++") + ] + + # A list item that carries an external link: "- [Name](https://...) ..." + entry_re = re.compile(r"^\s*[-*]\s+.*\]\(https?://") + # Strip the leading bullet so we can test the remainder. + bullet_re = re.compile(r"^\s*[-*]\s+") + + warnings, checked = [], 0 + for text in added: + if not entry_re.search(text): + continue # skips TOC anchors (#...), "Avoid" bullets, prose. + checked += 1 + body = bullet_re.sub("", text, count=1) + # House format puts " - description" after the link(s). Every valid + # entry has a " - " separator; a bare "- [Name](url)" has none. + if " - " not in body: + warnings.append(text.strip()) + print(f"::warning::Entry may be missing a description: {text.strip()}") + + out = ["## README format lint", ""] + out.append(f"Checked **{checked}** added link entr{'y' if checked == 1 else 'ies'}.") + out.append("") + if warnings: + out.append( + f"{len(warnings)} entr{'y' if len(warnings) == 1 else 'ies'} " + "may be missing a description " + "(house format: `- [Name](url) - description.`):" + ) + out.append("") + out += [f"- `{w}`" for w in warnings] + else: + out.append("All added link entries include a description.") + + report = "\n".join(out) + "\n" + print(report) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a") as fh: + fh.write(report) + + # Warn only. Format nits never block a PR. + sys.exit(0) + PY