Adds PR checks

This commit is contained in:
Alicia Sykes 2026-02-22 17:01:12 +00:00
parent 2fec6f757d
commit adc0df53a5
16 changed files with 1269 additions and 21 deletions

125
.github/workflows/pr-check.yml vendored Normal file
View file

@ -0,0 +1,125 @@
name: PR Check
on:
pull_request:
branches: [main]
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: read
jobs:
check-pr:
name: Check PR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch base ref
run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -q -r lib/requirements.txt
- name: Detect changed files
id: changes
run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }}
- name: Check for direct README edits
id: readme
run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }}
- name: Schema validation
id: schema
if: steps.changes.outputs.yaml_changed == 'true'
run: make validate
- name: YAML diff analysis
id: diff
if: steps.changes.outputs.yaml_changed == 'true'
run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }}
- name: Link validation
if: steps.changes.outputs.yaml_changed == 'true' && steps.diff.outputs.has_service_changes == 'true'
run: python lib/checks/check-links.py --diff-json /tmp/pr-diff.json
- name: PR template check
id: template
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: python lib/checks/check-template.py
- name: Non-YAML changes warning
if: steps.changes.outputs.non_yaml_changed == 'true'
run: python lib/checks/warn-non-yaml.py --base-ref ${{ github.event.pull_request.base.sha }}
- name: Save PR metadata
if: always()
run: |
mkdir -p /tmp/pr-meta
echo "${{ github.event.pull_request.number }}" > /tmp/pr-meta/number.txt
echo "${{ github.run_id }}" > /tmp/pr-meta/run-id.txt
- name: Build failure comment
if: failure()
run: |
cat > /tmp/pr-meta/comment.md << 'HEADER'
<!-- pr-check-bot -->
> [!CAUTION]
> ## PR check failed
HEADER
if [ "${{ steps.readme.outcome }}" = "failure" ]; then
cat >> /tmp/pr-meta/comment.md << 'EOF'
### Direct README edits
The auto-generated section of the README must not be edited directly.
Please make your changes in `awesome-privacy.yml` instead — the README is regenerated from it.
EOF
fi
if [ "${{ steps.schema.outcome }}" = "failure" ]; then
cat >> /tmp/pr-meta/comment.md << 'EOF'
### Schema validation failed
`awesome-privacy.yml` does not conform to the expected schema.
Please check your YAML against the structure defined in `lib/schema.json`.
EOF
fi
if [ "${{ steps.diff.outcome }}" = "failure" ]; then
cat >> /tmp/pr-meta/comment.md << 'EOF'
### Single-entry rule
Each PR should add, modify, or remove only **one** service at a time.
If you need to change multiple services, please split them into separate PRs.
EOF
fi
if [ "${{ steps.template.outcome }}" = "failure" ]; then
cat >> /tmp/pr-meta/comment.md << 'EOF'
### PR template incomplete
Your PR description is missing required sections.
Please fill out the **Type**, **Changes**, and **Checklist** sections of the PR template.
EOF
fi
# Footer with link to logs
printf '\n---\n*See the [workflow logs](%s/%s/actions/runs/%s) for full details.*\n' \
"${{ github.server_url }}" "${{ github.repository }}" "${{ github.run_id }}" \
>> /tmp/pr-meta/comment.md
- name: Upload PR metadata
if: always()
uses: actions/upload-artifact@v4
with:
name: pr-meta
path: /tmp/pr-meta/

105
.github/workflows/pr-comment.yml vendored Normal file
View file

@ -0,0 +1,105 @@
name: PR Comment
on:
workflow_run:
workflows: ["PR Check"]
types: [completed]
permissions:
actions: read
pull-requests: write
jobs:
comment:
name: Post PR comment
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Download PR metadata
id: download
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: pr-meta
path: pr-meta
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Post or update comment
uses: actions/github-script@v7
with:
github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const marker = '<!-- pr-check-bot -->';
const conclusion = context.payload.workflow_run.conclusion;
// Determine the PR number
let prNumber;
const numberFile = 'pr-meta/number.txt';
if (fs.existsSync(numberFile)) {
prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim());
} else {
// workflow_run.pull_requests is empty for fork PRs, so
// fall back to searching by head SHA if needed
const prs = context.payload.workflow_run.pull_requests;
if (prs && prs.length > 0) {
prNumber = prs[0].number;
} else {
const headSha = context.payload.workflow_run.head_sha;
const { data: prList } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'desc',
per_page: 10,
});
const match = prList.find(pr => pr.head.sha === headSha);
if (!match) {
console.log(`No open PR found for SHA ${headSha} — skipping comment.`);
return;
}
prNumber = match.number;
}
}
// Find existing bot comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body.includes(marker));
if (conclusion === 'failure') {
const commentFile = 'pr-meta/comment.md';
if (!fs.existsSync(commentFile)) {
console.log('No comment.md found — skipping comment.');
return;
}
const body = fs.readFileSync(commentFile, 'utf8').trim();
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}
} else if (existing) {
// Checks passed — remove stale failure comment
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

211
lib/checks/check-links.py Normal file
View file

@ -0,0 +1,211 @@
"""
Validates URLs and metadata for added/modified services in a PR.
Reads the diff JSON produced by check-yaml-diff.py.
All checks are warnings only -- this script never fails (exit 0).
"""
import argparse
import json
import os
import sys
import requests
import yaml
# Paths (relative to project root)
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
# Exit codes
EXIT_PASS = 0
EXIT_RUNTIME_ERROR = 2
# ANSI color helpers
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s)
TIMEOUT = 10
USER_AGENT = "awesome-privacy-ci/1.0"
DESC_MIN_LEN = 50
DESC_MAX_LEN = 250
def check_url(url, label):
"""Check if a URL is reachable. Returns (ok, message)."""
try:
resp = requests.head(
url,
timeout=TIMEOUT,
allow_redirects=True,
headers={"User-Agent": USER_AGENT},
)
if resp.status_code >= 400:
# Retry with GET -- some servers reject HEAD
resp = requests.get(
url,
timeout=TIMEOUT,
allow_redirects=True,
headers={"User-Agent": USER_AGENT},
stream=True,
)
resp.close()
if resp.status_code >= 400:
return False, f"{label}: HTTP {resp.status_code} for {url}"
return True, None
except requests.RequestException as e:
return False, f"{label}: Connection error for {url} ({type(e).__name__})"
def check_service(service_data, service_name, category, section):
"""Run all checks on a single service. Returns list of warning strings."""
warnings = []
name_prefix = f"{category} > {section} > {service_name}"
# Check url
url = service_data.get("url")
if url:
ok, msg = check_url(url, "url")
if not ok:
warnings.append(f"{name_prefix}: {msg}")
else:
warnings.append(f"{name_prefix}: missing required field 'url'")
# Check icon
icon = service_data.get("icon")
if icon:
ok, msg = check_url(icon, "icon")
if not ok:
warnings.append(f"{name_prefix}: {msg}")
else:
warnings.append(f"{name_prefix}: missing 'icon' field (recommended by contributing guide)")
# Check iosApp
ios_app = service_data.get("iosApp")
if ios_app:
ok, msg = check_url(ios_app, "iosApp")
if not ok:
warnings.append(f"{name_prefix}: {msg}")
# Check github
github = service_data.get("github")
if github:
github_url = f"https://github.com/{github}"
ok, msg = check_url(github_url, "github")
if not ok:
warnings.append(f"{name_prefix}: {msg}")
# Check description length
desc = service_data.get("description", "")
desc_stripped = desc.strip()
desc_len = len(desc_stripped)
if desc_len < DESC_MIN_LEN:
warnings.append(f"{name_prefix}: description too short ({desc_len} chars, minimum {DESC_MIN_LEN})")
elif desc_len > DESC_MAX_LEN:
warnings.append(f"{name_prefix}: description too long ({desc_len} chars, maximum {DESC_MAX_LEN})")
return warnings
def find_service_in_head(category, section, service_name):
"""Look up a service in the head YAML by category/section/service name."""
try:
with open(DATA_PATH, "r") as f:
data = yaml.safe_load(f)
for cat in data.get("categories", []):
if cat.get("name") == category:
for sec in cat.get("sections", []):
if sec.get("name") == section:
for svc in sec.get("services", []):
if svc.get("name") == service_name:
return svc
except Exception:
pass
return None
def write_step_summary(all_warnings, services_checked):
"""Write a Markdown summary to $GITHUB_STEP_SUMMARY."""
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_file:
return
lines = ["## Link Validation\n"]
if not services_checked:
lines.append("No services to check.\n")
elif not all_warnings:
lines.append(f"All checks passed for {services_checked} service(s).\n")
else:
lines.append(f"Checked {services_checked} service(s), found {len(all_warnings)} warning(s):\n")
lines.append("| Warning |")
lines.append("|---------|")
for w in all_warnings:
escaped = w.replace("|", "\\|")
lines.append(f"| {escaped} |")
lines.append("")
lines.append("> **Note:** Link warnings are informational only and do not fail the check. "
"URLs may be temporarily down or block automated requests.\n")
with open(summary_file, "a") as f:
f.write("\n".join(lines) + "\n")
def main():
parser = argparse.ArgumentParser(description="Validate links for added/modified services")
parser.add_argument("--diff-json", required=True, help="Path to the diff JSON file")
args = parser.parse_args()
# Load diff
try:
with open(args.diff_json, "r") as f:
diff = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(red(f"Failed to load diff JSON: {e}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
all_warnings = []
services_checked = 0
# Check added services
for svc in diff.get("services", {}).get("added", []):
services_checked += 1
warnings = check_service(
svc.get("fields", {}),
svc["service"],
svc["category"],
svc["section"],
)
all_warnings.extend(warnings)
# Check modified services -- only if they have URL-related field changes
for svc in diff.get("services", {}).get("modified", []):
url_fields = {"url", "icon", "iosApp", "github", "description"}
changed = set(svc.get("changed_fields", []))
if changed & url_fields:
services_checked += 1
head_svc = find_service_in_head(svc["category"], svc["section"], svc["service"])
if head_svc:
warnings = check_service(
head_svc,
svc["service"],
svc["category"],
svc["section"],
)
all_warnings.extend(warnings)
# Print results
if all_warnings:
print(yellow(f"Link validation: {len(all_warnings)} warning(s)"), file=sys.stderr)
for w in all_warnings:
print(f" {yellow('WARNING')} {w}", file=sys.stderr)
else:
print(green(f"Link validation passed. {services_checked} service(s) checked."))
write_step_summary(all_warnings, services_checked)
sys.exit(EXIT_PASS)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,123 @@
"""
Fails if the PR directly edits the auto-generated section of the README.
The generated section is between <!-- awesome-privacy-start --> and <!-- awesome-privacy-end -->.
"""
import argparse
import os
import re
import subprocess
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
README_PATH = ".github/README.md"
README_ABS = os.path.join(PROJECT_ROOT, README_PATH)
# Exit codes
EXIT_PASS = 0
EXIT_FAIL = 1
EXIT_RUNTIME_ERROR = 2
# ANSI color helpers
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
def get_changed_files(base_ref):
result = subprocess.run(
["git", "diff", "--name-only", f"{base_ref}..HEAD"],
capture_output=True, text=True, check=True,
cwd=PROJECT_ROOT,
)
return result.stdout.strip().splitlines()
def get_marker_lines():
"""Find the line numbers of the start/end markers in the README."""
try:
with open(README_ABS, "r") as f:
lines = f.readlines()
except FileNotFoundError:
return None, None
start_line = None
end_line = None
for i, line in enumerate(lines, start=1):
if "<!-- awesome-privacy-start -->" in line:
start_line = i
if "<!-- awesome-privacy-end -->" in line:
end_line = i
return start_line, end_line
def get_changed_line_numbers(base_ref):
"""Parse git diff hunk headers to find which lines were changed in the README."""
result = subprocess.run(
["git", "diff", "-U0", f"{base_ref}..HEAD", "--", README_PATH],
capture_output=True, text=True, check=True,
cwd=PROJECT_ROOT,
)
changed_lines = []
for line in result.stdout.splitlines():
# Match hunk headers like @@ -10,5 +12,7 @@
match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", line)
if match:
start = int(match.group(1))
count = int(match.group(2)) if match.group(2) else 1
for n in range(start, start + count):
changed_lines.append(n)
return changed_lines
def write_step_summary():
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_file:
return
lines = [
"## Direct README Edit Detected\n",
"This PR directly modifies the auto-generated section of `.github/README.md` "
"(between `<!-- awesome-privacy-start -->` and `<!-- awesome-privacy-end -->`).\n",
"**Please edit `awesome-privacy.yml` instead.** The README is regenerated automatically from that file.\n",
]
with open(summary_file, "a") as f:
f.write("\n".join(lines) + "\n")
def main():
parser = argparse.ArgumentParser(description="Check for direct README edits to generated section")
parser.add_argument("--base-ref", required=True, help="Base git ref to diff against")
args = parser.parse_args()
# Skip if README wasn't changed
changed_files = get_changed_files(args.base_ref)
if README_PATH not in changed_files:
print(green("README not modified, skipping."))
sys.exit(EXIT_PASS)
# Find marker lines
start_line, end_line = get_marker_lines()
if start_line is None or end_line is None:
print("Could not find generated-section markers in README, skipping check.")
sys.exit(EXIT_PASS)
# Check if any changed lines fall within the generated section
changed_lines = get_changed_line_numbers(args.base_ref)
for line_num in changed_lines:
if start_line <= line_num <= end_line:
print(red("Direct edits to the generated section of the README are not allowed."), file=sys.stderr)
print(red("Edit awesome-privacy.yml instead and the README will be regenerated."), file=sys.stderr)
write_step_summary()
sys.exit(EXIT_FAIL)
print(green("README changes are outside the generated section, OK."))
sys.exit(EXIT_PASS)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,206 @@
"""
Checks PR body against the pull request template.
Reads the PR body from the PR_BODY environment variable (avoids shell injection).
Exits with code 1 for severe violations (empty body, missing required sections).
"""
import os
import re
import sys
# Exit codes
EXIT_PASS = 0
EXIT_FAIL = 1
EXIT_RUNTIME_ERROR = 2
# Warnings that should cause a hard failure
CRITICAL_WARNINGS = {
"PR body is empty or not provided",
"Type section is missing",
"Type section is empty",
"Changes section is missing",
"Changes section is empty",
"Checklist section is missing",
"Checklist section does not contain any checkbox items",
}
# ANSI color helpers
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s)
# Valid PR types from the template
VALID_TYPES = {"Addition", "Amendment", "Removal", "Spelling or Grammar", "Website Update", "Misc"}
# Raw template text that indicates an unfilled section
RAW_TYPE_LINE = "Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc"
def strip_html_comments(text):
"""Remove <!-- ... --> comments from text."""
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL).strip()
def extract_section(body, header):
"""Extract content between a ### header and the next --- or ### header."""
pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)"
match = re.search(pattern, body, re.DOTALL)
if match:
return match.group(1)
return None
def check_type_section(content):
"""Check the Type section. Returns list of warning strings."""
warnings = []
if content is None:
warnings.append("Type section is missing")
return warnings
cleaned = strip_html_comments(content).strip()
if not cleaned:
warnings.append("Type section is empty")
return warnings
# Check for raw template text (unchanged)
if RAW_TYPE_LINE in cleaned:
warnings.append("Type section appears unchanged from the template -- please select one type")
return warnings
# Check how many valid types are present
found_types = [t for t in VALID_TYPES if t in cleaned]
if len(found_types) == 0:
warnings.append(f"Type section does not contain a recognized type. Expected one of: {', '.join(sorted(VALID_TYPES))}")
elif len(found_types) > 1:
warnings.append(f"Type section contains multiple types ({', '.join(found_types)}) -- please select only one")
return warnings
def check_text_section(content, section_name):
"""Check a text section (Changes, Supporting Material, Affiliation). Returns list of warnings."""
warnings = []
if content is None:
warnings.append(f"{section_name} section is missing")
return warnings
cleaned = strip_html_comments(content).strip()
if not cleaned:
warnings.append(f"{section_name} section is empty")
return warnings
def check_checklist(content):
"""Check the Checklist section. Returns list of warnings."""
warnings = []
if content is None:
warnings.append("Checklist section is missing")
return warnings
checked = re.findall(r"- \[x\]", content, re.IGNORECASE)
unchecked = re.findall(r"- \[ \]", content)
total = len(checked) + len(unchecked)
if total == 0:
warnings.append("Checklist section does not contain any checkbox items")
return warnings
if unchecked:
warnings.append(f"Checklist has {len(unchecked)} unchecked item(s) out of {total}")
return warnings
def has_critical_warnings(warnings):
"""Return True if any warning is a critical (hard-fail) violation."""
return any(w in CRITICAL_WARNINGS for w in warnings)
def write_step_summary(all_warnings):
"""Write a Markdown summary to $GITHUB_STEP_SUMMARY."""
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_file:
return
lines = ["## PR Template Check\n"]
if not all_warnings:
lines.append("All template checks passed.\n")
else:
critical = has_critical_warnings(all_warnings)
lines.append(f"Found {len(all_warnings)} warning(s):\n")
for w in all_warnings:
lines.append(f"- {w}")
lines.append("")
if critical:
lines.append("> **Error:** One or more required sections are missing or empty. "
"Please fill out the PR template before this check can pass.\n")
else:
lines.append("> **Note:** Template warnings are informational and do not fail the check. "
"Reviewers will verify compliance.\n")
with open(summary_file, "a") as f:
f.write("\n".join(lines) + "\n")
def main():
pr_body = os.environ.get("PR_BODY")
if pr_body is None or pr_body.strip() == "":
all_warnings = [
"PR body is empty or not provided",
"Type section is missing",
"Changes section is missing",
"Supporting Material section is missing",
"Affiliation section is missing",
"Checklist section is missing",
]
print(yellow(f"PR template check: {len(all_warnings)} warning(s)"), file=sys.stderr)
for w in all_warnings:
print(f" {yellow('WARNING')} {w}", file=sys.stderr)
write_step_summary(all_warnings)
sys.exit(EXIT_FAIL)
all_warnings = []
# Check each section
type_content = extract_section(pr_body, "Type")
all_warnings.extend(check_type_section(type_content))
changes_content = extract_section(pr_body, "Changes")
all_warnings.extend(check_text_section(changes_content, "Changes"))
supporting_content = extract_section(pr_body, "Supporting Material")
all_warnings.extend(check_text_section(supporting_content, "Supporting Material"))
affiliation_content = extract_section(pr_body, "Affiliation")
all_warnings.extend(check_text_section(affiliation_content, "Affiliation"))
checklist_content = extract_section(pr_body, "Checklist")
all_warnings.extend(check_checklist(checklist_content))
# Print results
if all_warnings:
print(yellow(f"PR template check: {len(all_warnings)} warning(s)"), file=sys.stderr)
for w in all_warnings:
print(f" {yellow('WARNING')} {w}", file=sys.stderr)
else:
print(green("PR template check passed."))
write_step_summary(all_warnings)
if has_critical_warnings(all_warnings):
sys.exit(EXIT_FAIL)
sys.exit(EXIT_PASS)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,342 @@
"""
Analyzes the diff between base and head versions of awesome-privacy.yml.
Enforces the single-entry rule: only one service addition/amendment/removal per PR.
Outputs a JSON diff to /tmp/pr-diff.json and writes a step summary.
"""
import argparse
import json
import os
import subprocess
import sys
import yaml
# Paths (relative to project root)
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
DIFF_OUTPUT_PATH = "/tmp/pr-diff.json"
# Exit codes
EXIT_PASS = 0
EXIT_RULE_VIOLATION = 1
EXIT_RUNTIME_ERROR = 2
# ANSI color helpers
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s)
dim = (lambda s: f"\033[2m{s}\033[0m") if _use_color else (lambda s: s)
def load_base_yaml(base_ref):
"""Load the YAML from the base ref using git show."""
try:
result = subprocess.run(
["git", "show", f"{base_ref}:awesome-privacy.yml"],
capture_output=True, text=True, check=True,
cwd=PROJECT_ROOT,
)
return yaml.safe_load(result.stdout)
except subprocess.CalledProcessError:
# File doesn't exist in base (completely new file)
print(yellow("Warning: awesome-privacy.yml not found in base ref, treating as empty"), file=sys.stderr)
return {"categories": []}
except yaml.YAMLError as e:
print(red(f"Failed to parse base YAML: {e}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
def load_head_yaml():
"""Load the YAML from the current working tree."""
try:
with open(DATA_PATH, "r") as f:
return yaml.safe_load(f)
except FileNotFoundError:
print(red(f"File not found: {DATA_PATH}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
except yaml.YAMLError as e:
print(red(f"Failed to parse head YAML: {e}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
def build_service_index(data):
"""Build a dict keyed by (category, section, service_name) -> service dict."""
index = {}
for cat in data.get("categories", []):
cat_name = cat.get("name", "")
for sec in cat.get("sections", []):
sec_name = sec.get("name", "")
for svc in sec.get("services", []):
svc_name = svc.get("name", "")
key = (cat_name, sec_name, svc_name)
index[key] = svc
return index
def build_section_index(data):
"""Build a dict keyed by (category, section) -> section metadata (excluding services)."""
index = {}
for cat in data.get("categories", []):
cat_name = cat.get("name", "")
for sec in cat.get("sections", []):
sec_name = sec.get("name", "")
key = (cat_name, sec_name)
meta = {k: v for k, v in sec.items() if k != "services"}
index[key] = meta
return index
def build_category_index(data):
"""Build a dict keyed by category_name -> category metadata (excluding sections)."""
index = {}
for cat in data.get("categories", []):
cat_name = cat.get("name", "")
meta = {k: v for k, v in cat.items() if k != "sections"}
index[cat_name] = meta
return index
def diff_services(base_data, head_data):
"""Find added, removed, and modified services."""
base_idx = build_service_index(base_data)
head_idx = build_service_index(head_data)
base_keys = set(base_idx.keys())
head_keys = set(head_idx.keys())
added = []
for key in sorted(head_keys - base_keys):
added.append({
"category": key[0],
"section": key[1],
"service": key[2],
"fields": head_idx[key],
})
removed = []
for key in sorted(base_keys - head_keys):
removed.append({
"category": key[0],
"section": key[1],
"service": key[2],
})
modified = []
for key in sorted(base_keys & head_keys):
base_svc = base_idx[key]
head_svc = head_idx[key]
if base_svc != head_svc:
changed_fields = []
all_fields = set(base_svc.keys()) | set(head_svc.keys())
for field in sorted(all_fields):
old_val = base_svc.get(field)
new_val = head_svc.get(field)
if old_val != new_val:
changed_fields.append(field)
modified.append({
"category": key[0],
"section": key[1],
"service": key[2],
"changed_fields": changed_fields,
})
return added, removed, modified
def diff_sections(base_data, head_data):
"""Find section-level metadata changes (intro, wordOfWarning, etc.)."""
base_idx = build_section_index(base_data)
head_idx = build_section_index(head_data)
base_keys = set(base_idx.keys())
head_keys = set(head_idx.keys())
changes = []
# New sections
for key in sorted(head_keys - base_keys):
changes.append({
"category": key[0],
"section": key[1],
"change_type": "added_section",
})
# Removed sections
for key in sorted(base_keys - head_keys):
changes.append({
"category": key[0],
"section": key[1],
"change_type": "removed_section",
})
# Modified section metadata
for key in sorted(base_keys & head_keys):
base_meta = base_idx[key]
head_meta = head_idx[key]
if base_meta != head_meta:
changed_fields = []
all_fields = set(base_meta.keys()) | set(head_meta.keys())
for field in sorted(all_fields):
if base_meta.get(field) != head_meta.get(field):
changed_fields.append(field)
changes.append({
"category": key[0],
"section": key[1],
"change_type": "modified_section_metadata",
"changed_fields": changed_fields,
})
return changes
def diff_categories(base_data, head_data):
"""Find structural category changes."""
base_idx = build_category_index(base_data)
head_idx = build_category_index(head_data)
base_keys = set(base_idx.keys())
head_keys = set(head_idx.keys())
changes = []
for name in sorted(head_keys - base_keys):
changes.append({"category": name, "change_type": "added_category"})
for name in sorted(base_keys - head_keys):
changes.append({"category": name, "change_type": "removed_category"})
return changes
def write_github_output(name, value):
"""Write a value to $GITHUB_OUTPUT."""
output_file = os.environ.get("GITHUB_OUTPUT")
if output_file:
with open(output_file, "a") as f:
f.write(f"{name}={value}\n")
def write_step_summary(diff_result):
"""Write a Markdown summary to $GITHUB_STEP_SUMMARY."""
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_file:
return
lines = ["## YAML Diff Analysis\n"]
added = diff_result["services"]["added"]
removed = diff_result["services"]["removed"]
modified = diff_result["services"]["modified"]
section_changes = diff_result["sections"]
category_changes = diff_result["categories"]
if not added and not removed and not modified and not section_changes and not category_changes:
lines.append("No changes detected in `awesome-privacy.yml`.\n")
else:
lines.append("| Type | Category | Section | Service | Details |")
lines.append("|------|----------|---------|---------|---------|")
for svc in added:
lines.append(f"| Added | {svc['category']} | {svc['section']} | {svc['service']} | New service |")
for svc in removed:
lines.append(f"| Removed | {svc['category']} | {svc['section']} | {svc['service']} | Service removed |")
for svc in modified:
fields = ", ".join(svc["changed_fields"])
lines.append(f"| Modified | {svc['category']} | {svc['section']} | {svc['service']} | Changed: {fields} |")
for change in section_changes:
detail = change["change_type"].replace("_", " ").title()
fields = ", ".join(change.get("changed_fields", []))
if fields:
detail += f" ({fields})"
lines.append(f"| Section | {change['category']} | {change['section']} | - | {detail} |")
for change in category_changes:
detail = change["change_type"].replace("_", " ").title()
lines.append(f"| Category | {change['category']} | - | - | {detail} |")
lines.append("")
with open(summary_file, "a") as f:
f.write("\n".join(lines) + "\n")
def main():
parser = argparse.ArgumentParser(description="Analyze YAML diff for PR checks")
parser.add_argument("--base-ref", required=True, help="Base git ref (SHA or branch) to diff against")
args = parser.parse_args()
# Load both versions
base_data = load_base_yaml(args.base_ref)
head_data = load_head_yaml()
# Compute diffs
added, removed, modified = diff_services(base_data, head_data)
section_changes = diff_sections(base_data, head_data)
category_changes = diff_categories(base_data, head_data)
# Build result
diff_result = {
"services": {
"added": added,
"removed": removed,
"modified": modified,
},
"sections": section_changes,
"categories": category_changes,
}
# Write diff JSON
with open(DIFF_OUTPUT_PATH, "w") as f:
json.dump(diff_result, f, indent=2)
print(f"Diff written to {DIFF_OUTPUT_PATH}")
# Determine if there are service-level changes
has_service_changes = bool(added or removed or modified)
write_github_output("has_service_changes", str(has_service_changes).lower())
# Write step summary
write_step_summary(diff_result)
# Enforce single-entry rule
service_change_count = len(added) + len(removed) + len(modified)
if service_change_count > 1:
print(red("Single-entry rule violation: PRs must contain only one service change."), file=sys.stderr)
print(red(f"Found {service_change_count} service-level changes:"), file=sys.stderr)
for svc in added:
print(f" + Added: {svc['category']} > {svc['section']} > {svc['service']}", file=sys.stderr)
for svc in removed:
print(f" - Removed: {svc['category']} > {svc['section']} > {svc['service']}", file=sys.stderr)
for svc in modified:
fields = ", ".join(svc["changed_fields"])
print(f" ~ Modified: {svc['category']} > {svc['section']} > {svc['service']} ({fields})", file=sys.stderr)
sys.exit(EXIT_RULE_VIOLATION)
# If no service changes, check section-level changes
if service_change_count == 0 and len(section_changes) > 1:
print(red("Single-entry rule violation: PRs must contain only one section-level change."), file=sys.stderr)
print(red(f"Found {len(section_changes)} section-level changes:"), file=sys.stderr)
for change in section_changes:
detail = change["change_type"].replace("_", " ")
fields = change.get("changed_fields", [])
extra = f" ({', '.join(fields)})" if fields else ""
print(f" ~ {change['category']} > {change['section']}: {detail}{extra}", file=sys.stderr)
sys.exit(EXIT_RULE_VIOLATION)
# Summary
total = service_change_count + len(section_changes) + len(category_changes)
if total == 0:
print(green("No changes detected in awesome-privacy.yml"))
else:
print(green(f"Single-entry rule passed. {service_change_count} service change(s), "
f"{len(section_changes)} section change(s), {len(category_changes)} category change(s)."))
sys.exit(EXIT_PASS)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,50 @@
"""
Detects which files changed between the PR base and HEAD.
Sets GitHub Actions outputs: yaml_changed, non_yaml_changed.
"""
import argparse
import os
import subprocess
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
YAML_FILE = "awesome-privacy.yml"
def write_github_output(name, value):
output_file = os.environ.get("GITHUB_OUTPUT")
if output_file:
with open(output_file, "a") as f:
f.write(f"{name}={value}\n")
def main():
parser = argparse.ArgumentParser(description="Detect changed files in a PR")
parser.add_argument("--base-ref", required=True, help="Base git ref to diff against")
args = parser.parse_args()
result = subprocess.run(
["git", "diff", "--name-only", f"{args.base_ref}..HEAD"],
capture_output=True, text=True, check=True,
cwd=PROJECT_ROOT,
)
changed_files = [f for f in result.stdout.strip().splitlines() if f]
print("Changed files:")
for f in changed_files:
print(f" {f}")
yaml_changed = YAML_FILE in changed_files
non_yaml_changed = any(f != YAML_FILE for f in changed_files)
write_github_output("yaml_changed", str(yaml_changed).lower())
write_github_output("non_yaml_changed", str(non_yaml_changed).lower())
print(f"yaml_changed={yaml_changed}, non_yaml_changed={non_yaml_changed}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,58 @@
"""
Warns when a PR modifies files other than awesome-privacy.yml.
This is expected for Website Update or Misc PRs, but may need extra review.
"""
import argparse
import os
import subprocess
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
YAML_FILE = "awesome-privacy.yml"
# ANSI color helpers
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s)
def main():
parser = argparse.ArgumentParser(description="Warn about non-YAML file changes")
parser.add_argument("--base-ref", required=True, help="Base git ref to diff against")
args = parser.parse_args()
result = subprocess.run(
["git", "diff", "--name-only", f"{args.base_ref}..HEAD"],
capture_output=True, text=True, check=True,
cwd=PROJECT_ROOT,
)
changed_files = [f for f in result.stdout.strip().splitlines() if f]
non_yaml = [f for f in changed_files if f != YAML_FILE]
if not non_yaml:
return
print(yellow("This PR modifies files other than awesome-privacy.yml:"), file=sys.stderr)
for f in non_yaml:
print(f" {f}", file=sys.stderr)
# Write step summary
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_file:
lines = [
"## Non-YAML Changes Warning\n",
"This PR modifies files other than `awesome-privacy.yml`:\n",
]
for f in non_yaml:
lines.append(f"- `{f}`")
lines.append("")
lines.append("> **Note:** Most PRs should only modify `awesome-privacy.yml`. "
"Non-YAML changes may require additional review.\n")
with open(summary_file, "a") as f:
f.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()

View file

@ -1,2 +1,3 @@
PyYAML==6.0.1
jsonschema==4.23.0
requests==2.32.3

View file

@ -7,38 +7,64 @@
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"name": { "type": "string", "minLength": 1, "maxLength": 50 },
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"services": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"description": { "type": "string" },
"url": { "type": "string" },
"github": { "type": ["string", "null"] },
"icon": { "type": ["string", "null"] },
"followWith": { "type": ["string", "null"] },
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"description": { "type": "string", "minLength": 10, "maxLength": 1500 },
"url": {
"type": "string",
"anyOf": [
{ "pattern": "^https?://" },
{ "maxLength": 0 }
]
},
"github": {
"type": ["string", "null"],
"pattern": "^([a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+|https://github\\.com/.+)$"
},
"icon": {
"type": ["string", "null"],
"pattern": "^https?://.+"
},
"followWith": { "type": ["string", "null"], "minLength": 1, "maxLength": 100 },
"securityAudited": { "type": ["boolean", "null"] },
"openSource": { "type": ["boolean", "null"] },
"acceptsCrypto": { "type": ["boolean", "null"] },
"tosdrId": { "type": ["number", "null"] },
"iosApp": { "type": ["string", "null"] },
"androidApp": { "type": ["string", "null"] },
"discordInvite": { "type": ["string", "null"] },
"subreddit": { "type": ["string", "null"] }
"tosdrId": { "type": ["integer", "null"], "minimum": 1 },
"iosApp": {
"type": ["string", "null"],
"pattern": "^https://apps\\.apple\\.com/"
},
"androidApp": {
"type": ["string", "null"],
"pattern": "^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$"
},
"discordInvite": {
"type": ["string", "null"],
"pattern": "^(https://discord\\.gg/[a-zA-Z0-9]+|[a-zA-Z0-9]+|)$"
},
"subreddit": {
"type": ["string", "null"],
"pattern": "^[a-zA-Z0-9_]+$",
"minLength": 1,
"maxLength": 50
}
},
"required": ["name", "description", "url"],
"additionalProperties": false
}
},
"intro": { "type": ["string", "null"] },
"intro": { "type": ["string", "null"], "minLength": 1 },
"notableMentions": {
"oneOf": [
{
@ -46,25 +72,26 @@
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"description": { "type": "string" },
"url": { "type": "string" }
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"description": { "type": "string", "minLength": 1 },
"url": { "type": "string", "pattern": "^https?://" }
},
"required": ["name", "url"],
"additionalProperties": false
}
},
{ "type": "string" },
{ "type": "string", "minLength": 1 },
{ "type": "null" }
]
},
"furtherInfo": { "type": ["string", "null"] },
"wordOfWarning": { "type": ["string", "null"] },
"furtherInfo": { "type": ["string", "null"], "minLength": 1 },
"wordOfWarning": { "type": ["string", "null"], "minLength": 1 },
"alternativeTo": {
"oneOf": [
{
"type": "array",
"items": { "type": "string" }
"items": { "type": "string", "minLength": 1, "maxLength": 100 },
"minItems": 1
},
{ "type": "null" }
]