From 6719979ff761fdd1709b7a1ad0e16ab20c924418 Mon Sep 17 00:00:00 2001 From: Pasha Suprunchuk <57152612+suprunchuk@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:05:48 +0300 Subject: [PATCH 01/19] updated the link to Firefox --- awesome-privacy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 7c159be..9fd7981 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -393,7 +393,7 @@ categories: Brave also has Tor built-in, when you open up a private tab/ window. - name: Firefox - url: https://www.mozilla.org/firefox + url: https://www.firefox.com/ icon: https://www.mozilla.org/media/protocol/img/logos/firefox/logo.fedb52c912d6.svg openSource: true tosdrId: 188 From a2469c9295b91dbaae742051bb182cf3475e4746 Mon Sep 17 00:00:00 2001 From: liss-bot Date: Sat, 7 Feb 2026 05:16:22 +0000 Subject: [PATCH 02/19] Updates contributors list --- .github/README.md | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/.github/README.md b/.github/README.md index 7ca2fb6..5769ca5 100644 --- a/.github/README.md +++ b/.github/README.md @@ -5576,13 +5576,6 @@ Huge thanks to the following sponsors, for their ongoing support πŸ’– ZeroTail - - - 4aka -
- Arch Incorp -
- undefined @@ -5596,15 +5589,15 @@ Huge thanks to the following sponsors, for their ongoing support πŸ’–
Frankdez93
- - + BigoudOps
John BigoudOps
- + + hudsonrock-partnerships @@ -5619,19 +5612,19 @@ Huge thanks to the following sponsors, for their ongoing support πŸ’– LambdaTest + + + hesreallyhim +
+ Really Him +
+ gl0bal01
Fab πŸ’– ↀ◑ↀ
- - - - 0x41647269656E -
- 0x41647269656E -
From dc90e5bdf4776285520cd3bf66f1749a82ed74da Mon Sep 17 00:00:00 2001 From: liss-bot Date: Sat, 14 Feb 2026 05:17:52 +0000 Subject: [PATCH 03/19] Updates contributors list --- .github/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/README.md b/.github/README.md index 5769ca5..38cfde1 100644 --- a/.github/README.md +++ b/.github/README.md @@ -5614,7 +5614,7 @@ Huge thanks to the following sponsors, for their ongoing support πŸ’– - hesreallyhim + hesreallyhim
Really Him
From ecfccc11206926527aae200c21b770caf2f1cb17 Mon Sep 17 00:00:00 2001 From: liss-bot Date: Sat, 21 Feb 2026 05:16:40 +0000 Subject: [PATCH 04/19] Updates contributors list --- .github/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/README.md b/.github/README.md index 38cfde1..f61fd3b 100644 --- a/.github/README.md +++ b/.github/README.md @@ -5625,6 +5625,13 @@ Huge thanks to the following sponsors, for their ongoing support πŸ’–
Fab πŸ’– ↀ◑ↀ + + + + clj00321 +
+ Christian LΓΈvgren Jensen +
From de9b3d1a18f32d3ab053adb5aa4a734c4dbbcc4d Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sun, 22 Feb 2026 15:09:09 +0000 Subject: [PATCH 05/19] Updates the repo mirroring workflow --- .github/workflows/mirror.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/mirror.yml diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml new file mode 100644 index 0000000..c7c0a62 --- /dev/null +++ b/.github/workflows/mirror.yml @@ -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 From 0ad29adfa6e32fa640cfa24e7e684488f0ab9500 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sun, 22 Feb 2026 15:09:21 +0000 Subject: [PATCH 06/19] Removes obsolete GH Actions --- .github/workflows/check-domain.yml | 42 --------- .github/workflows/compile-pdf.yml | 44 ---------- .github/workflows/credits.yml | 34 -------- .github/workflows/pr-labeler.yml | 25 ------ .github/workflows/spell-check.yml | 20 ----- .github/workflows/sync-mirror.yml | 17 ---- .github/workflows/ticket-check.yml | 40 --------- .github/workflows/validate-pr.yml | 90 -------------------- .github/workflows/welcome-non-stargazers.yml | 18 ---- 9 files changed, 330 deletions(-) delete mode 100644 .github/workflows/check-domain.yml delete mode 100644 .github/workflows/compile-pdf.yml delete mode 100644 .github/workflows/credits.yml delete mode 100644 .github/workflows/pr-labeler.yml delete mode 100644 .github/workflows/spell-check.yml delete mode 100644 .github/workflows/sync-mirror.yml delete mode 100644 .github/workflows/ticket-check.yml delete mode 100644 .github/workflows/validate-pr.yml delete mode 100644 .github/workflows/welcome-non-stargazers.yml diff --git a/.github/workflows/check-domain.yml b/.github/workflows/check-domain.yml deleted file mode 100644 index 542dc77..0000000 --- a/.github/workflows/check-domain.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/compile-pdf.yml b/.github/workflows/compile-pdf.yml deleted file mode 100644 index 81b202d..0000000 --- a/.github/workflows/compile-pdf.yml +++ /dev/null @@ -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 }} - diff --git a/.github/workflows/credits.yml b/.github/workflows/credits.yml deleted file mode 100644 index 84aad69..0000000 --- a/.github/workflows/credits.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml deleted file mode 100644 index 761e606..0000000 --- a/.github/workflows/pr-labeler.yml +++ /dev/null @@ -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"] } - ] diff --git a/.github/workflows/spell-check.yml b/.github/workflows/spell-check.yml deleted file mode 100644 index 9ddb46d..0000000 --- a/.github/workflows/spell-check.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/sync-mirror.yml b/.github/workflows/sync-mirror.yml deleted file mode 100644 index 672ab86..0000000 --- a/.github/workflows/sync-mirror.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/ticket-check.yml b/.github/workflows/ticket-check.yml deleted file mode 100644 index 12759a3..0000000 --- a/.github/workflows/ticket-check.yml +++ /dev/null @@ -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, '')" - 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. - - - - diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml deleted file mode 100644 index 743cff5..0000000 --- a/.github/workflows/validate-pr.yml +++ /dev/null @@ -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. - - diff --git a/.github/workflows/welcome-non-stargazers.yml b/.github/workflows/welcome-non-stargazers.yml deleted file mode 100644 index 57091ec..0000000 --- a/.github/workflows/welcome-non-stargazers.yml +++ /dev/null @@ -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>_ From 2fec6f757d0939d6e3b469ccb8c1e691816dadc2 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Sun, 22 Feb 2026 15:30:18 +0000 Subject: [PATCH 07/19] Improves the validation script. - Updates Python deps - Only run pip install on first time - Corrections to the schema - Adds missing URL field to Picocrypt to avoid failure - Improves the script, with neater output, proper error codes, colors, etc --- Makefile | 2 +- awesome-privacy.yml | 151 ++++++++++++++++---------------- lib/requirements.txt | 5 +- lib/schema.json | 44 +++++----- lib/validate-awesome-privacy.py | 145 +++++++++++++++++------------- 5 files changed, 188 insertions(+), 159 deletions(-) diff --git a/Makefile b/Makefile index d22402a..d773886 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 7c159be..1a02f4a 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -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 I’m 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 I’m 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: diff --git a/lib/requirements.txt b/lib/requirements.txt index 5a0d1aa..3e298b1 100644 --- a/lib/requirements.txt +++ b/lib/requirements.txt @@ -1,5 +1,2 @@ PyYAML==6.0.1 -requests==2.31.0 -jsonschema -pyyaml -termcolor +jsonschema==4.23.0 diff --git a/lib/schema.json b/lib/schema.json index 19ab787..d77f6a3 100644 --- a/lib/schema.json +++ b/lib/schema.json @@ -22,23 +22,23 @@ "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 } + "github": { "type": ["string", "null"] }, + "icon": { "type": ["string", "null"] }, + "followWith": { "type": ["string", "null"] }, + "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"] } }, "required": ["name", "description", "url"], "additionalProperties": false } }, - "intro": { "type": "string", "nullable": true }, + "intro": { "type": ["string", "null"] }, "notableMentions": { "oneOf": [ { @@ -54,16 +54,20 @@ "additionalProperties": false } }, - { "type": "string" } - ], - "nullable": true + { "type": "string" }, + { "type": "null" } + ] }, - "furtherInfo": { "type": "string", "nullable": true }, - "wordOfWarning": { "type": "string", "nullable": true }, + "furtherInfo": { "type": ["string", "null"] }, + "wordOfWarning": { "type": ["string", "null"] }, "alternativeTo": { - "type": "array", - "items": { "type": "string" }, - "nullable": true + "oneOf": [ + { + "type": "array", + "items": { "type": "string" } + }, + { "type": "null" } + ] } }, "required": ["name", "services"], diff --git a/lib/validate-awesome-privacy.py b/lib/validate-awesome-privacy.py index 83b26cd..ea7fa35 100644 --- a/lib/validate-awesome-privacy.py +++ b/lib/validate-awesome-privacy.py @@ -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__": From adc0df53a5baf977ec1a881e79385ecbfd8ccedb Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Sun, 22 Feb 2026 17:01:12 +0000 Subject: [PATCH 08/19] Adds PR checks --- .github/workflows/pr-check.yml | 125 +++++++ .github/workflows/pr-comment.yml | 105 ++++++ .../__pycache__/check-links.cpython-310.pyc | Bin 0 -> 5842 bytes .../check-readme-edits.cpython-310.pyc | Bin 0 -> 4080 bytes .../check-template.cpython-310.pyc | Bin 0 -> 5359 bytes .../check-yaml-diff.cpython-310.pyc | Bin 0 -> 9772 bytes .../detect-changes.cpython-310.pyc | Bin 0 -> 1836 bytes .../__pycache__/warn-non-yaml.cpython-310.pyc | Bin 0 -> 2220 bytes lib/checks/check-links.py | 211 +++++++++++ lib/checks/check-readme-edits.py | 123 +++++++ lib/checks/check-template.py | 206 +++++++++++ lib/checks/check-yaml-diff.py | 342 ++++++++++++++++++ lib/checks/detect-changes.py | 50 +++ lib/checks/warn-non-yaml.py | 58 +++ lib/requirements.txt | 1 + lib/schema.json | 69 ++-- 16 files changed, 1269 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/pr-check.yml create mode 100644 .github/workflows/pr-comment.yml create mode 100644 lib/checks/__pycache__/check-links.cpython-310.pyc create mode 100644 lib/checks/__pycache__/check-readme-edits.cpython-310.pyc create mode 100644 lib/checks/__pycache__/check-template.cpython-310.pyc create mode 100644 lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc create mode 100644 lib/checks/__pycache__/detect-changes.cpython-310.pyc create mode 100644 lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc create mode 100644 lib/checks/check-links.py create mode 100644 lib/checks/check-readme-edits.py create mode 100644 lib/checks/check-template.py create mode 100644 lib/checks/check-yaml-diff.py create mode 100644 lib/checks/detect-changes.py create mode 100644 lib/checks/warn-non-yaml.py diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..109df8e --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -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/ diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml new file mode 100644 index 0000000..4034dba --- /dev/null +++ b/.github/workflows/pr-comment.yml @@ -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, + }); + } diff --git a/lib/checks/__pycache__/check-links.cpython-310.pyc b/lib/checks/__pycache__/check-links.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4fc6b746504ea50ff0d556d17c5e0d41c9b48b6b GIT binary patch literal 5842 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 literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc b/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6eb4ca82fce5980dd4238dec331d8b3f40153493 GIT binary patch literal 4080 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 literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/check-template.cpython-310.pyc b/lib/checks/__pycache__/check-template.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d49b2561d824ecb8c9fd9a6115aa3b9f6c3ec9e1 GIT binary patch literal 5359 zcmb_g&2JmW72nxik}Hb(wk^e0jLDZ{3KrueO&SD_5?N9m1u|t&aumxlh!tlgtxWFH zvrEZZ(7mWd(?d`c$Sp7+wTB|t{u90TIM<$X?#)1v{@(CQvXnN5P}teoH#2YEym{~U z-cvX*kTvjoT=>qvJZ>2OqK}h53m>21iT;U380&^$!mP4&rr+keso&PRCD^(xto4*< z?WT`1$42<7$9A2gK4BlToUxwuvhq!l66xO=fh98ZUhfC3Ae=U>Pt$UseI&ANTEC_Z z>=+{VSGGPV2G)nfpcum1VKFR5@E#GPVhrz5aY0<fdrVvs<9J^X6XF%TFN(|J3f`Bz zaWVPWl-u5=LxUN@T+vVd)#M-9xgD?aIO6w8d@~dW+}(ElAc}ds<MDc<TIJGv((s7j z)#_C@_6pgO=ZZ*A=q%cjVXaLl-(H@7z`bD4mtj!zf|&2Q(swtj9>4DHg+5kBJ0AG@ z!LC<{{V>QEva|TEwB&H5$M;<s_`!C>!=QS=r>DW%$Fhp_>oE_!Jx}s2*RMwWy7$zN z`Au;60T*Nxe=yN1r?_06TV7f&RTxp*KkCzc&f)oY4vk<<<2&f#xwT^Ctzwe8^6>q4 zYcK384{z4KH@;_zMKX<fx_L2K^*7&X$=G^LcNJ1UP|mHYTiX=w%m{HODRPD++d3lx ziJ?WX(P<hh*r?drCIdomL)k~4#G=d6KFh6hAW7Uhhj_0hQ(ay^#dVZs^rQJn2_-2> z4*Wb$gXeKNSGxCqj&x(R>n|dm=0wuX&|SV5T=O;~Klb?jx_~ji7+a~s7-4KYl>Cl# zYc*FY`>r2VRHo$B!acXDoLM1!vQ*VK3yT(H)yYza$lu{zD*Ek3=$*qE)YJQeKzp6J z1Co`jB$_}I8@n2Cn$$Ce^^JXG9m8_+cJj8~&f~XUgO?Nv1vtwLuY@%cG2$(^^q$6r zBug_2Q=ej78F{8sTUB>E%G=76p0c9Grb^E*ug)$lsZ<oh&oIS~vGOH@RAYLyyc5@| z<<35nBq-VZgej?Cp?>E_hDp|_PxC8u1dEL$)?~-5<$TXfe49;EpUyhAo>@&xPBg6} z0O~OVP}w+XDw$t^Fm5Hr>4KPGWz&oIJul!cf8&ie_zo}$u;2zl<4^$6p^Is{)!uL+ zN#kQ9no1_oYy{V#ko@&eKiLQ{wYd>su(`3G|ABn8m`_QB1Vg30sN&W=m5w}DR(9ko zL`-eVuu+HlYywS{Zf&nJbvKSZ8K_jvjWM?iAKIJM5GfRAC`T>idkw52a`91fnAt3g zN4|y6Q|3TUL{3ca=<jH@jb}hE!5VPRCOZOx9W!P$p{f}4Lv~~y8-FyuHlj&A3MIBj zt*?zeL%yx4h)XT%AB-)gBa;)`^H`1RNHxtvCNhW@3wo|2`i|0ZCbkdtr9JQM*P5Aq zY|Ax0v!7-*F^;RvJ@4+EA4opZVMgE0>OtM(Z4DIQFL6T#5wH4)G2vDR`#ISeLO`MT zV*KO+jzCcJqX=Wk#g_*Vq2hz&i&JE`UdO#fyb)A(++f=iZLdIh>i{@Kn0nQ7BND5M zt3lksD8xK<5oFb5&4ei0FSmi;j~rYE(~32l?9b*Cgx21*=S^i6ZYmZdnq(^2BJf0O zl+ayeZ_%mYM)jE)Vyb<}NUrHHQzijAmQuJ?4J&Rnnn9<tM3R{%w@;l`gkHphu;nEP z<nRvZRl@DSPjGVLSl|m!aj+Plbjon+W1g5dxO|nET|32$JdKh8UTaii9~#mO5OtF) z`SWy!SPhnWN2N*WfzV(eFVjf}O0#R_)d%+$%1evI1=auM?9$@ACafr{>IKT)4gCN< z7D`B#x1g<-n=7Z245<FCun`EYmUdvygLTWZM$f8j3WL#`Xl&Cl$+dE(t>2DCU!zve zyv%Gn_n!%7oALupJcC<w+jGQb9u3?M7^U5AxM?22;WU1=oB_Aqb3Q__h<E2$ZHK`e zAojzSBe<|WKvdfe`1Lt$oY4)w(x}&=1d2#^5ulJdRd4Xwtu4RmyRB^Lr(6cUl8kjZ zp74{AlSJ(!c}w2Fk$M-Dx8-{vs9am1a^TiH`994dJ@zd6w7PMGj)i`XY0)qmQa7O) zsT*%o{u;znjzr<08{mG6kN<^eM+kTb4Q!V}gBb(egxxfb&1Y6@6HcME4%xWz%x>Bu z1<WdFBuaPi4Z-9SO#yz<OkA4Nw7)Z+cM&k(mwebmC*9Lv^#34=uxawl^pgOUndTc0 zpKd%#bcb&|YPswOXBVCI+u)#;5pRZ1Ir5cSlnnNk?YI$7E^ACf@(x#~8P}sc4;vap z5xeyL>HwD6@&n;kt17#Ar?^~Nn44W$knce4m_?VauNe+U`L`!jA0cI{n~KBOt-gHu zLwdNZTT;|l;1Q9RcN}fl%(3CN)7j)?rX+YfV*?I|+Px^MQIdnZMT8+hjk3B%v9=M9 znnrXH&SX(7#f;!}-A~~*5v_2W>2{nk=8Z>LxE{^SG%eh)cFiZP`Hr>+8tbDTVjL6M zL-S8#WH(1?(SMYoxF!0>4H{oVRJDQjhgPdvde?|b`=Ha`gLVa#&n54!Ny;T%xT<_C z!u^0p4ay)7cpUOK?<}r<e*boPWp&|RdFB4yyR)SS1vPdKwU}H)3WH*5wOwQJIl6&t zGD(F|H%w`bN4)MvktYh-B>fu#M4k+Ef+j5B<7{G1b9oUOO=>fIYN{B<-p5l@J=PsO z<y2|;!B!}dt01{`t2%oUp^hI^U+HMAH>EJW=X?7e0_naFnNWiHTd+`sF5ps7K~<=j zt5_E3Z-$Y|v<pCGZF_Oc7Rln29U?HO)IO<A-UKC|QaRXWxg!-2sHMR&5YJO7q~>0v z*uG+0FWcd$=2DJguU>913c)d=6cpeC2ucHja_o}nuu&veS(dXdvDbf;OPEl>)BTYl zb^)o5uzJE65#xo?0JJp?%DROKi1}>W5Y}H{sIeWVgdL}wOq*irIRGZk?4qdu#-#KL zCOHDovPA~-96hJ6X%hJKW<7Z;?xXcE%FIzV?kDpjK#X(P872yoW=8|@Ac2=QU847c zZfp7wU=Ls2ksL-~+*mQxObL8-M+PtgN%crt;Hx__h>?sQ8E$4;yEK{n+b?puZ-X@W zjEJGb))~*7RZt}}TvVBRCH=nxZM*q&(*2JFBAE_al*?0UQ<veMFOW!G!?h)s+mZ8R zjO!$b*Uky1=gqr%BEFt8E<d7;#^9f)T@jg6{|VQll8KBqF*#S6wb@c}v3TbN<I1M2 zN@d!DCS&JFrTR~Zp!&4?6SgL=oY}SG|M^jQ8*)iPP)P|&E7MBVkvGRGeSoqy+}Ajd z`tBe?ZWW_KlG^%}P5(GieG6YLu9ol3uB_;?1EtPWlvY9~Lpp40+@@j&g%&(JQ<r6` zcjO(~?h7<3M>!=bL0yw6ga*p7qb^}I>ZUVY+L+tRX1c@?jWcxQ4(Vlgw`<5ne~&$* z57Fe<1-SK9$6=f~R5_VhYmjA;arB-+W?NS=%9;6N&T`B_CduuUQ!W#)aydVtGUc)e zE9G)amlQAM8a3-Qaa8N4bicT|cz2<^P%15#Bt<)w_9GN}2R+@ZY`GDU9;%^KmV|L` z5SK5hDNr*_4Iz`(^h+AnchZ%OQZES9s~vSu7Yxp=S|}P-Z-!F;h(f$=IB5rGv@M%W dSQi-mIhJiXmXmV&oU6{TGwxh+ZbCyp{ueEYQ>*|0 literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc b/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51c91bfd2bd882b16391f3f81379554355e72e06 GIT binary patch literal 9772 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 literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/detect-changes.cpython-310.pyc b/lib/checks/__pycache__/detect-changes.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..89c86010b6f042edb3abb57013bcbbd2161f57d6 GIT binary patch literal 1836 zcmZux-ER{|5Z~SV@cA==q!6{Jx*}A`YHiwwwh{_a1ED}glZb>?SVE`s?Z&=vzH@fh zF}BW9EAxt~KJ@`bvLE?}`mnEk>c4<g(b;tf5%g9wyE{8OH#_s2$*R>7f^X1#8UN`Z z^taxe|BA5r9!PLt6txh=lq9%?%}iRv%vQ@Xv)!^8Ido?32fV<%IbMubky@=1wW;$Q zwaV0`1(++WO1-Cqf5$3Qgb^ishU&%fv(grmERw<>cH^k)AH)e0e$)-q4x@gX$suE@ zFT2d&edxDC!Td0#{=GY!Tg}oQgKKwVd2i76HzOHmsqnLb><^^4;g7;z5}exk(oeHA zI4^0I{(%>x?eR)DWFqUaMxV#WVKi!vdPzj|+39D)>;c^d5-ng79pV`TW{xK$C;SU( z<%E(c`F(X_<<`v3(Mv?F90Rvc>^a8h7YMBlV`{<E(H{C5iEBfU>+x8J17w=&&bF%F zy}y6&(XC+T(f;nEeTBo<_@=_8x}!)YRDq?(G0#$Eb(mCk)@P}5hCG(6ZYw(tdrUdU zVKQK<ya+^~<Eik$E5w(;_t#gFxP3k9vgoO}PEFt&ix`P!f23B7sh|_f?w}nkEG+AH zSAbQpjVpK!Uwvc0;TJ#<0iAsXSZOk0`U@61Od2&W@C4=PkWfowg1Rpt2sxTrb2P!y zK6Ge-dbF71Ll**8Iw2h!^kB#WEx&LkgjQ(vIm*eQH!IH3tTabkXo@G6EYsQ)f|y%d z=t*&6!+t5ZU!oJDrAOb&N^Tvai6g6YnO*=3pf|;E)0H)PQ7+}q6wj9DXi6{w$qGo^ zbCNP$%^mRQqa6G?v9(Q;qD$N3D+|P)fph`cIQ2uoXdSCEWg_BnUjuSnYBV%z8=M{N zkFVU)0EY1Tu=Qo;)A-=P4?AI;3Rx%0<Lqb<b4HcjWl0~h97C41(zx7c==3(SG#QQE zW>Z%H<h&MzeL3LlG+D})?3q-K$*{7bA&u}^iV)Jcwt)74#44~3q~eJ?!8zt6$A6F` zC`<zs!(I_ZnqTo6D83VN4G2|QPhugXtk>VTs*P#Ot7yE|gQqmtlPn4ou>p&>O5@t? z(~>sPx-llc|G%yYZEX$MZ`!qjc1@zE*3VETN5o_RZ$pfWk2imM5d3ie!5#hyD8KJs zalOM*_N>o0>e`;JVe;!GG`{?ve{Z!^<$QBc%kQ^~arON9&GE{I9pz^Ig7QM%>4#h} zRomp9L64<!S8uthgp>wwYGkTpl=NPxk{Go6Jd2nR%Ho4mc^WSP07O-GAMX5k=k|W^ zaA#*<xk6GXN#zI$;DUSoB$i2>GNBx3Ua91t!ZVc>rXy3H$^u2@B-xPhdO^A7pIq7I zuP$d|kVsWM?U1HVh()S>FvCC7Ot*{$GfB5~^e%%;=$u#ZGWM`dE@BrG?Ebsvdc@N* zkX!^>Bm5gsQ(h40dqGgIsX`FYED8dy4RBYp&$W&vMnc*BP<B;;#@sZrLf95Y0tPf_ z;FeCNd8K*anR%@ar|-NqE90`a-plA9VH>&(46bbCIUYFf;cLVup5+z1k4xBo^B<H) B>01B* literal 0 HcmV?d00001 diff --git a/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc b/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53295211e3ced8b7d90b3ec0a65ca9afa2498c2f GIT binary patch literal 2220 zcmb7F-ER{|5Z}E!pD$<UJA{-VbyZraPO$+YA)qKCgixr+hms(K5xShc8~e=p&e>gK ze6pXyW235ls8pdyw)(`sw6A^YKhOuHmd;)Xp@3BN_I7q>c6N4l=eM)*=%|a}`>pgc zxS2)hcg?*2jR12Ks`v#6MP)=WH9}m*X={{?v^C3S+FE6c8I8=q{)lFoGsH)wJVMQ~ z%dAFjkRPJ>66lT&#;7&Kc~l-}<NPYkQ2PanOq$hgd4f801lmb9Mct<czsDx`5k}P5 zN^2pP{NO(Hc_c`u&LZNIjZM;wX|NkGL3V=>Xe{fD6Iu6RxWh!;WW^Q_p83^osoM-o z?sh#81b+6s#j27~vKw>qkX1w=8F|p6J~SX%4MY{Ji}R$?mZa%-NyI>zJ(t`koIMLz zr{w+tCxzCM=}#=AoV9gtdHw$SW)*9#x1a8tP?w>KJP?X|=p}6K(A+`=b1litKfZjq znOO6WFE(GHS6E@e^@NnXo)3e{g=(EupNb0|IEP{sN5!rW`)YNSb0hSd73wceYU2iw zJmL^(^pOYJi_`-PIz0p{<F%tzwBTetZfjPXcxTc=RbbzJ4)k<Qtz7}T5;ao59N<1! zZWz?0mJafPiGd%OL)6F8YGfeb5b7Ro*sy!|(5P9U2SsLS?jX}QXr7L~Ks}@33`T}% z;11Dkw2%8H9ZThA%2_>2d`rQ9nUi@szK`W-V~kE57_gRg8$HSQGcrdfdzqK$&^Q^} zyCcUN6N5=QHN<_pXY*g_G@Yp#{cJib*U$8_gDHAS&!e-4<`84F=clsMx}WyubWcwA zZ2H;0@#8ei$qZ)bnZc=Ec8KYj(@2j$go|WB*@xY0KQAHia|h(QJkr1hnXZNTwS;J3 z6TcDz-gH<WEv?>9F-@uf<{A?Pta6lzDi2yRh@-?U7AwABMb37&le4#=OKO1xaPATr z6Sx$+#IN~5BxJ!*4rhDqfHSJBItyFM1XX2er6gA@Mm}7VI10OoT`JvOS-M@Y)L7MT z$u?&mIMix`vIOv}Oo~2bRy*`xu+nhTINgFOW`NWn+)wO2?qP29@K44b+~E^sy)u+h zx~TB(i6{k|a&Lrzkkz=^TKrs_(spN1GS`HSlx~D^)eprYbdHriazZ0Gbz<(=2}e62 z(NpsSN8mh1^dWn9?%iME8sQ%}!}UH0)YOYPlw3)Z$N$Ya*I^+`C#S?9L`{EpZ~N|p zTi(|8%7(Y~V0CqA^HFm4+&Qv#9P{O)aHn_(qMDmr`|o9UK9PcUuDgj@Bs;J1jzUSi zNEQ~>V#%&AERfY$NS)n?sK@P)5*_-ZMA{{vG*vor?=2eBocI=0#HTdSnDoPUxlv(X zVYe`=9G}-(J{L@lE%92r$s)O-S+4Rh&4bZYrrcCX^Fq0zU1{;S%7joRZ%4|}NcRBR zD!;M0{{71Gwzs*yzO8H_Dd1COgoJAW^IBmb!ysZpWgzWGQrTS=hH*!kqAOB$#<?<L zp|UJ`7VtQNtH40bZ?#wiw<Cr|WjY+517$VhASyV@PF>^5N}aPg6YWr{(W8@~Ui$DT z2QICrb1|O=$f&$%H=921rlTs8`t$+5l%-LvK_;#O$>Ry^V9S`oHa4*R*PJ~DoQ0<h z2hZZXX<@@Ku=TfN&0rTw-k8N-ys`e|*YtXh=V@!6SD02=&!chG^Y~d9;ooRNG${a4 zR?C-lm8AjKw_auaiby43KyL(QozuFfG+EFS>_GTZcKLZNDQb!9wILeSIE*>}T1$^T x&=$dfP6Y1}{ffZr1P^sPWQ+Ro5odr{$jLacV+Vg}SjL<=gB{cP%Ei{3e*o$%PP703 literal 0 HcmV?d00001 diff --git a/lib/checks/check-links.py b/lib/checks/check-links.py new file mode 100644 index 0000000..293fa24 --- /dev/null +++ b/lib/checks/check-links.py @@ -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() diff --git a/lib/checks/check-readme-edits.py b/lib/checks/check-readme-edits.py new file mode 100644 index 0000000..aa733b5 --- /dev/null +++ b/lib/checks/check-readme-edits.py @@ -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() diff --git a/lib/checks/check-template.py b/lib/checks/check-template.py new file mode 100644 index 0000000..b53671d --- /dev/null +++ b/lib/checks/check-template.py @@ -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() diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py new file mode 100644 index 0000000..2931e68 --- /dev/null +++ b/lib/checks/check-yaml-diff.py @@ -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() diff --git a/lib/checks/detect-changes.py b/lib/checks/detect-changes.py new file mode 100644 index 0000000..9b87e78 --- /dev/null +++ b/lib/checks/detect-changes.py @@ -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() diff --git a/lib/checks/warn-non-yaml.py b/lib/checks/warn-non-yaml.py new file mode 100644 index 0000000..e244dbc --- /dev/null +++ b/lib/checks/warn-non-yaml.py @@ -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() diff --git a/lib/requirements.txt b/lib/requirements.txt index 3e298b1..f59b908 100644 --- a/lib/requirements.txt +++ b/lib/requirements.txt @@ -1,2 +1,3 @@ PyYAML==6.0.1 jsonschema==4.23.0 +requests==2.32.3 diff --git a/lib/schema.json b/lib/schema.json index d77f6a3..2ca04d2 100644 --- a/lib/schema.json +++ b/lib/schema.json @@ -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" } ] From 66d9970144f2c043e32e5ab31b2d6cf4c06e0df3 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Sun, 22 Feb 2026 22:54:42 +0000 Subject: [PATCH 09/19] Updates PR Check --- .github/workflows/pr-check.yml | 156 ++++++++++++++++++++++++--------- 1 file changed, 115 insertions(+), 41 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 109df8e..94095b5 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -10,65 +10,131 @@ permissions: pull-requests: read jobs: - check-pr: - name: Check PR + detect-changes: + name: Detect changes runs-on: ubuntu-latest - + outputs: + yaml_changed: ${{ steps.changes.outputs.yaml_changed }} + non_yaml_changed: ${{ steps.changes.outputs.non_yaml_changed }} 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 }} - + - 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 + readme: + name: README edits + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - 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: Check for direct README edits + run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} + + schema: + name: Schema validation + needs: detect-changes + if: needs.detect-changes.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: Validate schema + run: make validate + + diff: + name: Single-entry rule + needs: detect-changes + if: needs.detect-changes.outputs.yaml_changed == 'true' + runs-on: ubuntu-latest + outputs: + has_service_changes: ${{ steps.diff.outputs.has_service_changes }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - 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: YAML diff analysis + id: diff + run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }} + - 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 + + links: + name: Link validation + needs: diff + if: needs.diff.result == 'success' && needs.diff.outputs.has_service_changes == '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 + - name: Validate links + run: python lib/checks/check-links.py --diff-json /tmp/pr-diff.json + + template: + name: PR template + 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: PR template check + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: python lib/checks/check-template.py + + comment: + name: Summary + if: always() + needs: [readme, schema, diff, links, template] + runs-on: ubuntu-latest + steps: + - name: Save PR metadata 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() + if: contains(needs.*.result, 'failure') run: | cat > /tmp/pr-meta/comment.md << 'HEADER' <!-- pr-check-bot --> @@ -76,7 +142,7 @@ jobs: > ## PR check failed HEADER - if [ "${{ steps.readme.outcome }}" = "failure" ]; then + if [ "${{ needs.readme.result }}" = "failure" ]; then cat >> /tmp/pr-meta/comment.md << 'EOF' ### Direct README edits @@ -85,7 +151,7 @@ jobs: EOF fi - if [ "${{ steps.schema.outcome }}" = "failure" ]; then + if [ "${{ needs.schema.result }}" = "failure" ]; then cat >> /tmp/pr-meta/comment.md << 'EOF' ### Schema validation failed @@ -94,7 +160,7 @@ jobs: EOF fi - if [ "${{ steps.diff.outcome }}" = "failure" ]; then + if [ "${{ needs.diff.result }}" = "failure" ]; then cat >> /tmp/pr-meta/comment.md << 'EOF' ### Single-entry rule @@ -103,7 +169,16 @@ jobs: EOF fi - if [ "${{ steps.template.outcome }}" = "failure" ]; then + if [ "${{ needs.links.result }}" = "failure" ]; then + cat >> /tmp/pr-meta/comment.md << 'EOF' + + ### Link validation failed + One or more URLs in your entry could not be verified. + Please ensure all links are correct and accessible. + EOF + fi + + if [ "${{ needs.template.result }}" = "failure" ]; then cat >> /tmp/pr-meta/comment.md << 'EOF' ### PR template incomplete @@ -112,7 +187,6 @@ jobs: 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 From 96e4e1d7b1b83942172405719bdc50b3380decbb Mon Sep 17 00:00:00 2001 From: 7 <spicezke@gmail.com> Date: Mon, 23 Feb 2026 01:20:59 +0300 Subject: [PATCH 10/19] Adds FFmpeg to File Converters Adds FFmpeg to File Converters --- awesome-privacy.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/awesome-privacy.yml b/awesome-privacy.yml index 7c159be..293424a 100644 --- a/awesome-privacy.yml +++ b/awesome-privacy.yml @@ -4996,7 +4996,17 @@ categories: services: [] - name: File Converters alternativeTo: ['format factory', 'handbrake', 'freemake video converter', 'any video converter', 'online-convert.com'] - services: [] + services: + - name: FFmpeg + url: https://ffmpeg.org/ + icon: https://ffmpeg.org/favicon.ico + openSource: true + github: FFmpeg/FFmpeg + description: | + A complete, cross-platform solution to record, convert, and stream audio and + video. It's the industry standard multimedia framework, handling a vast range + of formats. As a command-line tool, it guarantees that all processing is done + locally on your machine. - name: Creativity sections: From 2476a57bac22dd31a6ba7bd9f27ec0e4e38d86d8 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Mon, 23 Feb 2026 15:16:57 +0000 Subject: [PATCH 11/19] Updates check workflow, to review pull requests --- .github/workflows/pr-check.yml | 190 +++++++++++---------------- .github/workflows/pr-comment.yml | 11 +- lib/checks/check-additions.py | 173 ++++++++++++++++++++++++ lib/checks/check-pr-meta.py | 122 +++++++++++++++++ lib/checks/check-project.py | 217 +++++++++++++++++++++++++++++++ lib/checks/format-comment.py | 106 +++++++++++++++ 6 files changed, 700 insertions(+), 119 deletions(-) create mode 100644 lib/checks/check-additions.py create mode 100644 lib/checks/check-pr-meta.py create mode 100644 lib/checks/check-project.py create mode 100644 lib/checks/format-comment.py diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 94095b5..4e56533 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -18,13 +18,10 @@ jobs: non_yaml_changed: ${{ steps.changes.outputs.non_yaml_changed }} steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - 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 }} @@ -32,54 +29,64 @@ jobs: if: steps.changes.outputs.non_yaml_changed == 'true' run: python lib/checks/warn-non-yaml.py --base-ref ${{ github.event.pull_request.base.sha }} - readme: - name: README edits + pr-meta: + name: PR metadata runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - fetch-depth: 0 + python-version: "3.12" + - name: Check PR metadata + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + run: python lib/checks/check-pr-meta.py + - name: Upload findings + if: always() + uses: actions/upload-artifact@v4 + with: + name: findings-meta + path: /tmp/findings-meta.json + if-no-files-found: ignore + + file-checks: + name: File checks + 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" - - run: pip install -q -r lib/requirements.txt - name: Check for direct README edits run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} - schema: - name: Schema validation + data-validation: + name: Data validation needs: detect-changes if: needs.detect-changes.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: Validate schema - run: make validate - - diff: - name: Single-entry rule - needs: detect-changes - if: needs.detect-changes.outputs.yaml_changed == 'true' - runs-on: ubuntu-latest - outputs: - has_service_changes: ${{ steps.diff.outputs.has_service_changes }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - 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: YAML diff analysis + - name: Schema validation + id: schema + continue-on-error: true + run: make validate + - name: YAML diff 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 + env: + SCHEMA_OUTCOME: ${{ steps.schema.outcome }} + run: python lib/checks/check-additions.py - name: Upload diff data if: always() uses: actions/upload-artifact@v4 @@ -87,11 +94,21 @@ jobs: 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 checks failed + if: steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure' + run: exit 1 - links: - name: Link validation - needs: diff - if: needs.diff.result == 'success' && needs.diff.outputs.has_service_changes == 'true' + project-checks: + name: Project checks + needs: data-validation + if: "!cancelled() && needs.data-validation.result != 'skipped'" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -104,93 +121,44 @@ jobs: with: name: pr-diff path: /tmp - - name: Validate links - run: python lib/checks/check-links.py --diff-json /tmp/pr-diff.json + 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 - template: - name: PR template + summary: + name: Summary + if: always() + needs: [detect-changes, pr-meta, file-checks, data-validation, project-checks] 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: PR template check + - 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_BODY: ${{ github.event.pull_request.body }} - run: python lib/checks/check-template.py - - comment: - name: Summary - if: always() - needs: [readme, schema, diff, links, template] - runs-on: ubuntu-latest - steps: - - name: Save PR metadata - 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: contains(needs.*.result, 'failure') - run: | - cat > /tmp/pr-meta/comment.md << 'HEADER' - <!-- pr-check-bot --> - > [!CAUTION] - > ## PR check failed - HEADER - - if [ "${{ needs.readme.result }}" = "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 [ "${{ needs.schema.result }}" = "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 [ "${{ needs.diff.result }}" = "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 [ "${{ needs.links.result }}" = "failure" ]; then - cat >> /tmp/pr-meta/comment.md << 'EOF' - - ### Link validation failed - One or more URLs in your entry could not be verified. - Please ensure all links are correct and accessible. - EOF - fi - - if [ "${{ needs.template.result }}" = "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 - - 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 - + PR_USER: ${{ github.event.pull_request.user.login }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_ID: ${{ github.run_id }} + README_FAILED: ${{ needs.file-checks.result == 'failure' && 'true' || 'false' }} + run: python lib/checks/format-comment.py - name: Upload PR metadata if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml index 4034dba..455112c 100644 --- a/.github/workflows/pr-comment.yml +++ b/.github/workflows/pr-comment.yml @@ -33,7 +33,6 @@ jobs: script: | const fs = require('fs'); const marker = '<!-- pr-check-bot -->'; - const conclusion = context.payload.workflow_run.conclusion; // Determine the PR number let prNumber; @@ -73,12 +72,8 @@ jobs: }); 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 commentFile = 'pr-meta/comment.md'; + if (fs.existsSync(commentFile)) { const body = fs.readFileSync(commentFile, 'utf8').trim(); if (existing) { await github.rest.issues.updateComment({ @@ -96,7 +91,7 @@ jobs: }); } } else if (existing) { - // Checks passed β€” remove stale failure comment + // No findings β€” remove stale comment await github.rest.issues.deleteComment({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py new file mode 100644 index 0000000..474c651 --- /dev/null +++ b/lib/checks/check-additions.py @@ -0,0 +1,173 @@ +"""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." +) + + +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 not fields.get(f): + missing.add(f) + for svc in diff.get("services", {}).get("modified", []): + if not head: + continue + fields = find_service_fields( + head, svc["category"], svc["section"], svc["service"] + ) + if fields: + for f in REQUIRED_FIELDS: + if not fields.get(f): + 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 contains multiple service or section changes.""" + services = diff.get("services", {}) + svc_count = ( + len(services.get("added", [])) + + len(services.get("removed", [])) + + len(services.get("modified", [])) + ) + if svc_count > 1: + return MULTIPLE_MSG + if svc_count == 0: + sec_count = len(diff.get("sections", [])) + if sec_count > 1: + return MULTIPLE_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) + except Exception: + pass + + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-pr-meta.py b/lib/checks/check-pr-meta.py new file mode 100644 index 0000000..f63f561 --- /dev/null +++ b/lib/checks/check-pr-meta.py @@ -0,0 +1,122 @@ +"""Checks PR metadata: title format, draft status, template completeness, and checkboxes.""" + +import json +import os +import re +import sys + +FINDINGS_PATH = "/tmp/findings-meta.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" +) + + +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 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") + + 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) + except Exception: + pass + + write_findings(findings) + sys.exit(1 if critical else 0) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-project.py b/lib/checks/check-project.py new file mode 100644 index 0000000..ffa78db --- /dev/null +++ b/lib/checks/check-project.py @@ -0,0 +1,217 @@ +"""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 is unreachable.""" + for svc in get_services(diff, "added"): + url = svc.get("fields", {}).get("url") + if url and not check_url(url): + return LINK_MSG + for svc in get_services(diff, "modified"): + if "url" not in svc.get("changed_fields", []): + continue + head_svc = find_service_in_head( + head, svc["category"], svc["section"], svc["service"] + ) + if head_svc: + url = head_svc.get("url") + if url and not check_url(url): + 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() diff --git a/lib/checks/format-comment.py b/lib/checks/format-comment.py new file mode 100644 index 0000000..e81497a --- /dev/null +++ b/lib/checks/format-comment.py @@ -0,0 +1,106 @@ +"""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" + +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." +) + +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(readme_failed): + """Gather all findings in display order: meta, readme, data, project.""" + all_findings = [] + all_findings.extend(load_findings("findings-meta.json")) + if readme_failed: + all_findings.append(README_MSG) + 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", "") + readme_failed = os.environ.get("README_FAILED", "false") == "true" + + 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(readme_failed) + 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() From 512299c71db3edc0f25c6e75b8ce9c390fd5cb75 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Mon, 23 Feb 2026 20:28:43 +0000 Subject: [PATCH 12/19] Updates PR check step concurency --- .github/workflows/pr-check.yml | 79 ++++---- lib/checks/check-links.py | 211 -------------------- lib/checks/check-pr-meta.py | 19 +- lib/checks/check-template.py | 206 ------------------- lib/checks/check-yaml-diff.py | 348 ++++++++++----------------------- lib/checks/detect-changes.py | 8 +- lib/checks/format-comment.py | 17 +- lib/checks/warn-non-yaml.py | 58 ------ 8 files changed, 163 insertions(+), 783 deletions(-) delete mode 100644 lib/checks/check-links.py delete mode 100644 lib/checks/check-template.py delete mode 100644 lib/checks/warn-non-yaml.py diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 4e56533..d30babe 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -4,86 +4,76 @@ on: pull_request: branches: [main] types: [opened, edited, synchronize, reopened] + paths: + - 'awesome-privacy.yml' + - '.github/README.md' permissions: contents: read pull-requests: read jobs: - detect-changes: - name: Detect changes + pr-compliance: + name: PR Compliance runs-on: ubuntu-latest - outputs: - yaml_changed: ${{ steps.changes.outputs.yaml_changed }} - non_yaml_changed: ${{ steps.changes.outputs.non_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 changed files - id: changes - run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }} - - 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 }} - - pr-meta: - name: PR metadata - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - 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-meta - path: /tmp/findings-meta.json + name: findings-compliance + path: /tmp/findings-compliance.json if-no-files-found: ignore - - file-checks: - name: File checks - 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 for direct README edits - run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} + - name: Fail if critical + if: steps.readme.outcome == 'failure' || steps.meta.outcome == 'failure' + run: exit 1 data-validation: - name: Data validation - needs: detect-changes - if: needs.detect-changes.outputs.yaml_changed == 'true' + 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" - - run: pip install -q -r lib/requirements.txt + - 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 @@ -101,14 +91,14 @@ jobs: name: findings-data path: /tmp/findings-data.json if-no-files-found: ignore - - name: Fail if critical checks failed - if: steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure' + - name: Fail if critical + if: steps.changes.outputs.yaml_changed == 'true' && (steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure') run: exit 1 - project-checks: - name: Project checks + submission-eligibility: + name: Submission Eligibility needs: data-validation - if: "!cancelled() && needs.data-validation.result != 'skipped'" + if: "!cancelled() && needs.data-validation.outputs.yaml_changed == 'true'" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -138,7 +128,7 @@ jobs: summary: name: Summary if: always() - needs: [detect-changes, pr-meta, file-checks, data-validation, project-checks] + needs: [pr-compliance, data-validation, submission-eligibility] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -157,7 +147,6 @@ jobs: PR_USER: ${{ github.event.pull_request.user.login }} PR_NUMBER: ${{ github.event.pull_request.number }} RUN_ID: ${{ github.run_id }} - README_FAILED: ${{ needs.file-checks.result == 'failure' && 'true' || 'false' }} run: python lib/checks/format-comment.py - name: Upload PR metadata if: always() diff --git a/lib/checks/check-links.py b/lib/checks/check-links.py deleted file mode 100644 index 293fa24..0000000 --- a/lib/checks/check-links.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -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() diff --git a/lib/checks/check-pr-meta.py b/lib/checks/check-pr-meta.py index f63f561..aa1379e 100644 --- a/lib/checks/check-pr-meta.py +++ b/lib/checks/check-pr-meta.py @@ -5,7 +5,7 @@ import os import re import sys -FINDINGS_PATH = "/tmp/findings-meta.json" +FINDINGS_PATH = "/tmp/findings-compliance.json" BAD_TITLES = {"update readme.md", "update awesome-privacy.yml"} @@ -27,6 +27,11 @@ CHECKBOX_MSG = ( " 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): @@ -78,6 +83,13 @@ def check_checkboxes(body): 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: @@ -91,6 +103,7 @@ def main(): 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: @@ -111,6 +124,10 @@ def main(): finding = check_checkboxes(body) if finding: findings.append(finding) + + finding = check_readme(readme_failed) + if finding: + findings.append(finding) except Exception: pass diff --git a/lib/checks/check-template.py b/lib/checks/check-template.py deleted file mode 100644 index b53671d..0000000 --- a/lib/checks/check-template.py +++ /dev/null @@ -1,206 +0,0 @@ -""" -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() diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py index 2931e68..44a5285 100644 --- a/lib/checks/check-yaml-diff.py +++ b/lib/checks/check-yaml-diff.py @@ -1,7 +1,5 @@ -""" -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. +"""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 @@ -12,22 +10,18 @@ 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): @@ -35,13 +29,11 @@ def load_base_yaml(base_ref): try: result = subprocess.run( ["git", "show", f"{base_ref}:awesome-privacy.yml"], - capture_output=True, text=True, check=True, - cwd=PROJECT_ROOT, + 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) + 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) @@ -51,165 +43,45 @@ def load_base_yaml(base_ref): def load_head_yaml(): """Load the YAML from the current working tree.""" try: - with open(DATA_PATH, "r") as f: + with open(DATA_PATH) 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) + 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_service_index(data): - """Build a dict keyed by (category, section, service_name) -> service dict.""" +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", []): - cat_name = cat.get("name", "") + 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", []): - sec_name = sec.get("name", "") + 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", []): - svc_name = svc.get("name", "") - key = (cat_name, sec_name, svc_name) - index[key] = svc + index[(cn, sn, svc.get("name", ""))] = 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], - }) - +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): - 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, - }) - + 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 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") @@ -218,123 +90,113 @@ def write_github_output(name, value): 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 Markdown summary to $GITHUB_STEP_SUMMARY.""" + """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 = [] - 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"] + 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 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") + if bullets: + lines.extend(bullets) 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("") + 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(description="Analyze YAML diff for PR checks") - parser.add_argument("--base-ref", required=True, help="Base git ref (SHA or branch) to diff against") + parser = argparse.ArgumentParser() + parser.add_argument("--base-ref", required=True) args = parser.parse_args() - # Load both versions - base_data = load_base_yaml(args.base_ref) - head_data = load_head_yaml() + base = load_base_yaml(args.base_ref) + head = 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) + 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"}) - # Build result diff_result = { - "services": { - "added": added, - "removed": removed, - "modified": modified, - }, - "sections": section_changes, - "categories": category_changes, + "services": {"added": added, "removed": removed, "modified": modified}, + "sections": sections, + "categories": categories, } - # 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_github_output("has_service_changes", str(bool(added or removed or modified)).lower()) 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) + svc_count = len(added) + len(removed) + len(modified) + if svc_count > 1: + print(red(f"Single-entry rule violation: {svc_count} service changes found."), file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + if svc_count == 0 and len(sections) > 1: + print(red(f"Single-entry rule violation: {len(sections)} section changes found."), 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).")) + print(green(f"Single-entry rule passed. {svc_count} service, " + f"{len(sections)} section, {len(categories)} category change(s).")) sys.exit(EXIT_PASS) diff --git a/lib/checks/detect-changes.py b/lib/checks/detect-changes.py index 9b87e78..9fa591d 100644 --- a/lib/checks/detect-changes.py +++ b/lib/checks/detect-changes.py @@ -1,6 +1,6 @@ """ Detects which files changed between the PR base and HEAD. -Sets GitHub Actions outputs: yaml_changed, non_yaml_changed. +Sets GitHub Actions output: yaml_changed. """ import argparse @@ -38,12 +38,8 @@ def main(): 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}") + print(f"yaml_changed={yaml_changed}") if __name__ == "__main__": diff --git a/lib/checks/format-comment.py b/lib/checks/format-comment.py index e81497a..d2789b1 100644 --- a/lib/checks/format-comment.py +++ b/lib/checks/format-comment.py @@ -7,12 +7,6 @@ import sys ARTIFACTS_DIR = "/tmp/artifacts" OUTPUT_DIR = "/tmp/pr-meta" -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." -) - CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md" COMMENT_TEMPLATE = """<!-- pr-check-bot --> @@ -38,12 +32,10 @@ def load_findings(filename): return [] -def collect_findings(readme_failed): - """Gather all findings in display order: meta, readme, data, project.""" +def collect_findings(): + """Gather all findings in display order: compliance, data, project.""" all_findings = [] - all_findings.extend(load_findings("findings-meta.json")) - if readme_failed: - all_findings.append(README_MSG) + 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 @@ -78,7 +70,6 @@ def main(): user = os.environ.get("PR_USER", "contributor") pr_number = os.environ.get("PR_NUMBER", "") run_id = os.environ.get("RUN_ID", "") - readme_failed = os.environ.get("README_FAILED", "false") == "true" os.makedirs(OUTPUT_DIR, exist_ok=True) @@ -89,7 +80,7 @@ def main(): with open(os.path.join(OUTPUT_DIR, "run-id.txt"), "w") as f: f.write(run_id) - findings = collect_findings(readme_failed) + findings = collect_findings() write_step_summary(findings) if findings: diff --git a/lib/checks/warn-non-yaml.py b/lib/checks/warn-non-yaml.py deleted file mode 100644 index e244dbc..0000000 --- a/lib/checks/warn-non-yaml.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -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() From acd153fd2231122d1ba22dd39cc30cd770f85578 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Mon, 23 Feb 2026 21:32:26 +0000 Subject: [PATCH 13/19] Simplifies comment logic --- .github/workflows/pr-comment.yml | 58 ++++++++---------- .../__pycache__/check-links.cpython-310.pyc | Bin 5842 -> 0 bytes .../check-readme-edits.cpython-310.pyc | Bin 4080 -> 0 bytes .../check-template.cpython-310.pyc | Bin 5359 -> 0 bytes .../check-yaml-diff.cpython-310.pyc | Bin 9772 -> 0 bytes .../detect-changes.cpython-310.pyc | Bin 1836 -> 0 bytes .../__pycache__/warn-non-yaml.cpython-310.pyc | Bin 2220 -> 0 bytes lib/checks/check-additions.py | 15 ++--- 8 files changed, 32 insertions(+), 41 deletions(-) delete mode 100644 lib/checks/__pycache__/check-links.cpython-310.pyc delete mode 100644 lib/checks/__pycache__/check-readme-edits.cpython-310.pyc delete mode 100644 lib/checks/__pycache__/check-template.cpython-310.pyc delete mode 100644 lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc delete mode 100644 lib/checks/__pycache__/detect-changes.cpython-310.pyc delete mode 100644 lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml index 455112c..44e6c33 100644 --- a/.github/workflows/pr-comment.yml +++ b/.github/workflows/pr-comment.yml @@ -26,7 +26,7 @@ jobs: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Post or update comment + - name: Post comment uses: actions/github-script@v7 with: github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} @@ -34,12 +34,20 @@ jobs: 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()); - } else { + } + 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; @@ -53,48 +61,34 @@ jobs: state: 'open', sort: 'updated', direction: 'desc', - per_page: 10, + per_page: 100, }); const match = prList.find(pr => pr.head.sha === headSha); if (!match) { - console.log(`No open PR found for SHA ${headSha} β€” skipping comment.`); + console.log(`No open PR found for SHA ${headSha} β€” skipping.`); return; } prNumber = match.number; } } - // Find existing bot comment + // 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, }); - const existing = comments.find(c => c.body.includes(marker)); - - const commentFile = 'pr-meta/comment.md'; - if (fs.existsSync(commentFile)) { - 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) { - // No findings β€” remove stale comment - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - }); + 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, + }); diff --git a/lib/checks/__pycache__/check-links.cpython-310.pyc b/lib/checks/__pycache__/check-links.cpython-310.pyc deleted file mode 100644 index 4fc6b746504ea50ff0d556d17c5e0d41c9b48b6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5842 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 diff --git a/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc b/lib/checks/__pycache__/check-readme-edits.cpython-310.pyc deleted file mode 100644 index 6eb4ca82fce5980dd4238dec331d8b3f40153493..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4080 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 diff --git a/lib/checks/__pycache__/check-template.cpython-310.pyc b/lib/checks/__pycache__/check-template.cpython-310.pyc deleted file mode 100644 index d49b2561d824ecb8c9fd9a6115aa3b9f6c3ec9e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5359 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 diff --git a/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc b/lib/checks/__pycache__/check-yaml-diff.cpython-310.pyc deleted file mode 100644 index 51c91bfd2bd882b16391f3f81379554355e72e06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9772 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 diff --git a/lib/checks/__pycache__/detect-changes.cpython-310.pyc b/lib/checks/__pycache__/detect-changes.cpython-310.pyc deleted file mode 100644 index 89c86010b6f042edb3abb57013bcbbd2161f57d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1836 zcmZux-ER{|5Z~SV@cA==q!6{Jx*}A`YHiwwwh{_a1ED}glZb>?SVE`s?Z&=vzH@fh zF}BW9EAxt~KJ@`bvLE?}`mnEk>c4<g(b;tf5%g9wyE{8OH#_s2$*R>7f^X1#8UN`Z z^taxe|BA5r9!PLt6txh=lq9%?%}iRv%vQ@Xv)!^8Ido?32fV<%IbMubky@=1wW;$Q zwaV0`1(++WO1-Cqf5$3Qgb^ishU&%fv(grmERw<>cH^k)AH)e0e$)-q4x@gX$suE@ zFT2d&edxDC!Td0#{=GY!Tg}oQgKKwVd2i76HzOHmsqnLb><^^4;g7;z5}exk(oeHA zI4^0I{(%>x?eR)DWFqUaMxV#WVKi!vdPzj|+39D)>;c^d5-ng79pV`TW{xK$C;SU( z<%E(c`F(X_<<`v3(Mv?F90Rvc>^a8h7YMBlV`{<E(H{C5iEBfU>+x8J17w=&&bF%F zy}y6&(XC+T(f;nEeTBo<_@=_8x}!)YRDq?(G0#$Eb(mCk)@P}5hCG(6ZYw(tdrUdU zVKQK<ya+^~<Eik$E5w(;_t#gFxP3k9vgoO}PEFt&ix`P!f23B7sh|_f?w}nkEG+AH zSAbQpjVpK!Uwvc0;TJ#<0iAsXSZOk0`U@61Od2&W@C4=PkWfowg1Rpt2sxTrb2P!y zK6Ge-dbF71Ll**8Iw2h!^kB#WEx&LkgjQ(vIm*eQH!IH3tTabkXo@G6EYsQ)f|y%d z=t*&6!+t5ZU!oJDrAOb&N^Tvai6g6YnO*=3pf|;E)0H)PQ7+}q6wj9DXi6{w$qGo^ zbCNP$%^mRQqa6G?v9(Q;qD$N3D+|P)fph`cIQ2uoXdSCEWg_BnUjuSnYBV%z8=M{N zkFVU)0EY1Tu=Qo;)A-=P4?AI;3Rx%0<Lqb<b4HcjWl0~h97C41(zx7c==3(SG#QQE zW>Z%H<h&MzeL3LlG+D})?3q-K$*{7bA&u}^iV)Jcwt)74#44~3q~eJ?!8zt6$A6F` zC`<zs!(I_ZnqTo6D83VN4G2|QPhugXtk>VTs*P#Ot7yE|gQqmtlPn4ou>p&>O5@t? z(~>sPx-llc|G%yYZEX$MZ`!qjc1@zE*3VETN5o_RZ$pfWk2imM5d3ie!5#hyD8KJs zalOM*_N>o0>e`;JVe;!GG`{?ve{Z!^<$QBc%kQ^~arON9&GE{I9pz^Ig7QM%>4#h} zRomp9L64<!S8uthgp>wwYGkTpl=NPxk{Go6Jd2nR%Ho4mc^WSP07O-GAMX5k=k|W^ zaA#*<xk6GXN#zI$;DUSoB$i2>GNBx3Ua91t!ZVc>rXy3H$^u2@B-xPhdO^A7pIq7I zuP$d|kVsWM?U1HVh()S>FvCC7Ot*{$GfB5~^e%%;=$u#ZGWM`dE@BrG?Ebsvdc@N* zkX!^>Bm5gsQ(h40dqGgIsX`FYED8dy4RBYp&$W&vMnc*BP<B;;#@sZrLf95Y0tPf_ z;FeCNd8K*anR%@ar|-NqE90`a-plA9VH>&(46bbCIUYFf;cLVup5+z1k4xBo^B<H) B>01B* diff --git a/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc b/lib/checks/__pycache__/warn-non-yaml.cpython-310.pyc deleted file mode 100644 index 53295211e3ced8b7d90b3ec0a65ca9afa2498c2f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2220 zcmb7F-ER{|5Z}E!pD$<UJA{-VbyZraPO$+YA)qKCgixr+hms(K5xShc8~e=p&e>gK ze6pXyW235ls8pdyw)(`sw6A^YKhOuHmd;)Xp@3BN_I7q>c6N4l=eM)*=%|a}`>pgc zxS2)hcg?*2jR12Ks`v#6MP)=WH9}m*X={{?v^C3S+FE6c8I8=q{)lFoGsH)wJVMQ~ z%dAFjkRPJ>66lT&#;7&Kc~l-}<NPYkQ2PanOq$hgd4f801lmb9Mct<czsDx`5k}P5 zN^2pP{NO(Hc_c`u&LZNIjZM;wX|NkGL3V=>Xe{fD6Iu6RxWh!;WW^Q_p83^osoM-o z?sh#81b+6s#j27~vKw>qkX1w=8F|p6J~SX%4MY{Ji}R$?mZa%-NyI>zJ(t`koIMLz zr{w+tCxzCM=}#=AoV9gtdHw$SW)*9#x1a8tP?w>KJP?X|=p}6K(A+`=b1litKfZjq znOO6WFE(GHS6E@e^@NnXo)3e{g=(EupNb0|IEP{sN5!rW`)YNSb0hSd73wceYU2iw zJmL^(^pOYJi_`-PIz0p{<F%tzwBTetZfjPXcxTc=RbbzJ4)k<Qtz7}T5;ao59N<1! zZWz?0mJafPiGd%OL)6F8YGfeb5b7Ro*sy!|(5P9U2SsLS?jX}QXr7L~Ks}@33`T}% z;11Dkw2%8H9ZThA%2_>2d`rQ9nUi@szK`W-V~kE57_gRg8$HSQGcrdfdzqK$&^Q^} zyCcUN6N5=QHN<_pXY*g_G@Yp#{cJib*U$8_gDHAS&!e-4<`84F=clsMx}WyubWcwA zZ2H;0@#8ei$qZ)bnZc=Ec8KYj(@2j$go|WB*@xY0KQAHia|h(QJkr1hnXZNTwS;J3 z6TcDz-gH<WEv?>9F-@uf<{A?Pta6lzDi2yRh@-?U7AwABMb37&le4#=OKO1xaPATr z6Sx$+#IN~5BxJ!*4rhDqfHSJBItyFM1XX2er6gA@Mm}7VI10OoT`JvOS-M@Y)L7MT z$u?&mIMix`vIOv}Oo~2bRy*`xu+nhTINgFOW`NWn+)wO2?qP29@K44b+~E^sy)u+h zx~TB(i6{k|a&Lrzkkz=^TKrs_(spN1GS`HSlx~D^)eprYbdHriazZ0Gbz<(=2}e62 z(NpsSN8mh1^dWn9?%iME8sQ%}!}UH0)YOYPlw3)Z$N$Ya*I^+`C#S?9L`{EpZ~N|p zTi(|8%7(Y~V0CqA^HFm4+&Qv#9P{O)aHn_(qMDmr`|o9UK9PcUuDgj@Bs;J1jzUSi zNEQ~>V#%&AERfY$NS)n?sK@P)5*_-ZMA{{vG*vor?=2eBocI=0#HTdSnDoPUxlv(X zVYe`=9G}-(J{L@lE%92r$s)O-S+4Rh&4bZYrrcCX^Fq0zU1{;S%7joRZ%4|}NcRBR zD!;M0{{71Gwzs*yzO8H_Dd1COgoJAW^IBmb!ysZpWgzWGQrTS=hH*!kqAOB$#<?<L zp|UJ`7VtQNtH40bZ?#wiw<Cr|WjY+517$VhASyV@PF>^5N}aPg6YWr{(W8@~Ui$DT z2QICrb1|O=$f&$%H=921rlTs8`t$+5l%-LvK_;#O$>Ry^V9S`oHa4*R*PJ~DoQ0<h z2hZZXX<@@Ku=TfN&0rTw-k8N-ys`e|*YtXh=V@!6SD02=&!chG^Y~d9;ooRNG${a4 zR?C-lm8AjKw_auaiby43KyL(QozuFfG+EFS>_GTZcKLZNDQb!9wILeSIE*>}T1$^T x&=$dfP6Y1}{ffZr1P^sPWQ+Ro5odr{$jLacV+Vg}SjL<=gB{cP%Ei{3e*o$%PP703 diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py index 474c651..b637d89 100644 --- a/lib/checks/check-additions.py +++ b/lib/checks/check-additions.py @@ -86,12 +86,13 @@ def check_required_fields(diff, head): 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 not fields.get(f): + if f in changed and not fields.get(f): missing.add(f) if missing: names = ", ".join(f"`{f}`" for f in sorted(missing)) @@ -120,16 +121,12 @@ def check_open_source(diff): def check_single_entry(diff): - """Return a finding if the diff contains multiple service or section changes.""" + """Return a finding if the diff adds multiple new services or sections.""" services = diff.get("services", {}) - svc_count = ( - len(services.get("added", [])) - + len(services.get("removed", [])) - + len(services.get("modified", [])) - ) - if svc_count > 1: + added_count = len(services.get("added", [])) + if added_count > 1: return MULTIPLE_MSG - if svc_count == 0: + if added_count == 0: sec_count = len(diff.get("sections", [])) if sec_count > 1: return MULTIPLE_MSG From 3b6a18fdecb57e75009a7adcbc0c83c5c8d1f214 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Mon, 23 Feb 2026 21:53:33 +0000 Subject: [PATCH 14/19] Updates contributing and PR checks --- .github/CONTRIBUTING.md | 13 ++-- lib/checks/check-additions.py | 108 +++++++++++++++++++++++++++++++++- lib/checks/check-project.py | 22 +++++-- lib/checks/check-yaml-diff.py | 14 +++-- 4 files changed, 135 insertions(+), 22 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index de71b2b..b9e07ef 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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 diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py index b637d89..af51e03 100644 --- a/lib/checks/check-additions.py +++ b/lib/checks/check-additions.py @@ -35,6 +35,22 @@ OPENSOURCE_MSG = ( 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): @@ -81,7 +97,7 @@ def check_required_fields(diff, head): for svc in diff.get("services", {}).get("added", []): fields = svc.get("fields", {}) for f in REQUIRED_FIELDS: - if not fields.get(f): + if fields.get(f) is None: missing.add(f) for svc in diff.get("services", {}).get("modified", []): if not head: @@ -92,7 +108,7 @@ def check_required_fields(diff, head): ) if fields: for f in REQUIRED_FIELDS: - if f in changed and not fields.get(f): + if f in changed and fields.get(f) is None: missing.add(f) if missing: names = ", ".join(f"`{f}`" for f in sorted(missing)) @@ -133,6 +149,75 @@ def check_single_entry(diff): 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: @@ -158,6 +243,25 @@ def main(): 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 diff --git a/lib/checks/check-project.py b/lib/checks/check-project.py index ffa78db..abb2d87 100644 --- a/lib/checks/check-project.py +++ b/lib/checks/check-project.py @@ -126,21 +126,31 @@ def get_services(diff, key): def check_links(diff, head): - """Return LINK_MSG if any service URL is unreachable.""" + """Return LINK_MSG if any service URL or icon URL is unreachable.""" for svc in get_services(diff, "added"): - url = svc.get("fields", {}).get("url") + 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"): - if "url" not in svc.get("changed_fields", []): + 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: - url = head_svc.get("url") - if url and not check_url(url): - return LINK_MSG + 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 diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py index 44a5285..05cafe9 100644 --- a/lib/checks/check-yaml-diff.py +++ b/lib/checks/check-yaml-diff.py @@ -187,16 +187,18 @@ def main(): write_github_output("has_service_changes", str(bool(added or removed or modified)).lower()) write_step_summary(diff_result) - svc_count = len(added) + len(removed) + len(modified) - if svc_count > 1: - print(red(f"Single-entry rule violation: {svc_count} service changes found."), file=sys.stderr) + 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) - if svc_count == 0 and len(sections) > 1: + if added_count == 0 and len(sections) > 1: print(red(f"Single-entry rule violation: {len(sections)} section changes found."), file=sys.stderr) sys.exit(EXIT_RULE_VIOLATION) - print(green(f"Single-entry rule passed. {svc_count} service, " - f"{len(sections)} section, {len(categories)} category change(s).")) + 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) From 9a9c452b00efd9d3d8ec52667f638781c72f6720 Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Mon, 23 Feb 2026 21:58:58 +0000 Subject: [PATCH 15/19] Fix check for multiple changes --- lib/checks/check-additions.py | 5 +++-- lib/checks/check-yaml-diff.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py index af51e03..30cad8a 100644 --- a/lib/checks/check-additions.py +++ b/lib/checks/check-additions.py @@ -143,8 +143,9 @@ def check_single_entry(diff): if added_count > 1: return MULTIPLE_MSG if added_count == 0: - sec_count = len(diff.get("sections", [])) - if sec_count > 1: + 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 diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py index 05cafe9..a29e096 100644 --- a/lib/checks/check-yaml-diff.py +++ b/lib/checks/check-yaml-diff.py @@ -191,8 +191,9 @@ def main(): if added_count > 1: print(red(f"Single-entry rule violation: {added_count} service additions found."), file=sys.stderr) sys.exit(EXIT_RULE_VIOLATION) - if added_count == 0 and len(sections) > 1: - print(red(f"Single-entry rule violation: {len(sections)} section changes found."), file=sys.stderr) + 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) From da8c5bba756ee61ae74a1fcc395706410dcfd7a4 Mon Sep 17 00:00:00 2001 From: Liss-Bot <alicia-gh-bot@mail.as93.net> Date: Mon, 23 Feb 2026 22:02:25 +0000 Subject: [PATCH 16/19] Updated Awesome Privacy content (last modified on 23-Feb-2026) --- .github/README.md | 70 +++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/.github/README.md b/.github/README.md index f61fd3b..0caa538 100644 --- a/.github/README.md +++ b/.github/README.md @@ -186,7 +186,7 @@ free plan. > - [Password Safe](https://www.pwsafe.org) - An offline, open source password manager designed by [Bruce Schneier](https://www.schneier.com/academic/passsafe/), with native applications for Windows, Linux, MacOS, Android and iOS, and support for YubiKey. The UI is a little dated, and there is no official browser extension, making is slightly less convenient to use compared with other options > - [PassBolt](https://www.passbolt.com) - A good option for teams. It is free, open source, self-hosted, extensible and OpenPGP based. It is specifically good for development and DevOps usage, with integrations for the terminal, browser and chat, and can be easily extended for custom usage, and deployed quickly with Docker -> - [1Password](https://1password.com) - (proprietary) A fully-featured cross-platform password manager with sync. 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 +> - [1Password](https://1password.com) - (proprietary) A fully-featured cross-platform password manager with sync. 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 </details> <details> @@ -278,9 +278,9 @@ and HMAC-Based One-Time Password (HOTP, specified in [RFC 4226](https://tools.ie Λ™ </details> -- **[<img src='https://raw.githubusercontent.com/ente-io/ente/main/auth/assets/icons/auth-icon.ico' width='16' height='16' alt='icon' /> Ente Auth](https://ente.io/auth/)** - 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 +- **[<img src='https://raw.githubusercontent.com/ente-io/ente/main/auth/assets/icons/auth-icon.ico' width='16' height='16' alt='icon' /> Ente Auth](https://ente.io/auth/)** - 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. […](https://awesome-privacy.xyz/essentials/2-factor-authentication/ente-auth "View full Ente Auth report") - <details> @@ -346,8 +346,8 @@ It features small explicit keys, no config options, and UNIX-style composability Λ™ </details> -- **[<img src='https://avatars.githubusercontent.com/u/171401041' width='16' height='16' alt='icon' /> Picocrypt](None)** - 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, +- **[<img src='https://avatars.githubusercontent.com/u/171401041' width='16' height='16' alt='icon' /> Picocrypt]()** - 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, with a focus on security, simplicity, and reliability. […](https://awesome-privacy.xyz/essentials/file-encryption/picocrypt "View full Picocrypt report") - <details> @@ -565,10 +565,10 @@ meta data. </details> - **[<img src='https://github.com/simplex-chat/simplex-chat/blob/stable/media-logos/simplex-symbol-light.png' width='16' height='16' alt='icon' /> SimpleX](https://simplex.chat/)** - 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/). […](https://awesome-privacy.xyz/communication/encrypted-messaging/simplex "View full SimpleX report") - <details> @@ -742,11 +742,11 @@ full-featured free plan and premium subscription plans allowing for 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. […](https://awesome-privacy.xyz/communication/encrypted-email/tuta "View full Tuta report") - <details> @@ -1061,7 +1061,7 @@ Native apps for Android, iOS, Windows, GNU/Linux and MacOS. ### Virtual Phone Numbers -- **[<img src='https://i.ibb.co/2t4MBFj/apple-touch-icon.png' width='16' height='16' alt='icon' /> SMSPool](https://www.smspool.net)** - Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers. +- **[<img src='https://i.ibb.co/2t4MBFj/apple-touch-icon.png' width='16' height='16' alt='icon' /> SMSPool](https://www.smspool.net)** - 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. […](https://awesome-privacy.xyz/communication/virtual-phone-numbers/smspool "View full SMSPool report") - <details> @@ -1474,7 +1474,7 @@ and other features you may expect of a full-featured translation solution in-bro <details> <summary>✳️ <b>Notable Mentions</b></summary> -> - [Extension source viewer](https://addons.mozilla.org/en-US/firefox/addon/crxviewer) - 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 +> - [Extension source viewer](https://addons.mozilla.org/en-US/firefox/addon/crxviewer) - 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 </details> <p align="right"><sup><a href="#top">⬆️ [Back to Top]</a></sub></p> @@ -2830,7 +2830,7 @@ who have legal right to view your data. ### DNS Hosting - **[<img src='https://desec.io/favicon.svg' width='16' height='16' alt='icon' /> deSEC](https://desec.io)** - 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. […](https://awesome-privacy.xyz/networking/dns-hosting/desec "View full deSEC report") - <details> <summary>Stats</summary> @@ -2991,7 +2991,7 @@ month for 10GB, additional plans go up-to 2TB. <summary>✳️ <b>Notable Mentions</b></summary> > 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 @@ -3581,7 +3581,7 @@ for Windows, implemented by Bogdan Hrastnik. > > 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 @@ -3684,7 +3684,7 @@ 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. -- **[<img src='https://grapheneos.org/apple-touch-icon.png' width='16' height='16' alt='icon' /> GrapheneOS](https://grapheneos.org/)** - GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay. +- **[<img src='https://grapheneos.org/apple-touch-icon.png' width='16' height='16' alt='icon' /> GrapheneOS](https://grapheneos.org/)** - 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. […](https://awesome-privacy.xyz/operating-systems/mobile-operating-systems/grapheneos "View full GrapheneOS report") - <details> @@ -3694,8 +3694,8 @@ GrapheneOS is a young project, and currently only supports Pixel devices, partia Λ™ </details> -- **[<img src='https://calyxos.org/assets/images/favicon/apple-touch-icon.png' width='16' height='16' alt='icon' /> CalyxOS](https://calyxos.org)** - 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 +- **[<img src='https://calyxos.org/assets/images/favicon/apple-touch-icon.png' width='16' height='16' alt='icon' /> CalyxOS](https://calyxos.org)** - 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. […](https://awesome-privacy.xyz/operating-systems/mobile-operating-systems/calyxos "View full CalyxOS report") - <details> @@ -3705,8 +3705,8 @@ only supports Pixel devices and Xiaomi Mi A2 with Fairphone 4, OnePlus 8T, OnePl Λ™ </details> -- **[<img src='https://divestos.org/images/favicon.png' width='16' height='16' alt='icon' /> DivestOS](https://divestos.org)** - 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 +- **[<img src='https://divestos.org/images/favicon.png' width='16' height='16' alt='icon' /> DivestOS](https://divestos.org)** - 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. […](https://awesome-privacy.xyz/operating-systems/mobile-operating-systems/divestos "View full DivestOS report") - <details> @@ -3716,7 +3716,7 @@ solely by Tad (SkewedZeppelin) since 2014. Λ™ </details> -- **[<img src='https://www.lineageos.org/images/logo.png' width='16' height='16' alt='icon' /> LineageOS](https://www.lineageos.org)** - A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained, +- **[<img src='https://www.lineageos.org/images/logo.png' width='16' height='16' alt='icon' /> LineageOS](https://www.lineageos.org)** - 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. […](https://awesome-privacy.xyz/operating-systems/mobile-operating-systems/lineageos "View full LineageOS report") - <details> @@ -3903,7 +3903,7 @@ which are not available in a standard Windows 10 instance. It does require 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 @@ -4106,7 +4106,7 @@ 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. </details> @@ -4188,7 +4188,7 @@ decrease your privacy. Never use a free anti-virus, and never trust the companie 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. </details> @@ -4334,7 +4334,7 @@ you are happy with how it works, from a privacy perspective. - **[<img src='https://avatars.githubusercontent.com/u/7365162?s=200&v=4' width='16' height='16' alt='icon' /> Gladys Assistant](https://gladysassistant.com/)** - 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, ...). […](https://awesome-privacy.xyz/smart-home-and-iot/smart-home/gladys-assistant "View full Gladys Assistant report") - <details> @@ -4407,7 +4407,7 @@ and be very weary crypto-related scams are very common. as is and cryptocurrency > 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/). > </details> <details> @@ -4774,12 +4774,12 @@ without algorithmic timeline manipulations. It operates across independent serve Λ™ </details> -- **[<img src='https://icon.horse/icon/github.com' width='16' height='16' alt='icon' /> nostr](https://github.com/nostr-protocol/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 I’m looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr. +- **[<img src='https://icon.horse/icon/github.com' width='16' height='16' alt='icon' /> nostr](https://github.com/nostr-protocol/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 I’m looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr. […](https://awesome-privacy.xyz/social/social-networks/nostr "View full nostr report") - <details> <summary>Stats</summary> @@ -5291,9 +5291,9 @@ easily extendable via community plugins, or by writing Python scripts 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 […](https://awesome-privacy.xyz/creativity/audio-editors-and-recorders/audacity "View full Audacity report") - <details> From 5391cc66b8073835832e1c13df6f14870b66cdb7 Mon Sep 17 00:00:00 2001 From: Liss-Bot <alicia-gh-bot@mail.as93.net> Date: Mon, 23 Feb 2026 22:03:56 +0000 Subject: [PATCH 17/19] Updated Awesome Privacy content (last modified on 23-Feb-2026) --- .github/README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/README.md b/.github/README.md index 0caa538..afecb18 100644 --- a/.github/README.md +++ b/.github/README.md @@ -107,7 +107,7 @@ corporations, governments, and hackers from logging, storing or selling your per - [E-Book Readers](#e-book-readers) (0) - [Podcast Players](#podcast-players) (0) - [Torrent Downloaders](#torrent-downloaders) (0) - - [File Converters](#file-converters) (0) + - [File Converters](#file-converters) (1) - **Creativity** - [Image Editors](#image-editors) (8) - [Video Editors](#video-editors) (7) @@ -5107,7 +5107,19 @@ It is built upon the [Invidious](https://invidious.io/) API. ### File Converters -<p align="center"><b>⚠️ This section is still a work in progress ⚠️</b><br /><i>Check back soon, or help us complete it by submitting a pull request</i></p> +- **[<img src='https://ffmpeg.org/favicon.ico' width='16' height='16' alt='icon' /> FFmpeg](https://ffmpeg.org/)** - A complete, cross-platform solution to record, convert, and stream audio and +video. It's the industry standard multimedia framework, handling a vast range +of formats. As a command-line tool, it guarantees that all processing is done +locally on your machine. +[…](https://awesome-privacy.xyz/media/file-converters/ffmpeg "View full FFmpeg report") + - <details> + <summary>Stats</summary> + + [![GitHub: FFmpeg/FFmpeg](https://img.shields.io/github/stars/FFmpeg/FFmpeg?style=flat&logo=github&label=FFmpeg&color=%235f53f4&cacheSeconds=3600)](https://github.com/FFmpeg/FFmpeg) [![FFmpeg on Awesome Privacy](https://img.shields.io/badge/View%20Report-FC60A8?style=flat&logo=awesomelists&label=FFmpeg)](https://awesome-privacy.xyz/media/file-converters/ffmpeg) +πŸ“¦ Open Source Λ™ + + </details> + <p align="right"><sup><a href="#top">⬆️ [Back to Top]</a></sub></p> --- From 2b7a5a02f4de39082ec98382bfae593eea479114 Mon Sep 17 00:00:00 2001 From: Liss-Bot <alicia-gh-bot@mail.as93.net> Date: Mon, 23 Feb 2026 22:26:52 +0000 Subject: [PATCH 18/19] Updated Awesome Privacy content (last modified on 23-Feb-2026) --- .github/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/README.md b/.github/README.md index afecb18..5096200 100644 --- a/.github/README.md +++ b/.github/README.md @@ -408,7 +408,7 @@ Brave also has Tor built-in, when you open up a private tab/ window. Λ™ </details> -- **[<img src='https://www.mozilla.org/media/protocol/img/logos/firefox/logo.fedb52c912d6.svg' width='16' height='16' alt='icon' /> Firefox](https://www.mozilla.org/firefox)** - Significantly more private, and offers some nifty privacy features than Chrome, +- **[<img src='https://www.mozilla.org/media/protocol/img/logos/firefox/logo.fedb52c912d6.svg' width='16' height='16' alt='icon' /> Firefox](https://www.firefox.com/)** - Significantly more private, and offers some nifty privacy features than Chrome, Internet Explorer and Safari. After installing, there are a couple of small tweaks you will need to make, in order to secure Firefox. For a though config, see [@arkenfox's user.js](https://github.com/arkenfox/user.js/). You can also follow From 5e57a1ca0c91ff9161f829515522067f06abb5ea Mon Sep 17 00:00:00 2001 From: Alicia Sykes <alicia@omg.lol> Date: Tue, 24 Feb 2026 12:32:50 +0000 Subject: [PATCH 19/19] Updates checks script --- lib/checks/check-additions.py | 30 +++++++++++++++++++++++------- lib/checks/check-readme-edits.py | 18 ------------------ lib/checks/check-yaml-diff.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/lib/checks/check-additions.py b/lib/checks/check-additions.py index 30cad8a..f972f1e 100644 --- a/lib/checks/check-additions.py +++ b/lib/checks/check-additions.py @@ -150,30 +150,46 @@ def check_single_entry(diff): return None -def build_name_index(head): - """Build {lowercase_name: "category > section"} from all services.""" +def _added_keys(diff): + """Build a set of (category, section, lowercase_name) for added services.""" + keys = set() + for svc in diff.get("services", {}).get("added", []): + name = svc.get("fields", {}).get("name", "").lower().strip() + keys.add((svc.get("category", ""), svc.get("section", ""), name)) + return keys + + +def build_name_index(head, diff): + """Build {lowercase_name: "category > section"} from all services, excluding additions.""" index = {} if not head: return index + exclude = _added_keys(diff) 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: + if name and (cn, sn, name) not in exclude: index[name] = f"{cn} > {sn}" return index -def build_url_index(head): - """Build {url: service_name} from all services, skipping empty URLs.""" +def build_url_index(head, diff): + """Build {url: service_name} from all services, excluding additions.""" index = {} if not head: return index + exclude = _added_keys(diff) 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 (cn, sn, name) in exclude: + continue url = svc.get("url", "") if url: index[url] = svc.get("name", "") @@ -245,8 +261,8 @@ def main(): if finding: findings.append(finding) - name_index = build_name_index(head) - url_index = build_url_index(head) + name_index = build_name_index(head, diff) + url_index = build_url_index(head, diff) finding = check_duplicate_name(diff, name_index) if finding: diff --git a/lib/checks/check-readme-edits.py b/lib/checks/check-readme-edits.py index aa733b5..4aa5598 100644 --- a/lib/checks/check-readme-edits.py +++ b/lib/checks/check-readme-edits.py @@ -72,23 +72,6 @@ def get_changed_line_numbers(base_ref): 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") @@ -112,7 +95,6 @@ def main(): 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.")) diff --git a/lib/checks/check-yaml-diff.py b/lib/checks/check-yaml-diff.py index a29e096..5ddc798 100644 --- a/lib/checks/check-yaml-diff.py +++ b/lib/checks/check-yaml-diff.py @@ -90,6 +90,23 @@ def write_github_output(name, value): f.write(f"{name}={value}\n") +def find_duplicate_names(data): + """Find duplicate service names within the same section.""" + duplicates = [] + for cat in data.get("categories", []): + cn = cat.get("name", "") + for sec in cat.get("sections", []): + sn = sec.get("name", "") + seen = {} + for svc in sec.get("services", []): + name = svc.get("name", "") + if name in seen: + duplicates.append((cn, sn, name)) + else: + seen[name] = True + return duplicates + + def fmt_path(key): """Format a tuple key as a readable path.""" return " β†’ ".join(key) if isinstance(key, tuple) else key @@ -126,6 +143,11 @@ def write_step_summary(diff_result): bullets.append(f"- Added category **{change['category']}**") else: bullets.append(f"- Removed category **{change['category']}**") + for dup in diff_result.get("duplicates", []): + bullets.append( + f"- ⚠️ Duplicate service name **{dup['service']}** " + f"in {dup['category']} β†’ {dup['section']}" + ) if bullets: lines.extend(bullets) @@ -175,10 +197,15 @@ def main(): for k in cat_removed: categories.append({"category": k, "change_type": "removed_category"}) + duplicates = find_duplicate_names(head) + dup_entries = [{"category": d[0], "section": d[1], "service": d[2]} + for d in duplicates] + diff_result = { "services": {"added": added, "removed": removed, "modified": modified}, "sections": sections, "categories": categories, + "duplicates": dup_entries, } with open(DIFF_OUTPUT_PATH, "w") as f: @@ -196,6 +223,11 @@ def main(): print(red(f"Single-entry rule violation: {len(added_sections)} section additions found."), file=sys.stderr) sys.exit(EXIT_RULE_VIOLATION) + if duplicates: + names = ", ".join(f"{d[2]} (in {d[0]} β†’ {d[1]})" for d in duplicates) + print(red(f"Duplicate service names found: {names}"), 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, "