Merge pull request #387 from Lissy93/ref/2026-refresh

Ref/2026 refresh
This commit is contained in:
Alicia Sykes 2026-02-23 22:01:58 +00:00 committed by GitHub
commit a6a8682af6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1609 additions and 505 deletions

View file

@ -19,7 +19,7 @@
You can add, edit or remove entries by opening a pull request.
All data is stored in [`awesome-privacy.yml`](https://github.com/Lissy93/awesome-privacy/blob/main/awesome-privacy.yml).
If you're adding, editing or removing a listing - **this is the only file you need to edit**.
If you're adding, editing or removing a listing - **this is the only file you need to edit**. Don't edit the README directly, as this is auto-generated from the YAML file.
### Process
@ -74,12 +74,11 @@ Usually these entries go within the "Notable Mentions" section instead._
Your pull request must follow these requirements. Failure to do so, might result in it being closed.
- Do not edit the README directly when adding / editing a listing
- Do not edit the README directly when adding / editing a listing (it's auto-generated!)
- Ensure your PR is not a duplicate, search for existing / previous submissions first
- You must respond to any comments or requests for changes in a timely manner, 48-hours maximum
- You must respond to any comments or requests for changes in a timely manner, 14 days maximum
- Write short but descriptive git commit messages, under 50 characters. This must be in the format of `Adds [software-name] to [section-name]`. Your PR will be rejected if you name it `Updates README.md`
- Only include a single addition / amendment / removal, per pull request
- If your pull request contains multiple commits, you must squash them first
- You must complete each of the sections in the pull request template. Do not delete it!
- Where applicable, include links to supporting material for your addition: git repo, docs, recent security audits, etc. This will make researching it much easier for reviewers
- While adding new software to the list, don't make your entry read like an advert. Be objective, and include drawbacks as well as strengths
@ -90,8 +89,8 @@ Your pull request must follow these requirements. Failure to do so, might result
- You must adhere to the Contributor Covenant Code of Conduct
- Don't open a Draft / WIP pull request while you work on the guidelines. A pull request should be 100% ready and should adhere to all the above guidelines when you open it
- Your changes must be correctly spelled, and with good grammar
- Your changes must be correctly formatted, in valid markdown
- The addition title must be a link the project, and in bold
- Your changes must be correctly formatted, in valid yaml and markdown
- The addition title must be a link the project
- The addition description must be no less than 50, and no more than 250 characters, keep it clear and to the point
---
@ -103,8 +102,6 @@ This file may look a bit daunting to start with, but don't worry - it's pretty s
### Top-Level Structure
```mermaid
---
title: Class Diagram

View file

@ -1,42 +0,0 @@
# Checks domain and SSL status, then raises an issue if either is expiring soon
name: 🌎 Check Domain Expiry
on:
workflow_dispatch:
schedule:
- cron: '0 5 * * 6' # Every Saturday morning.
jobs:
check-domain:
runs-on: ubuntu-latest
name: Check domain
strategy:
matrix:
domain:
- https://awesome-privacy.xyz
steps:
- name: Check domain SSL and registry expire date
id: check-domain
uses: codex-team/action-check-domain@v1
with:
url: ${{ matrix.domain }}
- name: Raise issue if domain expiring soon
if: ${{ steps.check-domain.outputs.paid-till-days-left && steps.check-domain.outputs.paid-till-days-left < 30 }}
uses: rishabhgupta/git-action-issue@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
assignees: Lissy93
title: '[WEBSITE] Domain Expiring Soon'
body: >
**Priority Notice**
Domain, ${{ matrix.domain }} will expire in ${{ steps.check-domain.outputs.paid-till-days-left }} days.
@Lissy93 - Please take action immediately to prevent any downtime
- name: Raise issue if SSL Cert expiring soon
if: ${{ steps.check-domain.outputs.ssl-expire-days-left && steps.check-domain.outputs.ssl-expire-days-left < 14 }}
uses: rishabhgupta/git-action-issue@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
assignees: Lissy93
title: '[WEBSITE] SSL Cert Expiring Soon'
body: >
**Priority Notice**
The SSL Certificate for ${{ matrix.domain }} will expire in ${{ steps.check-domain.outputs.ssl-expire-days-left }} days, on ${{ steps.check-domain.outputs.ssl-expire-date }}.
@Lissy93 - Please take action immediately to prevent any downtime

View file

@ -1,44 +0,0 @@
# Generates and saved a PDF document from the main markdown file
# Easier to read on certain devices, or for users with accesibility needs
name: 📁 Compile PDF Document
on:
workflow_dispatch: # Manual dispatch
schedule:
- cron: '0 5 * * 6' # Every Saturday morning.
jobs:
# Job #1 - Generate an embedded SVG asset, showing all contributors
compile-pdf:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Make PDF 📄
uses: baileyjm02/markdown-to-pdf@v1.1.0
with:
input_dir: .
output_dir: .github/assets/
build_pdf: true
build_html: false
table_of_contents: false
- name: Upload Artifact 📤
uses: actions/upload-artifact@v3
with:
name: awesome-privacy-pdf
path: .github/assets/README.pdf
- name: Commit file ✅
run: |
git config --local user.email "alicia-gh-bot@mail.as93.net"
git config --local user.name "liss-bot"
git add .github/assets/*.pdf
if ! git diff-index --quiet HEAD; then
git commit -m "Generate PDF file"
else
echo "Nothing to do"
fi
- name: Push changes ➡️
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}

View file

@ -1,34 +0,0 @@
# Inserts list of contributors and community members into ./docs/credits.md
name: 📊 Generate Contributor Credits
on:
workflow_dispatch: # Manual dispatch
schedule:
- cron: '0 5 * * 6' # Every Saturday morning.
jobs:
# Job #1 - Inserts sponsors into README
insert-sponsors:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Generate Sponsors in Credits 💖
uses: JamesIves/github-sponsors-readme-action@1.0.5
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
file: '.github/README.md'
# Job #2 - Inserts contributors into README
insert-credits:
runs-on: ubuntu-latest
name: Inserts contributors into credits.md
steps:
- name: Contribute List - Credits Page
uses: akhilmhdh/contributors-readme-action@v2.2
env:
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
with:
image_size: 80
readme_path: .github/README.md
columns_per_row: 6
commit_message: 'Updates contributors list'
committer_username: liss-bot
committer_email: liss-bot@d0h.co

16
.github/workflows/mirror.yml vendored Normal file
View file

@ -0,0 +1,16 @@
# Syncs repo to the Codeberg mirror
name: 🪞 Mirror
on:
schedule: [{ cron: '0 5 * * 0' }]
push: { tags: ['v*'] }
workflow_dispatch:
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: lissy93/repo-mirror-action@main
with:
ssh_key: ${{ secrets.CODEBERG_SSH }}
host: git@codeberg.org
user: alicia
repo: awesome-privacy

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

@ -0,0 +1,156 @@
name: PR Check
on:
pull_request:
branches: [main]
types: [opened, edited, synchronize, reopened]
paths:
- 'awesome-privacy.yml'
- '.github/README.md'
permissions:
contents: read
pull-requests: read
jobs:
pr-compliance:
name: PR Compliance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check README edits
id: readme
continue-on-error: true
run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }}
- name: Check PR metadata
id: meta
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_DRAFT: ${{ github.event.pull_request.draft }}
README_FAILED: ${{ steps.readme.outcome == 'failure' && 'true' || 'false' }}
run: python lib/checks/check-pr-meta.py
- name: Upload findings
if: always()
uses: actions/upload-artifact@v4
with:
name: findings-compliance
path: /tmp/findings-compliance.json
if-no-files-found: ignore
- name: Fail if critical
if: steps.readme.outcome == 'failure' || steps.meta.outcome == 'failure'
run: exit 1
data-validation:
name: Data Validation
runs-on: ubuntu-latest
outputs:
yaml_changed: ${{ steps.changes.outputs.yaml_changed }}
steps:
- uses: actions/checkout@v4
- run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Detect changes
id: changes
run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }}
- name: Install dependencies
if: steps.changes.outputs.yaml_changed == 'true'
run: pip install -q -r lib/requirements.txt
- name: Schema validation
if: steps.changes.outputs.yaml_changed == 'true'
id: schema
continue-on-error: true
run: make validate
- name: YAML diff
if: steps.changes.outputs.yaml_changed == 'true'
id: diff
continue-on-error: true
run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }}
- name: Check additions
if: steps.changes.outputs.yaml_changed == 'true'
env:
SCHEMA_OUTCOME: ${{ steps.schema.outcome }}
run: python lib/checks/check-additions.py
- name: Upload diff data
if: always()
uses: actions/upload-artifact@v4
with:
name: pr-diff
path: /tmp/pr-diff.json
if-no-files-found: ignore
- name: Upload findings
if: always()
uses: actions/upload-artifact@v4
with:
name: findings-data
path: /tmp/findings-data.json
if-no-files-found: ignore
- name: Fail if critical
if: steps.changes.outputs.yaml_changed == 'true' && (steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure')
run: exit 1
submission-eligibility:
name: Submission Eligibility
needs: data-validation
if: "!cancelled() && needs.data-validation.outputs.yaml_changed == 'true'"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -q -r lib/requirements.txt
- name: Download diff data
uses: actions/download-artifact@v4
with:
name: pr-diff
path: /tmp
continue-on-error: true
- name: Check project health
env:
PR_USER: ${{ github.event.pull_request.user.login }}
GITHUB_TOKEN: ${{ github.token }}
run: python lib/checks/check-project.py
- name: Upload findings
if: always()
uses: actions/upload-artifact@v4
with:
name: findings-project
path: /tmp/findings-project.json
if-no-files-found: ignore
summary:
name: Summary
if: always()
needs: [pr-compliance, data-validation, submission-eligibility]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Download all findings
uses: actions/download-artifact@v4
with:
pattern: findings-*
path: /tmp/artifacts
merge-multiple: true
continue-on-error: true
- name: Format comment
env:
PR_USER: ${{ github.event.pull_request.user.login }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_ID: ${{ github.run_id }}
run: python lib/checks/format-comment.py
- name: Upload PR metadata
if: always()
uses: actions/upload-artifact@v4
with:
name: pr-meta
path: /tmp/pr-meta/

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

@ -0,0 +1,94 @@
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 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 -->';
// Check if there are findings to post
const commentFile = 'pr-meta/comment.md';
if (!fs.existsSync(commentFile)) {
console.log('No findings to post — skipping.');
return;
}
// Determine the PR number
let prNumber;
const numberFile = 'pr-meta/number.txt';
if (fs.existsSync(numberFile)) {
prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim());
}
if (!prNumber) {
// 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: 100,
});
const match = prList.find(pr => pr.head.sha === headSha);
if (!match) {
console.log(`No open PR found for SHA ${headSha} — skipping.`);
return;
}
prNumber = match.number;
}
}
// Skip if we already commented on this PR
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
if (comments.some(c => c.body.includes(marker))) {
console.log('Bot comment already exists — skipping.');
return;
}
// Post the comment
const body = fs.readFileSync(commentFile, 'utf8').trim();
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});

View file

@ -1,25 +0,0 @@
# Applies labels based on the pull request category
name: 🏷️ PR Labeler
on:
pull_request:
types: [opened, edited]
jobs:
label-pr:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Apply Labels
if: "! contains(github.event.pull_request.body, 'Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc')"
uses: Naturalclar/issue-action@v2.0.2
with:
title-or-body: both
github-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
parameters: >
[
{"keywords": ["Addition"], "labels": ["Addition"] },
{"keywords": ["Amendment"], "labels": ["Amendment"] },
{"keywords": ["Removal"], "labels": ["Removal"] },
{"keywords": ["Spelling or Grammar"], "labels": ["Grammar"] },
{"keywords": ["Website Update"], "labels": ["Website"] },
{"keywords": ["Misc"], "labels": ["Misc"] }
]

View file

@ -1,20 +0,0 @@
# Spell check newly added content, when PR opened and, put typo list as comment
name: ✏️ Spell Check
on: [pull_request]
jobs:
misspell:
name: runner / misspell
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Run Spell Check 📝
uses: reviewdog/action-misspell@v1
with:
github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
locale: US
level: info
reporter: github-pr-review
path: .
filter_mode: added
fail_on_error: false

View file

@ -1,17 +0,0 @@
# Pushes the contents of the repo to the Codeberg mirror
name: 🪞 Mirror to Codeberg
on:
workflow_dispatch: # Manual dispatch
schedule:
- cron: '0 5 * * 6'
jobs:
codeberg:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url: "git@codeberg.org:alicia/awesome-privacy.git"
ssh_private_key: ${{ secrets.CODEBERG_SSH }}

View file

@ -1,40 +0,0 @@
# Checks newly opened issues contain enough info, and follow the required format
name: 🎫 Issue Validator
on:
issues:
types: [opened, edited]
jobs:
check-title:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Check Default Title
if: "endsWith(github.event.issue.title, '<title>')"
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Please ensure that your ticket has an appropriate title
- name: Check Title Contains Categroy
if: "!(startsWith(github.event.issue.title, '[') && contains(github.event.issue.title, ']'))"
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Please ensure that your ticket's title is preceded with a category.
For example, `[ADDITION]`, `[AMENDMENT]`, `[REMOVAL]` or `[QUESTION]`.
- name: Check Quality Checklist
if: "contains(github.event.issue.body, '[ ]') || !(contains(github.event.issue.body, '[X]') || contains(github.event.issue.body, '[x]'))"
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
body: |
Please ensure that you've followed the issue template fully.
It's important that you complete the quality & transparency checklist.

View file

@ -1,90 +0,0 @@
# Checks that PR title conform to contributing standards (or at least !== Update README.md)
name: ⛳ Validate PR
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: write
env:
BASE_MSG: >+
Thanks for contributing to Awesome-Privacy! Your pull request will be reviewed shortly.
In the meantime, please be sure that you have read, and complied with the guidelines outlined in the
[Contributing Docs](https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md).
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Validate Title is not Default
if: "contains(github.event.pull_request.title, 'Update README.md')"
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Hi @${{ github.actor }},
Please update your pull request, to include a more descriptive title.
- name: Validate Checklist is Completed
if: >
contains(github.event.pull_request.body, '[ ]') ||
!(contains(github.event.pull_request.body, '[X]') || contains(github.event.pull_request.body, '[x]'))
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Hello @${{ github.actor }} 👋
${{ env.BASE_MSG }}
⚠️ It looks like you've not complete the quality and transparency checklist.
- name: Validate Affiliation Section is Present
if: >
!contains(github.event.pull_request.body, 'Affiliation')
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Hello @${{ github.actor }} 👋
${{ env.BASE_MSG }}
⚠️ You must indicate if you are affiliated with any software modified by this PR.
If not applicable, you may set this field to N/A.
- name: Validate Category
if: >
contains(github.event.pull_request.body, 'Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc') ||
!(
contains(github.event.pull_request.body, 'Addition') ||
contains(github.event.pull_request.body, 'Amendment') ||
contains(github.event.pull_request.body, 'Removal') ||
contains(github.event.pull_request.body, 'Spelling or Grammar') ||
contains(github.event.pull_request.body, 'Website Update') ||
contains(github.event.pull_request.body, 'Misc')
)
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Hello @${{ github.actor }} 👋
${{ env.BASE_MSG }}
⚠️ You must specify a category
Either: `Addition`, `Amendment`, `Removal`, `Spelling or Grammar`, `Website Update`, or `Misc`.
- name: Validate Supporting Material is Present
if: >
!contains(github.event.pull_request.body, 'Supporting Material')
uses: peter-evans/create-or-update-comment@v2
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Hello @${{ github.actor }} 👋
${{ env.BASE_MSG }}
⚠️ If applicable, please ensure you've provided supporting material.

View file

@ -1,18 +0,0 @@
name: ⭐ Hello non-Stargazers
on:
issues:
types: [opened]
jobs:
check-user:
if: ${{ github.event.comment.author_association != 'CONTRIBUTOR' }}
runs-on: ubuntu-latest
name: Add comment to issues opened by non-stargazers
steps:
- name: comment
uses: qxip/please-star-light@v4
with:
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
autoclose: false
message: |
If you're enjoying Awesome-Privacy, consider dropping us a ⭐<br>
_<sub>🤖 I'm a bot, and this message was automated</sub>_

View file

@ -34,7 +34,7 @@ WEB_DIR := web
# Targets for lib/
install_lib_deps:
$(PYTHON) -m pip install -r $(LIB_DIR)/requirements.txt
$(PYTHON) -m pip install -q -r $(LIB_DIR)/requirements.txt
gen_readme: install_lib_deps
$(PYTHON) $(LIB_DIR)/awesome-privacy-readme-gen.py

View file

@ -135,7 +135,7 @@ categories:
Free for self-hosted data (or $3/ month hosted). Be aware that 1Password
is not fully open source, but they do regularly publish results of their
independent [security audits](https://support.1password.com/security-assessments),
and they have a solid reputation for transparently disclosing and fixing vulnerabilities
and they have a solid reputation for transparently disclosing and fixing vulnerabilities
furtherInfo: >
**Other Open Source PM**: [Buttercup](https://buttercup.pw), [Clipperz](https://clipperz.is),
[Pass](https://www.passwordstore.org), [Padloc](https://padloc.app), [TeamPass](https://teampass.net),
@ -252,9 +252,9 @@ categories:
iosApp: https://apps.apple.com/us/app/ente-auth/id6444121398
androidApp: io.ente.auth
description: |
Ente Auth is a free and open-source app which stores and generates TOTP tokens.
It can be used with an online account to backup and sync your tokens across your
devices (and access them via a web interface) in a secure, end-to-end encrypted
Ente Auth is a free and open-source app which stores and generates TOTP tokens.
It can be used with an online account to backup and sync your tokens across your
devices (and access them via a web interface) in a secure, end-to-end encrypted
fashion. It can also be used offline on a single device with no account necessary.
furtherInfo: >
@ -265,7 +265,7 @@ categories:
[Etopa](https://play.google.com/store/apps/details?id=de.ltheinrich.etopa) *(Android)*<br>
For KeePass users, [TrayTop](https://keepass.info/plugins.html#traytotp)
is a plugin for managing TOTP's - offline and compatible with Windows, Mac and Linux.
#############################
###### File Encryption ######
#############################
@ -309,9 +309,10 @@ categories:
- name: Picocrypt
github: Picocrypt/Picocrypt
icon: https://avatars.githubusercontent.com/u/171401041
url: ''
description: |
Picocrypt is a very small (hence Pico), very simple, yet very secure encryption tools
that you can use to protect your files. It's designed to be the go-to tool for encryption,
that you can use to protect your files. It's designed to be the go-to tool for encryption,
with a focus on security, simplicity, and reliability.
wordOfWarning: >
@ -440,7 +441,7 @@ categories:
After installing, check the privacy & security settings, and update the configuration to
something that you are comfortable with. 12Bytes maintains a comprehensive guide on
[Firefox Configuration for Privacy and Performance](https://codeberg.org/12bytes/firefox-config-guide)
############################
###### Search Engines ######
############################
@ -524,8 +525,8 @@ categories:
and self-hostable, although using a [public instance](https://searx.space)
has the benefit of not singling out your queries to the engines used.
A fork of the original [Searx](https://searx.github.io/searx/).
- name: Communication
sections:
#################################
@ -581,10 +582,10 @@ categories:
subreddit: SimpleXChat
description: |
Simplex is gaining popularity as a secure and private messaging app renowned
for its robust encryption protocol without user IDs or phone numbers and this improves your privacy.
for its robust encryption protocol without user IDs or phone numbers and this improves your privacy.
Simplex offers instant messaging, supports media attachments and voice and video calls.
Additionally, it is cross-platform, open-source, and completely free, aligning with the modern user's
preferences for convenience, security, and accessibility.
Additionally, it is cross-platform, open-source, and completely free, aligning with the modern user's
preferences for convenience, security, and accessibility.
Learn more about the [Security Policy](https://simplex.chat/security/).
- name: XMPP
@ -615,7 +616,7 @@ categories:
an open specification and Simple pragmatic RESTful HTTP/JSON API it makes it easy
to integrates with existing 3rd party IDs to authenticate and discover users, as
well as to build apps on top of it.
notableMentions:
notableMentions:
- name: Chat Secure
url: https://chatsecure.org
- name: KeyBase
@ -649,7 +650,7 @@ categories:
be used to communicate any sensitive data.
[Wire](https://wire.com/) has also been removed, due to
a [recent acquisition](https://blog.privacytools.io/delisting-wire/)
###########################
###### P2P Messaging ######
###########################
@ -724,7 +725,7 @@ categories:
url: https://github.com/Bitmessage/PyBitmessage
- name: RetroShare
url: https://retroshare.cc
#############################
###### Encrypted Email ######
#############################
@ -792,11 +793,11 @@ categories:
custom domains (starting at $3/month). Tuta
[does not use OpenPGP](https://tuta.com/blog/posts/differences-email-encryption/)
like other encrypted mail providers, instead they use a standardized, hybrid method
consisting of symmetrical and asymmetrical algorithms (with AES256, and RSA 2048
consisting of symmetrical and asymmetrical algorithms (with AES256, and RSA 2048
or ECC (x25519) and Kyber-1024). This causes compatibility issues when communicating with contacts
using PGP. But it does allow them to encrypt much more of the header data (body,
attachments, subject lines, and sender names etc) which PGP mail providers cannot do. The recent upgrades
to Tuta's encryption algorithm makes data stored and sent with their service safe against attacks
to Tuta's encryption algorithm makes data stored and sent with their service safe against attacks
posed by quantum computers.
- name: Mailfence
url: https://mailfence.com?src=digitald
@ -1104,7 +1105,7 @@ categories:
url: https://www.smspool.net
icon: https://i.ibb.co/2t4MBFj/apple-touch-icon.png
description: |
Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers.
Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers.
We support over 50+ countries and support over 300+ services.
androidApp: com.smspool.app
iosApp: https://apps.apple.com/app/smspool/id6474617801
@ -1452,7 +1453,7 @@ categories:
Displays a country flag depicting the location of the current website's server, which can be useful to know at a glance.
Click icon for more tools such as site safety checks, whois, validation etc **Download**:
[Firefox](https://addons.mozilla.org/en-US/firefox/addon/flagfox/)
- name: Lightbeam
url: https://mozilla.github.io/lightbeam/
github: mozilla/lightbeam-we
@ -1523,7 +1524,7 @@ categories:
url: https://addons.mozilla.org/en-US/firefox/addon/crxviewer
description: >
A handy extension for viewing the source code of another browser extension,
which is a useful tool for verifying the code does what it says
which is a useful tool for verifying the code does what it says
wordOfWarning: |
- Having many extensions installed raises entropy, causing your fingerprint to be more unique, hence making tracking easier.
- Much of the functionality of the above addons can be applied without installing anything, by configuring browser settings yourself. For Firefox this is done in the user.js
@ -2043,7 +2044,7 @@ categories:
See [Streisand](https://github.com/StreisandEffect/streisand), to learn more, and get started with running a VPN.
[Digital Ocean](https://m.do.co/c/3838338e7f79) provides flexible,
secure and easy Linux VMs, (from $0.007/hour or $5/month),
Here is a [1-click install script](http://dovpn.carlfriess.com/)for
Here is a [1-click install script](http://dovpn.carlfriess.com/)for
on [Digital Ocean](https://m.do.co/c/3838338e7f79), by Carl Friess.
Recently distributed self-hosted solutions for running your own VPNs have
@ -2203,8 +2204,8 @@ categories:
Tor, I2P and Freenet are all anonymity networks - but they work very differently and each is good for specific purposes.
So a good and viable solution would be to use all of them, for different tasks.
*You can read more about how I2P compares to Tor, [here](https://blokt.com/guides/what-is-i2p-vs-tor-browser)*
#####################
###### Proxies ######
#####################
@ -2323,7 +2324,7 @@ categories:
Mullvads public DNS with QNAME minimization and basic ad blocking.
It has been audited by the security experts at Assured.
You can use this privacy-enhancing service even if you don't use Mullvad.
#########################
###### DNS Clients ######
#########################
@ -2643,7 +2644,7 @@ categories:
Some VPNs have ad-tracking blocking features, such as
[TrackStop with PerfectPrivacy](https://www.perfect-privacy.com/en/features/trackstop?a_aid=securitychecklist).
[Private Internet Access](https://www.privateinternetaccess.com/),
[CyberGhost](https://www.cyberghostvpn.com/),
[PureVPN](https://www.anrdoezrs.net/click-9242873-13842740),
@ -2882,7 +2883,7 @@ categories:
[5 eyes](https://en.wikipedia.org/wiki/Five_Eyes) (Australia, Canada, New Zealand, US and UK)
and [other international cooperatives](https://en.wikipedia.org/wiki/Five_Eyes#Other_international_cooperatives)
who have legal right to view your data.
###############################
###### Domain Registrars ######
###############################
@ -2920,7 +2921,7 @@ categories:
followWith: Web
description: |
Free DNS hosting provider designed with security in mind, and running
on purely open source software. deSEC is backed and funded by SSE.
on purely open source software. deSEC is backed and funded by SSE.
###########################
@ -3007,7 +3008,7 @@ categories:
All notes are saved individually as .md files, making them easy to manage.
No mobile app, built-in cloud-sync, encryption or web UI. But due to the structure of the files,
it is easy to use your own cloud sync provider, and additional features are provided through extensions.
- name: Joplin
url: https://joplinapp.org
github: laurent22/joplin
@ -3081,7 +3082,7 @@ categories:
notableMentions: |
If you are already tied into Evernote, One Note etc, then [SafeRoom](https://www.getsaferoom.com)
is a utility that encrypts your entire notebook, before it is uploaded to the cloud.
is a utility that encrypts your entire notebook, before it is uploaded to the cloud.
[Org Mode](https://orgmode.org) is a mode for [GNU Emacs](https://www.gnu.org/software/emacs/)
dedicated to working with the Org markup format. Org can be thought of as
@ -3331,7 +3332,7 @@ categories:
using [Web Torrent](https://webtorrent.io).
For specifically transferring images, [Up1](https://github.com/Upload/Up1) is a good self-hosted option, with client-side encryption.
Finally [PsiTransfer](https://github.com/psi-4ward/psitransfer) is a feature-rich, self-hosted file drop, using streams.
@ -3380,7 +3381,7 @@ categories:
description: |
Simple bookmark manager written in Go, intended to be a clone of Pocket, it has both a simple and clean web
interface as well as a CLI. Shiori has easy import/ export, is portable and has webpage archiving features.
notableMentions: |
[Ymarks](https://ymarks.org) is a C-based self-hosted bookmark synchronization
server and [Chrome](https://chrome.google.com/webstore/detail/ymarks/gefignhaigoigfjfbjjobmegihhaacfi) extension.
@ -3438,7 +3439,7 @@ categories:
notableMentions: |
[Apache OpenMeetings](https://openmeetings.apache.org) provides self-hosted
video-conferencing, chat rooms, file server and tools for meetings.
[together.brave.com](https://together.brave.com) is Brave's Jitsi Fork.
For remote learning, [BigBlueButton](https://bigbluebutton.org) is self-hosted conference call software,
@ -3495,10 +3496,10 @@ categories:
it is not open source, and although there is a free version, a license
is required to access all features. VMWare performs very well when running
on a server, with hundreds of hosts and users.
For Mac users, [Parallels](https://www.parallels.com/uk/) is a popular
option which performs really well, but again is not open source.
For Windows users, there's
[Hyper-V](https://docs.microsoft.com/en-us/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v),
which is a native Windows product, developed by Microsoft.
@ -3567,7 +3568,7 @@ categories:
description: |
iOS app for encrypting/ decrypting text.
Has native keyboard integration, keychain support and app integrations which makes it quick to use in any app.
- name: FlowCrypt
url: https://flowcrypt.com
@ -3646,10 +3647,10 @@ categories:
Alternatively, with [ImageMagic](https://imagemagick.org) installed, just run
`convert -strip path/to/image.png` to remove all metadata.
If you have [GIMP](https://www.gimp.org) installed, then just go to `File --> Export As --> Export --> Advanced Options --> Uncheck the "Save EXIF data" option`.
Often you need to perform meta data removal programmatically, as part of a script or automation process.
Often you need to perform meta data removal programmatically, as part of a script or automation process.
- GoLang: [go-exif](https://github.com/dsoprea/go-exif) by @dsoprea
- JS: [exifr](https://github.com/MikeKovarik/exifr) by @MikeKovarik
- Python: [Piexif](https://github.com/hMatoba/Piexif) by @hMatoba
@ -3665,7 +3666,7 @@ categories:
[not remove it](https://uk.norton.com/internetsecurity-privacy-is-my-personal-data-really-gone-when-its-deleted-from-a-device.html)
from the disk, and recovering deleted files is a
[simple task](https://www.lifewire.com/how-to-recover-deleted-files-2622870).
Therefore, to protect your privacy, you should erase/ overwrite data from
the disk, before you destroy, sell or give away a hard drive.
services:
@ -3754,12 +3755,12 @@ categories:
Or [badblocks](https://linux.die.net/man/8/badblocks) which is intended to search for all bad blocks,
but can also be used to write zeros to a disk,
by running `sudo badblocks -wsv /dev/sdd`.
An effective method of erasing an SSD, it to use [hdparm](https://en.wikipedia.org/wiki/Hdparm)
to issue a [secure erase](https://en.wikipedia.org/wiki/Parallel_ATA#HDD_passwords_and_security)
command, to your target storage device,
for this, see step-by-step instructions via: [wiki.kernel.org](https://ata.wiki.kernel.org/index.php/ATA_Secure_Erase).
Finally, [srm](https://www.systutorials.com/docs/linux/man/1-srm/) can be
use to securely remove files or directories, just run `srm -zsv /path/to/file`
for a single pass over.
@ -3777,7 +3778,7 @@ categories:
If you are an Android user, your device has Google built-in at its core.
[Google tracks you](https://digitalcontentnext.org/blog/2018/08/21/google-data-collection-research/),
collecting a wealth of information, and logging your every move.
A [custom ROM](https://en.wikipedia.org/wiki/List_of_custom_Android_distributions),
is an open source, usually Google-free mobile OS that can be flashed to your device.
services:
@ -3786,7 +3787,7 @@ categories:
github: GrapheneOS/hardened_malloc
icon: https://grapheneos.org/apple-touch-icon.png
description: |
GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay.
GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay.
GrapheneOS is a young project, and currently only supports Pixel devices, partially due to their strong hardware security.
- name: CalyxOS
@ -3795,8 +3796,8 @@ categories:
github: CalyxOS/calyxos
tosdrId: 2558
description: |
CalyxOS is an free and open source Android mobile operating system that puts privacy and security into the hands of everyday users.
Plus, proactive security recommendations and automatic updates take the guesswork out of keeping your personal data personal. Also currently
CalyxOS is an free and open source Android mobile operating system that puts privacy and security into the hands of everyday users.
Plus, proactive security recommendations and automatic updates take the guesswork out of keeping your personal data personal. Also currently
only supports Pixel devices and Xiaomi Mi A2 with Fairphone 4, OnePlus 8T, OnePlus 9 test builds available. Developed by the Calyx Foundation.
- name: DivestOS
@ -3805,8 +3806,8 @@ categories:
github: Divested-Mobile/DivestOS-Build
tosdrId: 2550
description: |
DivestOS is a vastly diverged unofficial more secure and private soft fork of LineageOS. DivestOS primary goal is prolonging the life-span of
discontinued devices, enhancing user privacy, and providing a modest increase of security where/when possible. Project is developed and maintained
DivestOS is a vastly diverged unofficial more secure and private soft fork of LineageOS. DivestOS primary goal is prolonging the life-span of
discontinued devices, enhancing user privacy, and providing a modest increase of security where/when possible. Project is developed and maintained
solely by Tad (SkewedZeppelin) since 2014.
- name: LineageOS
@ -3815,17 +3816,17 @@ categories:
github: LineageOS/android
tosdrId: 7188
description: |
A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained,
A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained,
supports a wide range of devices, and comes bundled with Privacy Guard.
notableMentions: |
[Replicant OS](https://www.replicant.us/) is a fully-featured distro,
with an emphasis on freedom, privacy and security.
[OmniRom](https://www.omnirom.org/),
[Resurrection Remix OS](https://resurrectionremix.com/)
and [Paranoid Android](http://paranoidandroid.co/) are also popular options.
Alternatively, [Ubuntu Touch](https://ubports.com/) is a Linux (Ubuntu)- based OS.
It is secure by design and runs on almost any device, - but it does fall short when it comes to the app store.
@ -3992,7 +3993,7 @@ categories:
some technical knowledge to get started with, but once setup should perform
just as any other Windows 10 system. Note that you should only download the
LTSC ISO from the Microsoft's
[official page](https://www.microsoft.com/en-in/evalcenter/evaluate-windows-10-enterprise)
[official page](https://www.microsoft.com/en-in/evalcenter/evaluate-windows-10-enterprise)
#### Improve the Security and Privacy of your current OS
@ -4221,8 +4222,8 @@ categories:
and check reviews/ forums.
Create a system restore point, before making any significant changes to
your OS (such as disabling core features).
From a security and privacy perspective, Linux may be a better option.
From a security and privacy perspective, Linux may be a better option.
notableMentions: |
See also these lists:
@ -4295,7 +4296,7 @@ categories:
that offer free solutions, even if you pay for the premium package.
This includes (but not limited to) Avast, AVG, McAfee and Kasperky.
For AV to be effective, it needs intermate access to all areas of your PC,
so it is important to go with a trusted vendor, and monitor its activity closely.
so it is important to go with a trusted vendor, and monitor its activity closely.
- name: Development
@ -4350,7 +4351,7 @@ categories:
[reputation](https://srlabs.de/bites/smart-spies) when it comes to protecting
consumers privacy, there have been
[many recent breaches](https://www.theverge.com/2019/10/21/20924886/alexa-google-home-security-vulnerability-srlabs-phishing-eavesdropping).
For that reason it is recommended not to have these devices in your house.
The following are open source AI voice assistants, that aim to provide a
human voice interface while also protecting your privacy and security
@ -4386,7 +4387,7 @@ categories:
[LinTO](https://linto.ai), [Jovo](https://www.jovo.tech) and [Snips](https://snips.ai)
are private-by-design voice assistant frameworks that can be built on by developers,
or used by enterprises.
[Jasper](https://jasperproject.github.io),
[Stephanie](https://github.com/SlapBot/stephanie-va) and
[Hey Athena](https://github.com/rcbyron/hey-athena-client) are Python-based voice assistant, but neither is under active development anymore.
@ -4403,7 +4404,7 @@ categories:
description: |
An open source privacy-respecting Home Assistant, compatible with a wide range of
devices including Raspberry Pi, desktop computers, or NAS systems.
Actively developed, with good french community and various integrations
Actively developed, with good french community and various integrations
(Zigbee, Philips, Camera, Tuya, MQTT, Telegram, ...).
url: https://gladysassistant.com/
github: gladysassistant/gladys
@ -4446,7 +4447,7 @@ categories:
notableMentions: |
Other privacy-focused cryptocurrencies include:
[PIVX](https://pivx.org),
[Verge](https://vergecurrency.com), and [Piratechain](https://pirate.black/).
[Verge](https://vergecurrency.com), and [Piratechain](https://pirate.black/).
wordOfWarning: |
Not all cryptocurrencies are anonymous, and without using a privacy-focused coin,
a record of your transaction will live on a publicly available distributed ledger, forever.
@ -4583,7 +4584,7 @@ categories:
[BitMex](https://www.bitmex.com/) has more advanced trading features,
but ID verification is required for higher value trades involving Fiat currency.
For buying and selling alt-coins, [Binance](https://www.binance.com/en/register?ref=X2BHKID1) has a wide range of currencies,
~and ID verification is not needed for small-value trades~ but ID verification is required in most countries.
@ -4720,7 +4721,7 @@ categories:
Over the past decade, social networks have revolutionized the way we communicate
and bought the world closer together - but it came at the
[cost of our privacy](https://en.wikipedia.org/wiki/Privacy_concerns_with_social_networking_services).
Social networks are built on the principle of sharing - but you, the user
should be able to choose with whom you share what, and that is what the
following sites aim to do.
@ -4751,12 +4752,12 @@ categories:
- name: nostr
description: |
nostr stands for Notes and other stuff transmitted by relays.
It is an open protocol, not merely a platform.
This distinction enables truly censorship-resistant and global value-for-value publishing on the web.
With the power to replace data-greedy applications like Twitter and Instagram,
nostr offers a promising alternative for users seeking a more private and secure online experience
without algorithmic manipulations. ".... I feel like Im looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr.
nostr stands for Notes and other stuff transmitted by relays.
It is an open protocol, not merely a platform.
This distinction enables truly censorship-resistant and global value-for-value publishing on the web.
With the power to replace data-greedy applications like Twitter and Instagram,
nostr offers a promising alternative for users seeking a more private and secure online experience
without algorithmic manipulations. ".... I feel like Im looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr.
url: https://github.com/nostr-protocol/nostr
github: https://github.com/nostr-protocol
@ -4875,7 +4876,7 @@ categories:
then [Listed.to](https://listed.to) is a public blogging platform with
strong privacy features.
It lets you publish posts directly through the Standard Notes app or web interface.
Other minimalistic platforms include [Notepin.co](https://notepin.co) and [Pen.io](http://pen.io).
Want to write a simple text post and promote it yourself?
@ -4957,7 +4958,7 @@ categories:
url: https://newpipe.schabi.org
description: An open source, privacy-respecting YouTube client for Android.
# tosdrId: 2568
- name: FreeTube
- name: FreeTube
url: https://freetubeapp.io
description: |
An open source YouTube client for Windows, MacOS and Linux, providing
@ -5087,7 +5088,7 @@ categories:
- name: Video Editors
alternativeTo: ['adobe premiere pro', 'final cut pro', 'davinci resolve', 'imovie', 'sony vegas pro']
services:
services:
- name: Shotcut
followWith: Windows, Mac OS, Linux
description: |
@ -5152,7 +5153,7 @@ categories:
github: NatronGitHub/Natron
icon: https://natrongithub.github.io/img/Natron_icon.svg
openSource: true
- name: Audio Editors & Recorders
alternativeTo: ['adobe audition', 'garageband', 'fl studio', 'ableton live']
services:
@ -5163,16 +5164,16 @@ categories:
great free alternative to Adobe Audition.
Features recording from real and virtual devices,
import/export to a wide range of formats, high-quality processing
advanced multi-track editing, noise reduction, pitch correction,
advanced multi-track editing, noise reduction, pitch correction,
audio restoration and much more.
It's easily extendable via community plugins, and
It's easily extendable via community plugins, and
also supports cusotm macros and many scripting options
url: https://www.audacityteam.org
github: audacity/audacity
icon: https://www.audacityteam.org/_astro/Audacity_Logo.63b57726.svg
openSource: true
tosdrId: 4516
- name: Casting & Streaming
alternativeTo: ['xsplit', 'streamlabs obs', 'twitch studio', 'wirecast']
services:
@ -5189,11 +5190,11 @@ categories:
icon: https://obsproject.com/assets/images/new_icon_small-r.png
openSource: true
tosdrId: 4227
- name: Screenshot Tools
alternativeTo: ['snagit', 'greenshot', 'lightshot', 'gyazo', 'sharex']
services: []
- name: 3D Graphics
alternativeTo: ['blender', 'autodesk maya', 'cinema 4d', '3ds max', 'sketchup']
services:
@ -5219,7 +5220,7 @@ categories:
url: https://wings3d.com
github: dgud/wings
icon: https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Wings3d.png/120px-Wings3d.png
- name: Animation
alternativeTo: ['adobe after effects', 'animate cc', 'toon boom harmony', 'moho (anime studio)', 'pencil2d']
services:

View file

@ -0,0 +1,275 @@
"""Validates data quality for added/modified services using the diff JSON."""
import json
import os
import sys
import yaml
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_PATH = "/tmp/pr-diff.json"
FINDINGS_PATH = "/tmp/findings-data.json"
REQUIRED_FIELDS = ("name", "description", "url", "icon")
CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md"
SCHEMA_MSG = (
"Some of the schema checks have failed. Please check that your addition"
" contains all the required fields, with acceptable values, nothing"
" additional and that it is following valid YAML syntax"
)
MULTIPLE_MSG = "Please make just one addition per pull request"
MISSING_TPL = (
"Did you include all required fields? Looks like {fields} is missing or"
f" invalid. Please see the [required fields]({CONTRIBUTING}#service-fields)"
" for available fields."
)
POSITION_MSG = (
"New entries must be added to the end of the section, unless otherwise requested"
)
OPENSOURCE_MSG = (
"You indicated this app/service is not open source. This will likely make"
" it ineligible for listing on Awesome Privacy in accordance with our"
f" [Requirements]({CONTRIBUTING}#requirements)."
" Please ensure that this is justified in your PR body."
)
DUPLICATE_NAME_MSG = (
"A service named `{name}` already exists (in {location})."
" If this is a different service, please clarify in your PR description"
)
DUPLICATE_URL_MSG = (
"The URL `{url}` is already associated with `{existing}`."
" Please check this isn't a duplicate submission"
)
DESC_LENGTH_MSG = (
"Description length ({length} chars) is outside the recommended 50\u2013250"
f" character range. Please see our [Contributing Guidelines]({CONTRIBUTING}#description)"
)
OPENSOURCE_GITHUB_MSG = (
"You marked this service as open source but didn't include a `github` field."
" Please add the repository link"
)
def load_json(path):
"""Load JSON from a file, returning None on any error."""
try:
with open(path) as f:
return json.load(f)
except Exception:
return None
def load_yaml_data(path):
"""Load YAML from a file, returning None on any error."""
try:
with open(path) as f:
return yaml.safe_load(f)
except Exception:
return None
def find_section_services(head, category, section):
"""Return the services list for a category/section pair, or None."""
for cat in head.get("categories", []):
if cat.get("name") == category:
for sec in cat.get("sections", []):
if sec.get("name") == section:
return sec.get("services", [])
return None
def find_service_fields(head, category, section, service_name):
"""Look up a service's fields in the head YAML."""
services = find_section_services(head, category, section)
if services:
for svc in services:
if svc.get("name") == service_name:
return svc
return None
def check_required_fields(diff, head):
"""Return a finding if any added/modified service is missing required fields."""
missing = set()
for svc in diff.get("services", {}).get("added", []):
fields = svc.get("fields", {})
for f in REQUIRED_FIELDS:
if fields.get(f) is None:
missing.add(f)
for svc in diff.get("services", {}).get("modified", []):
if not head:
continue
changed = svc.get("changed_fields", [])
fields = find_service_fields(
head, svc["category"], svc["section"], svc["service"]
)
if fields:
for f in REQUIRED_FIELDS:
if f in changed and fields.get(f) is None:
missing.add(f)
if missing:
names = ", ".join(f"`{f}`" for f in sorted(missing))
return MISSING_TPL.format(fields=names)
return None
def check_position(diff, head):
"""Return a finding if a newly added service is not at the end of its section."""
if not head:
return None
for svc in diff.get("services", {}).get("added", []):
services = find_section_services(head, svc["category"], svc["section"])
if services and services[-1].get("name") != svc["service"]:
return POSITION_MSG
return None
def check_open_source(diff):
"""Return a finding if an added service has openSource missing or not true."""
for svc in diff.get("services", {}).get("added", []):
fields = svc.get("fields", {})
if fields.get("openSource") is not True:
return OPENSOURCE_MSG
return None
def check_single_entry(diff):
"""Return a finding if the diff adds multiple new services or sections."""
services = diff.get("services", {})
added_count = len(services.get("added", []))
if added_count > 1:
return MULTIPLE_MSG
if added_count == 0:
added_sections = [s for s in diff.get("sections", [])
if s.get("change_type") == "added_section"]
if len(added_sections) > 1:
return MULTIPLE_MSG
return None
def build_name_index(head):
"""Build {lowercase_name: "category > section"} from all services."""
index = {}
if not head:
return index
for cat in head.get("categories", []):
cn = cat.get("name", "")
for sec in cat.get("sections", []):
sn = sec.get("name", "")
for svc in sec.get("services", []):
name = svc.get("name", "").lower().strip()
if name:
index[name] = f"{cn} > {sn}"
return index
def build_url_index(head):
"""Build {url: service_name} from all services, skipping empty URLs."""
index = {}
if not head:
return index
for cat in head.get("categories", []):
for sec in cat.get("sections", []):
for svc in sec.get("services", []):
url = svc.get("url", "")
if url:
index[url] = svc.get("name", "")
return index
def check_duplicate_name(diff, name_index):
"""Return a finding if an added service name already exists in the YAML."""
for svc in diff.get("services", {}).get("added", []):
name = svc.get("fields", {}).get("name", "").lower().strip()
if name and name in name_index:
return DUPLICATE_NAME_MSG.format(
name=svc["fields"]["name"], location=name_index[name],
)
return None
def check_duplicate_url(diff, url_index):
"""Return a finding if an added service URL already exists in the YAML."""
for svc in diff.get("services", {}).get("added", []):
url = svc.get("fields", {}).get("url", "")
if url and url in url_index:
return DUPLICATE_URL_MSG.format(url=url, existing=url_index[url])
return None
def check_description_length(diff):
"""Return a finding if an added service description is outside 50-250 chars."""
for svc in diff.get("services", {}).get("added", []):
desc = svc.get("fields", {}).get("description", "")
length = len(desc)
if length < 50 or length > 250:
return DESC_LENGTH_MSG.format(length=length)
return None
def check_opensource_github(diff):
"""Return a finding if an added service is open source but has no github field."""
for svc in diff.get("services", {}).get("added", []):
fields = svc.get("fields", {})
if fields.get("openSource") is True and not fields.get("github"):
return OPENSOURCE_GITHUB_MSG
return None
def main():
findings = []
try:
if os.environ.get("SCHEMA_OUTCOME") == "failure":
findings.append(SCHEMA_MSG)
diff = load_json(DIFF_PATH)
head = load_yaml_data(DATA_PATH)
if diff:
finding = check_single_entry(diff)
if finding:
findings.append(finding)
finding = check_required_fields(diff, head)
if finding:
findings.append(finding)
finding = check_position(diff, head)
if finding:
findings.append(finding)
finding = check_open_source(diff)
if finding:
findings.append(finding)
name_index = build_name_index(head)
url_index = build_url_index(head)
finding = check_duplicate_name(diff, name_index)
if finding:
findings.append(finding)
finding = check_duplicate_url(diff, url_index)
if finding:
findings.append(finding)
finding = check_description_length(diff)
if finding:
findings.append(finding)
finding = check_opensource_github(diff)
if finding:
findings.append(finding)
except Exception:
pass
with open(FINDINGS_PATH, "w") as f:
json.dump(findings, f)
sys.exit(0)
if __name__ == "__main__":
main()

139
lib/checks/check-pr-meta.py Normal file
View file

@ -0,0 +1,139 @@
"""Checks PR metadata: title format, draft status, template completeness, and checkboxes."""
import json
import os
import re
import sys
FINDINGS_PATH = "/tmp/findings-compliance.json"
BAD_TITLES = {"update readme.md", "update awesome-privacy.yml"}
TITLE_MSG = (
"The pull request title does not follow the format defined in our guidelines."
" Please rename it to `[Add/Remove/Update] [software name] in [software section]`"
)
DRAFT_MSG = (
"Please avoid opening WIP pull requests."
" Your PR should be 100% ready and complete before submitting"
)
TEMPLATE_MSG = (
"Please fill in pull request template in full."
" You can find a copy of this"
" [here](https://github.com/Lissy93/awesome-privacy/blob/main/.github/PULL_REQUEST_TEMPLATE.md)"
)
CHECKBOX_MSG = (
"Ensure you have completed the checklist (put a tick the checkboxes with `[x]`),"
" to confirm that you've read the contributing guidelines, checked your submission,"
" indicated your affiliation and agree to follow our CoC"
)
README_MSG = (
"Do not edit the README directly. This file is auto-generated from the"
" content in `awesome-privacy.yml`, and so your changes will be overridden!"
" Instead, only modify the YAML file, and be sure to follow our Contributing Guidelines."
)
def extract_section(body, header):
"""Extract content between a ### header and the next delimiter."""
pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)"
match = re.search(pattern, body, re.DOTALL)
return match.group(1) if match else None
def strip_html_comments(text):
"""Remove HTML comments from text."""
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL).strip()
def check_title(title):
"""Return a finding if the PR title matches a known-bad pattern."""
if title and title.strip().lower() in BAD_TITLES:
return TITLE_MSG
return None
def check_draft(draft_str):
"""Return a finding if the PR is in draft state."""
if str(draft_str).lower() == "true":
return DRAFT_MSG
return None
def check_template(body):
"""Return a finding if required template sections are missing or empty."""
for header in ("Type", "Changes", "Checklist"):
content = extract_section(body, header)
if content is None or not strip_html_comments(content):
return TEMPLATE_MSG
return None
def check_checkboxes(body):
"""Return a finding if any checklist checkboxes are unchecked."""
section = extract_section(body, "Checklist")
if section is None:
return None
checked = re.findall(r"- \[x\]", section, re.IGNORECASE)
unchecked = re.findall(r"- \[ \]", section)
if not checked and not unchecked:
return None
if unchecked:
return CHECKBOX_MSG
return None
def check_readme(readme_failed):
"""Return a finding if the README check reported a failure."""
if readme_failed == "true":
return README_MSG
return None
def write_findings(findings):
"""Write the findings list to the output JSON file."""
with open(FINDINGS_PATH, "w") as f:
json.dump(findings, f)
def main():
findings = []
critical = False
try:
title = os.environ.get("PR_TITLE", "")
body = os.environ.get("PR_BODY", "")
draft = os.environ.get("PR_DRAFT", "false")
readme_failed = os.environ.get("README_FAILED", "false")
finding = check_title(title)
if finding:
findings.append(finding)
finding = check_draft(draft)
if finding:
findings.append(finding)
if not body or not body.strip():
findings.append(TEMPLATE_MSG)
critical = True
else:
finding = check_template(body)
if finding:
findings.append(finding)
critical = True
finding = check_checkboxes(body)
if finding:
findings.append(finding)
finding = check_readme(readme_failed)
if finding:
findings.append(finding)
except Exception:
pass
write_findings(findings)
sys.exit(1 if critical else 0)
if __name__ == "__main__":
main()

227
lib/checks/check-project.py Normal file
View file

@ -0,0 +1,227 @@
"""Checks project health: URL reachability, GitHub repo stars, activity, and author match."""
import json
import os
import sys
from datetime import datetime, timezone
import requests
import yaml
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_PATH = "/tmp/pr-diff.json"
FINDINGS_PATH = "/tmp/findings-project.json"
TIMEOUT = 10
USER_AGENT = "awesome-privacy-ci/1.0"
MIN_STARS = 100
INACTIVE_DAYS = 90
LINK_MSG = (
"Our automated checks were unable to verify the link(s) you included"
" were reachable, so please double check this yourself"
)
AUTHOR_MSG = (
"Looks like you are the author of this package. Please ensure that you"
" have clearly disclosed this in your PR body for transparency"
)
STARS_MSG = (
"It looks like your submission is adding a quite small project."
" In some circumstances we may ask you to resubmit this once the project"
" is more mature and has a proven track record of good practices and maintenance."
)
ACTIVITY_MSG = (
"Please confirm that the project you are adding is actively maintained,"
" as it looks to not have had any recent updates in the past 3 months."
)
def load_diff(path):
"""Load the diff JSON, returning None on any error."""
try:
with open(path) as f:
return json.load(f)
except Exception:
return None
def check_url(url):
"""Return True if the URL is reachable, True on any error (no false positives)."""
try:
resp = requests.head(
url, timeout=TIMEOUT, allow_redirects=True,
headers={"User-Agent": USER_AGENT},
)
if resp.status_code >= 400:
resp = requests.get(
url, timeout=TIMEOUT, allow_redirects=True,
headers={"User-Agent": USER_AGENT}, stream=True,
)
resp.close()
return resp.status_code < 400
except Exception:
return True
def parse_github_field(value):
"""Parse a github field into (owner, repo), or (None, None) on failure."""
if not value:
return None, None
if value.startswith("https://github.com/"):
parts = value.removeprefix("https://github.com/").strip("/").split("/")
if len(parts) >= 2:
return parts[0], parts[1]
return None, None
if "/" in value:
parts = value.split("/")
if len(parts) == 2:
return parts[0], parts[1]
return None, None
def fetch_repo(owner, repo, token):
"""Fetch GitHub repo metadata, returning None on any error."""
try:
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
if token:
headers["Authorization"] = f"token {token}"
resp = requests.get(
f"https://api.github.com/repos/{owner}/{repo}",
headers=headers, timeout=TIMEOUT,
)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
def load_yaml_data():
"""Load the head YAML, returning None on any error."""
try:
with open(DATA_PATH) as f:
return yaml.safe_load(f)
except Exception:
return None
def find_service_in_head(head, category, section, service_name):
"""Look up a service in the head YAML by path."""
if not head:
return None
for cat in head.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
return None
def get_services(diff, key):
"""Safely extract a service list from the diff."""
return diff.get("services", {}).get(key, [])
def check_links(diff, head):
"""Return LINK_MSG if any service URL or icon URL is unreachable."""
for svc in get_services(diff, "added"):
fields = svc.get("fields", {})
url = fields.get("url")
if url and not check_url(url):
return LINK_MSG
icon = fields.get("icon")
if icon and not check_url(icon):
return LINK_MSG
for svc in get_services(diff, "modified"):
changed = svc.get("changed_fields", [])
if "url" not in changed and "icon" not in changed:
continue
head_svc = find_service_in_head(
head, svc["category"], svc["section"], svc["service"]
)
if head_svc:
if "url" in changed:
url = head_svc.get("url")
if url and not check_url(url):
return LINK_MSG
if "icon" in changed:
icon = head_svc.get("icon")
if icon and not check_url(icon):
return LINK_MSG
return None
def check_repo_signals(diff, pr_user, token):
"""Check GitHub repo author match, stars, and activity for added services."""
findings = []
if not token:
return findings
cache = {}
for svc in get_services(diff, "added"):
gh = svc.get("fields", {}).get("github")
owner, repo = parse_github_field(gh)
if not owner:
continue
cache_key = f"{owner}/{repo}"
if cache_key not in cache:
cache[cache_key] = fetch_repo(owner, repo, token)
data = cache[cache_key]
if not data:
continue
repo_owner = data.get("owner", {})
if (
pr_user
and repo_owner.get("type") == "User"
and repo_owner.get("login", "").lower() == pr_user.lower()
and AUTHOR_MSG not in findings
):
findings.append(AUTHOR_MSG)
stars = data.get("stargazers_count", 0)
if stars < MIN_STARS and STARS_MSG not in findings:
findings.append(STARS_MSG)
pushed = data.get("pushed_at")
if pushed and ACTIVITY_MSG not in findings:
try:
pushed_dt = datetime.fromisoformat(pushed.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
if (now - pushed_dt).days > INACTIVE_DAYS:
findings.append(ACTIVITY_MSG)
except Exception:
pass
return findings
def main():
findings = []
try:
diff = load_diff(DIFF_PATH)
if not diff:
with open(FINDINGS_PATH, "w") as f:
json.dump(findings, f)
sys.exit(0)
head = load_yaml_data()
finding = check_links(diff, head)
if finding:
findings.append(finding)
pr_user = os.environ.get("PR_USER", "")
token = os.environ.get("GITHUB_TOKEN", "")
findings.extend(check_repo_signals(diff, pr_user, token))
except Exception:
pass
with open(FINDINGS_PATH, "w") as f:
json.dump(findings, f)
sys.exit(0)
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,207 @@
"""Analyzes the diff between base and head versions of awesome-privacy.yml.
Enforces the single-entry rule and outputs a JSON diff to /tmp/pr-diff.json.
"""
import argparse
import json
import os
import subprocess
import sys
import yaml
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_PASS = 0
EXIT_RULE_VIOLATION = 1
EXIT_RUNTIME_ERROR = 2
_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)
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:
print(yellow("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) as f:
return yaml.safe_load(f)
except (FileNotFoundError, yaml.YAMLError) as e:
print(red(f"Failed to load head YAML: {e}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
def build_index(data, depth):
"""Build a keyed index at the given depth (3=services, 2=sections, 1=categories)."""
index = {}
for cat in data.get("categories", []):
cn = cat.get("name", "")
if depth == 1:
index[cn] = {k: v for k, v in cat.items() if k != "sections"}
continue
for sec in cat.get("sections", []):
sn = sec.get("name", "")
if depth == 2:
index[(cn, sn)] = {k: v for k, v in sec.items() if k != "services"}
continue
for svc in sec.get("services", []):
index[(cn, sn, svc.get("name", ""))] = svc
return index
def diff_index(base_idx, head_idx):
"""Return (added_keys, removed_keys, modified_keys_with_changed_fields)."""
base_keys, head_keys = set(base_idx), set(head_idx)
added = sorted(head_keys - base_keys)
removed = sorted(base_keys - head_keys)
modified = []
for key in sorted(base_keys & head_keys):
if base_idx[key] != head_idx[key]:
all_fields = set(base_idx[key]) | set(head_idx[key])
changed = sorted(f for f in all_fields if base_idx[key].get(f) != head_idx[key].get(f))
modified.append((key, changed))
return added, removed, modified
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 fmt_path(key):
"""Format a tuple key as a readable path."""
return "".join(key) if isinstance(key, tuple) else key
def write_step_summary(diff_result):
"""Write a bullet-point Markdown summary to $GITHUB_STEP_SUMMARY."""
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_file:
return
lines = ["## YAML Diff Analysis\n"]
bullets = []
for svc in diff_result["services"]["added"]:
bullets.append(f"- Added **{svc['service']}** in {svc['category']}{svc['section']}")
for svc in diff_result["services"]["removed"]:
bullets.append(f"- Removed **{svc['service']}** from {svc['category']}{svc['section']}")
for svc in diff_result["services"]["modified"]:
fields = ", ".join(f"`{f}`" for f in svc["changed_fields"])
bullets.append(f"- Modified {fields} in {svc['category']}{svc['section']}{svc['service']}")
for change in diff_result["sections"]:
ct = change["change_type"]
path = f"{change['category']}{change['section']}"
if ct == "added_section":
bullets.append(f"- Added section **{change['section']}** in {change['category']}")
elif ct == "removed_section":
bullets.append(f"- Removed section **{change['section']}** from {change['category']}")
else:
fields = ", ".join(f"`{f}`" for f in change.get("changed_fields", []))
bullets.append(f"- Modified section metadata ({fields}) in {path}")
for change in diff_result["categories"]:
if change["change_type"] == "added_category":
bullets.append(f"- Added category **{change['category']}**")
else:
bullets.append(f"- Removed category **{change['category']}**")
if bullets:
lines.extend(bullets)
else:
lines.append("No changes detected in `awesome-privacy.yml`.")
with open(summary_file, "a") as f:
f.write("\n".join(lines) + "\n")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base-ref", required=True)
args = parser.parse_args()
base = load_base_yaml(args.base_ref)
head = load_head_yaml()
svc_added, svc_removed, svc_modified = diff_index(
build_index(base, 3), build_index(head, 3),
)
sec_added, sec_removed, sec_modified = diff_index(
build_index(base, 2), build_index(head, 2),
)
cat_added, cat_removed, _ = diff_index(
build_index(base, 1), build_index(head, 1),
)
added = [{"category": k[0], "section": k[1], "service": k[2],
"fields": build_index(head, 3)[k]} for k in svc_added]
removed = [{"category": k[0], "section": k[1], "service": k[2]} for k in svc_removed]
modified = [{"category": k[0], "section": k[1], "service": k[2],
"changed_fields": cf} for k, cf in svc_modified]
sections = []
for k in sec_added:
sections.append({"category": k[0], "section": k[1], "change_type": "added_section"})
for k in sec_removed:
sections.append({"category": k[0], "section": k[1], "change_type": "removed_section"})
for k, cf in sec_modified:
sections.append({"category": k[0], "section": k[1],
"change_type": "modified_section_metadata", "changed_fields": cf})
categories = []
for k in cat_added:
categories.append({"category": k, "change_type": "added_category"})
for k in cat_removed:
categories.append({"category": k, "change_type": "removed_category"})
diff_result = {
"services": {"added": added, "removed": removed, "modified": modified},
"sections": sections,
"categories": categories,
}
with open(DIFF_OUTPUT_PATH, "w") as f:
json.dump(diff_result, f, indent=2)
write_github_output("has_service_changes", str(bool(added or removed or modified)).lower())
write_step_summary(diff_result)
added_count = len(added)
if added_count > 1:
print(red(f"Single-entry rule violation: {added_count} service additions found."), file=sys.stderr)
sys.exit(EXIT_RULE_VIOLATION)
added_sections = [s for s in sections if s["change_type"] == "added_section"]
if added_count == 0 and len(added_sections) > 1:
print(red(f"Single-entry rule violation: {len(added_sections)} section additions found."), file=sys.stderr)
sys.exit(EXIT_RULE_VIOLATION)
total = len(added) + len(removed) + len(modified)
print(green(f"Single-entry rule passed. {total} service "
f"({added_count} added), {len(sections)} section, "
f"{len(categories)} category change(s)."))
sys.exit(EXIT_PASS)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,46 @@
"""
Detects which files changed between the PR base and HEAD.
Sets GitHub Actions output: 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
write_github_output("yaml_changed", str(yaml_changed).lower())
print(f"yaml_changed={yaml_changed}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,97 @@
"""Aggregates findings from all check jobs into a formatted PR comment."""
import json
import os
import sys
ARTIFACTS_DIR = "/tmp/artifacts"
OUTPUT_DIR = "/tmp/pr-meta"
CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md"
COMMENT_TEMPLATE = """<!-- pr-check-bot -->
Hello @{user}
Thank you for contributing to Awesome Privacy! We will review your PR shortly. In the meantime, please ensure that your submission is inline with our guidelines in our [Contributing Requirements]({contributing}).
Looks like there could be some issues in your PR. Please double check that:
{findings}
> [!NOTE]
> I am a bot, and sometimes make mistakes in my suggestions. But a human will review your submission shortly!"""
def load_findings(filename):
"""Load a findings JSON array from the artifacts directory, or empty list on error."""
try:
with open(os.path.join(ARTIFACTS_DIR, filename)) as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception:
return []
def collect_findings():
"""Gather all findings in display order: compliance, data, project."""
all_findings = []
all_findings.extend(load_findings("findings-compliance.json"))
all_findings.extend(load_findings("findings-data.json"))
all_findings.extend(load_findings("findings-project.json"))
return all_findings
def format_comment(findings, user):
"""Build the markdown comment from findings."""
bullet_list = "\n".join(f"- {f}" for f in findings)
return COMMENT_TEMPLATE.format(
user=user, contributing=CONTRIBUTING, findings=bullet_list,
)
def write_step_summary(findings):
"""Write a summary to GITHUB_STEP_SUMMARY."""
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_file:
return
lines = ["## PR Check Summary\n"]
if findings:
lines.append(f"⚠️ Found {len(findings)} issue(s):\n")
for f in findings:
lines.append(f"- {f}")
else:
lines.append("✅ All checks passed.\n")
with open(summary_file, "a") as f:
f.write("\n".join(lines) + "\n")
def main():
try:
user = os.environ.get("PR_USER", "contributor")
pr_number = os.environ.get("PR_NUMBER", "")
run_id = os.environ.get("RUN_ID", "")
os.makedirs(OUTPUT_DIR, exist_ok=True)
if pr_number:
with open(os.path.join(OUTPUT_DIR, "number.txt"), "w") as f:
f.write(pr_number)
if run_id:
with open(os.path.join(OUTPUT_DIR, "run-id.txt"), "w") as f:
f.write(run_id)
findings = collect_findings()
write_step_summary(findings)
if findings:
comment = format_comment(findings, user)
with open(os.path.join(OUTPUT_DIR, "comment.md"), "w") as f:
f.write(comment)
except Exception:
pass
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -1,5 +1,3 @@
PyYAML==6.0.1
requests==2.31.0
jsonschema
pyyaml
termcolor
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", "nullable": true },
"icon": { "type": "string", "nullable": true },
"followWith": { "type": "string", "nullable": true },
"securityAudited": { "type": "boolean", "nullable": true },
"openSource": { "type": "boolean", "nullable": true },
"acceptsCrypto": { "type": "boolean", "nullable": true },
"tosdrId": { "type": "number", "nullable": true },
"iosApp": { "type": "string", "nullable": true },
"androidApp": { "type": "string", "nullable": true },
"discordInvite": { "type": "string", "nullable": true },
"subreddit": { "type": "string", "nullable": true }
"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": ["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", "nullable": true },
"intro": { "type": ["string", "null"], "minLength": 1 },
"notableMentions": {
"oneOf": [
{
@ -46,24 +72,29 @@
"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" }
],
"nullable": true
{ "type": "string", "minLength": 1 },
{ "type": "null" }
]
},
"furtherInfo": { "type": "string", "nullable": true },
"wordOfWarning": { "type": "string", "nullable": true },
"furtherInfo": { "type": ["string", "null"], "minLength": 1 },
"wordOfWarning": { "type": ["string", "null"], "minLength": 1 },
"alternativeTo": {
"type": "array",
"items": { "type": "string" },
"nullable": true
"oneOf": [
{
"type": "array",
"items": { "type": "string", "minLength": 1, "maxLength": 100 },
"minItems": 1
},
{ "type": "null" }
]
}
},
"required": ["name", "services"],

View file

@ -1,85 +1,112 @@
import json
import os
import sys
import logging
import yaml
from termcolor import colored
from jsonschema import Draft7Validator
# Configure Logging
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=LOG_LEVEL)
logger = logging.getLogger(__name__)
# Paths (relative to project root)
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
SCHEMA_PATH = os.path.join(PROJECT_ROOT, "lib/schema.json")
# Exit codes
EXIT_VALID = 0
EXIT_VALIDATION_ERRORS = 1
EXIT_RUNTIME_ERROR = 2
MAX_ERRORS = 20
# ANSI color helpers (disabled when NO_COLOR is set or stderr is not a TTY)
_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)
# Determine the project root based on the script's location
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
awesome_privacy_path = os.path.join(project_root, 'awesome-privacy.yml')
schema_path = os.path.join(project_root, 'lib/schema.json')
def resolve_path(data, path_parts):
"""Walk the data along path_parts, replacing indices with 'name' values."""
segments = []
current = data
for part in path_parts:
if isinstance(current, dict) and part in current:
current = current[part]
if isinstance(current, dict) and "name" in current:
segments.append(current["name"])
elif not isinstance(part, int):
pass # skip dict keys like 'categories', 'sections', 'services'
elif isinstance(current, list) and isinstance(part, int) and part < len(current):
current = current[part]
if isinstance(current, dict) and "name" in current:
segments.append(current["name"])
else:
segments.append(str(part))
else:
segments.append(str(part))
break
return " > ".join(segments) if segments else "(root)"
# Log method, accepts a message and optional log level
# and prints the output to the terminal in right color
def loggy(message: str, level: str = 'debug'):
if level == "info":
logger.info(colored(message, 'blue'))
elif level == "warning":
logger.warning(colored(message, 'yellow'))
elif level == "error":
logger.error(colored(message, 'red'))
elif level == "success":
logger.info(colored(message, 'green'))
elif level == "debug":
logger.debug(colored(message, 'grey'))
# Loads a given YAML file and returns the data
def load_yaml(yaml_path: str):
loggy(f"Loading YAML from {yaml_path}", "info")
def load_yaml(path):
try:
with open(yaml_path, 'r') as file:
return yaml.safe_load(file)
with open(path, "r") as f:
return yaml.safe_load(f)
except FileNotFoundError:
print(red(f"File not found: {path}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
except yaml.YAMLError as e:
loggy(f"Failed to load YAML: {e}", "error")
sys.exit(1)
print(red(f"Failed to parse YAML: {e}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
# Loads a given JSON Schema file and returns the data
def load_schema(schema_path: str):
loggy(f"Loading JSON Schema from {schema_path}", "info")
def load_schema(path):
try:
with open(schema_path, 'r') as file:
return json.load(file)
with open(path, "r") as f:
return json.load(f)
except FileNotFoundError:
print(red(f"File not found: {path}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
except json.JSONDecodeError as e:
loggy(f"Failed to load JSON Schema: {e}", "error")
sys.exit(1)
print(red(f"Failed to parse JSON schema: {e}"), file=sys.stderr)
sys.exit(EXIT_RUNTIME_ERROR)
# Validates the given YAML data against the given JSON Schema
def validate_yaml(data, schema):
loggy("Beginning validation", "info")
def validate(data, schema):
validator = Draft7Validator(schema)
errors = sorted(validator.iter_errors(data), key=lambda e: e.path)
if errors:
for error in errors:
error_location = "->".join(map(str, error.path))
loggy(f"Validation error: {error.message} (at {error_location})", "warning")
return False
return True
errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path))
formatted = []
for error in errors:
location = resolve_path(data, list(error.path))
formatted.append(f"{location}: {error.message}")
return formatted
# Main method
def main():
loggy("Starting...", "info")
yaml_data = load_yaml(awesome_privacy_path)
schema = load_schema(schema_path)
data = load_yaml(DATA_PATH)
schema = load_schema(SCHEMA_PATH)
errors = validate(data, schema)
if validate_yaml(yaml_data, schema):
loggy("Validation successful!", "success")
sys.exit(0)
else:
loggy("Validation failed.", "error")
sys.exit(1)
if errors:
shown = errors[:MAX_ERRORS]
for msg in shown:
print(red("ERROR") + " " + msg, file=sys.stderr)
if len(errors) > MAX_ERRORS:
print(dim(f"...and {len(errors) - MAX_ERRORS} more"), file=sys.stderr)
print(red(f"Validation failed: {len(errors)} error(s)"), file=sys.stderr)
sys.exit(EXIT_VALIDATION_ERRORS)
# Gather stats
categories = data.get("categories", [])
num_categories = len(categories)
num_sections = sum(len(c.get("sections", [])) for c in categories)
num_services = sum(
len(s.get("services", []))
for c in categories
for s in c.get("sections", [])
)
print(green(f"Valid! {num_categories} categories, {num_sections} sections, {num_services} services"))
sys.exit(EXIT_VALID)
if __name__ == "__main__":