mirror of
https://github.com/pluja/awesome-privacy.git
synced 2026-07-21 19:05:38 +02:00
ci: add PR lint, monthly health report, and PR template
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- Thanks for contributing. Keep this PR to one topic. See misc/Contributing.md. -->
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- One line: what you add and the section it goes under. -->
|
||||||
|
Adds `<Service or Section>` to the `<Section>` 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user