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/.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/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 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..d30babe --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,156 @@ +name: PR Check + +on: + pull_request: + branches: [main] + types: [opened, edited, synchronize, reopened] + paths: + - 'awesome-privacy.yml' + - '.github/README.md' + +permissions: + contents: read + pull-requests: read + +jobs: + pr-compliance: + name: PR Compliance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Check README edits + id: readme + continue-on-error: true + run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }} + - name: Check PR metadata + id: meta + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + README_FAILED: ${{ steps.readme.outcome == 'failure' && 'true' || 'false' }} + run: python lib/checks/check-pr-meta.py + - name: Upload findings + if: always() + uses: actions/upload-artifact@v4 + with: + name: findings-compliance + path: /tmp/findings-compliance.json + if-no-files-found: ignore + - name: Fail if critical + if: steps.readme.outcome == 'failure' || steps.meta.outcome == 'failure' + run: exit 1 + + data-validation: + name: Data Validation + runs-on: ubuntu-latest + outputs: + yaml_changed: ${{ steps.changes.outputs.yaml_changed }} + steps: + - uses: actions/checkout@v4 + - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }} + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Detect changes + id: changes + run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }} + - name: Install dependencies + if: steps.changes.outputs.yaml_changed == 'true' + run: pip install -q -r lib/requirements.txt + - name: Schema validation + if: steps.changes.outputs.yaml_changed == 'true' + id: schema + continue-on-error: true + run: make validate + - name: YAML diff + if: steps.changes.outputs.yaml_changed == 'true' + id: diff + continue-on-error: true + run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }} + - name: Check additions + if: steps.changes.outputs.yaml_changed == 'true' + env: + SCHEMA_OUTCOME: ${{ steps.schema.outcome }} + run: python lib/checks/check-additions.py + - name: Upload diff data + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-diff + path: /tmp/pr-diff.json + if-no-files-found: ignore + - name: Upload findings + if: always() + uses: actions/upload-artifact@v4 + with: + name: findings-data + path: /tmp/findings-data.json + if-no-files-found: ignore + - name: Fail if critical + if: steps.changes.outputs.yaml_changed == 'true' && (steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure') + run: exit 1 + + submission-eligibility: + name: Submission Eligibility + needs: data-validation + if: "!cancelled() && needs.data-validation.outputs.yaml_changed == 'true'" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -q -r lib/requirements.txt + - name: Download diff data + uses: actions/download-artifact@v4 + with: + name: pr-diff + path: /tmp + continue-on-error: true + - name: Check project health + env: + PR_USER: ${{ github.event.pull_request.user.login }} + GITHUB_TOKEN: ${{ github.token }} + run: python lib/checks/check-project.py + - name: Upload findings + if: always() + uses: actions/upload-artifact@v4 + with: + name: findings-project + path: /tmp/findings-project.json + if-no-files-found: ignore + + summary: + name: Summary + if: always() + needs: [pr-compliance, data-validation, submission-eligibility] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Download all findings + uses: actions/download-artifact@v4 + with: + pattern: findings-* + path: /tmp/artifacts + merge-multiple: true + continue-on-error: true + - name: Format comment + env: + PR_USER: ${{ github.event.pull_request.user.login }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_ID: ${{ github.run_id }} + run: python lib/checks/format-comment.py + - name: Upload PR metadata + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-meta + path: /tmp/pr-meta/ diff --git a/.github/workflows/pr-comment.yml b/.github/workflows/pr-comment.yml new file mode 100644 index 0000000..44e6c33 --- /dev/null +++ b/.github/workflows/pr-comment.yml @@ -0,0 +1,94 @@ +name: PR Comment + +on: + workflow_run: + workflows: ["PR Check"] + types: [completed] + +permissions: + actions: read + pull-requests: write + +jobs: + comment: + name: Post PR comment + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' + + steps: + - name: Download PR metadata + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: pr-meta + path: pr-meta + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Post comment + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const marker = ''; + + // Check if there are findings to post + const commentFile = 'pr-meta/comment.md'; + if (!fs.existsSync(commentFile)) { + console.log('No findings to post — skipping.'); + return; + } + + // Determine the PR number + let prNumber; + const numberFile = 'pr-meta/number.txt'; + if (fs.existsSync(numberFile)) { + prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim()); + } + if (!prNumber) { + // workflow_run.pull_requests is empty for fork PRs, so + // fall back to searching by head SHA if needed + const prs = context.payload.workflow_run.pull_requests; + if (prs && prs.length > 0) { + prNumber = prs[0].number; + } else { + const headSha = context.payload.workflow_run.head_sha; + const { data: prList } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'desc', + per_page: 100, + }); + const match = prList.find(pr => pr.head.sha === headSha); + if (!match) { + console.log(`No open PR found for SHA ${headSha} — skipping.`); + return; + } + prNumber = match.number; + } + } + + // Skip if we already commented on this PR + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + if (comments.some(c => c.body.includes(marker))) { + console.log('Bot comment already exists — skipping.'); + return; + } + + // Post the comment + const body = fs.readFileSync(commentFile, 'utf8').trim(); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); 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>_ 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/checks/check-additions.py b/lib/checks/check-additions.py new file mode 100644 index 0000000..30cad8a --- /dev/null +++ b/lib/checks/check-additions.py @@ -0,0 +1,275 @@ +"""Validates data quality for added/modified services using the diff JSON.""" + +import json +import os +import sys + +import yaml + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") +DIFF_PATH = "/tmp/pr-diff.json" +FINDINGS_PATH = "/tmp/findings-data.json" + +REQUIRED_FIELDS = ("name", "description", "url", "icon") + +CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md" + +SCHEMA_MSG = ( + "Some of the schema checks have failed. Please check that your addition" + " contains all the required fields, with acceptable values, nothing" + " additional and that it is following valid YAML syntax" +) +MULTIPLE_MSG = "Please make just one addition per pull request" +MISSING_TPL = ( + "Did you include all required fields? Looks like {fields} is missing or" + f" invalid. Please see the [required fields]({CONTRIBUTING}#service-fields)" + " for available fields." +) +POSITION_MSG = ( + "New entries must be added to the end of the section, unless otherwise requested" +) +OPENSOURCE_MSG = ( + "You indicated this app/service is not open source. This will likely make" + " it ineligible for listing on Awesome Privacy in accordance with our" + f" [Requirements]({CONTRIBUTING}#requirements)." + " Please ensure that this is justified in your PR body." +) +DUPLICATE_NAME_MSG = ( + "A service named `{name}` already exists (in {location})." + " If this is a different service, please clarify in your PR description" +) +DUPLICATE_URL_MSG = ( + "The URL `{url}` is already associated with `{existing}`." + " Please check this isn't a duplicate submission" +) +DESC_LENGTH_MSG = ( + "Description length ({length} chars) is outside the recommended 50\u2013250" + f" character range. Please see our [Contributing Guidelines]({CONTRIBUTING}#description)" +) +OPENSOURCE_GITHUB_MSG = ( + "You marked this service as open source but didn't include a `github` field." + " Please add the repository link" +) + + +def load_json(path): + """Load JSON from a file, returning None on any error.""" + try: + with open(path) as f: + return json.load(f) + except Exception: + return None + + +def load_yaml_data(path): + """Load YAML from a file, returning None on any error.""" + try: + with open(path) as f: + return yaml.safe_load(f) + except Exception: + return None + + +def find_section_services(head, category, section): + """Return the services list for a category/section pair, or None.""" + for cat in head.get("categories", []): + if cat.get("name") == category: + for sec in cat.get("sections", []): + if sec.get("name") == section: + return sec.get("services", []) + return None + + +def find_service_fields(head, category, section, service_name): + """Look up a service's fields in the head YAML.""" + services = find_section_services(head, category, section) + if services: + for svc in services: + if svc.get("name") == service_name: + return svc + return None + + +def check_required_fields(diff, head): + """Return a finding if any added/modified service is missing required fields.""" + missing = set() + for svc in diff.get("services", {}).get("added", []): + fields = svc.get("fields", {}) + for f in REQUIRED_FIELDS: + if fields.get(f) is None: + missing.add(f) + for svc in diff.get("services", {}).get("modified", []): + if not head: + continue + changed = svc.get("changed_fields", []) + fields = find_service_fields( + head, svc["category"], svc["section"], svc["service"] + ) + if fields: + for f in REQUIRED_FIELDS: + if f in changed and fields.get(f) is None: + missing.add(f) + if missing: + names = ", ".join(f"`{f}`" for f in sorted(missing)) + return MISSING_TPL.format(fields=names) + return None + + +def check_position(diff, head): + """Return a finding if a newly added service is not at the end of its section.""" + if not head: + return None + for svc in diff.get("services", {}).get("added", []): + services = find_section_services(head, svc["category"], svc["section"]) + if services and services[-1].get("name") != svc["service"]: + return POSITION_MSG + return None + + +def check_open_source(diff): + """Return a finding if an added service has openSource missing or not true.""" + for svc in diff.get("services", {}).get("added", []): + fields = svc.get("fields", {}) + if fields.get("openSource") is not True: + return OPENSOURCE_MSG + return None + + +def check_single_entry(diff): + """Return a finding if the diff adds multiple new services or sections.""" + services = diff.get("services", {}) + added_count = len(services.get("added", [])) + if added_count > 1: + return MULTIPLE_MSG + if added_count == 0: + added_sections = [s for s in diff.get("sections", []) + if s.get("change_type") == "added_section"] + if len(added_sections) > 1: + return MULTIPLE_MSG + return None + + +def build_name_index(head): + """Build {lowercase_name: "category > section"} from all services.""" + index = {} + if not head: + return index + for cat in head.get("categories", []): + cn = cat.get("name", "") + for sec in cat.get("sections", []): + sn = sec.get("name", "") + for svc in sec.get("services", []): + name = svc.get("name", "").lower().strip() + if name: + index[name] = f"{cn} > {sn}" + return index + + +def build_url_index(head): + """Build {url: service_name} from all services, skipping empty URLs.""" + index = {} + if not head: + return index + for cat in head.get("categories", []): + for sec in cat.get("sections", []): + for svc in sec.get("services", []): + url = svc.get("url", "") + if url: + index[url] = svc.get("name", "") + return index + + +def check_duplicate_name(diff, name_index): + """Return a finding if an added service name already exists in the YAML.""" + for svc in diff.get("services", {}).get("added", []): + name = svc.get("fields", {}).get("name", "").lower().strip() + if name and name in name_index: + return DUPLICATE_NAME_MSG.format( + name=svc["fields"]["name"], location=name_index[name], + ) + return None + + +def check_duplicate_url(diff, url_index): + """Return a finding if an added service URL already exists in the YAML.""" + for svc in diff.get("services", {}).get("added", []): + url = svc.get("fields", {}).get("url", "") + if url and url in url_index: + return DUPLICATE_URL_MSG.format(url=url, existing=url_index[url]) + return None + + +def check_description_length(diff): + """Return a finding if an added service description is outside 50-250 chars.""" + for svc in diff.get("services", {}).get("added", []): + desc = svc.get("fields", {}).get("description", "") + length = len(desc) + if length < 50 or length > 250: + return DESC_LENGTH_MSG.format(length=length) + return None + + +def check_opensource_github(diff): + """Return a finding if an added service is open source but has no github field.""" + for svc in diff.get("services", {}).get("added", []): + fields = svc.get("fields", {}) + if fields.get("openSource") is True and not fields.get("github"): + return OPENSOURCE_GITHUB_MSG + return None + + +def main(): + findings = [] + try: + if os.environ.get("SCHEMA_OUTCOME") == "failure": + findings.append(SCHEMA_MSG) + + diff = load_json(DIFF_PATH) + head = load_yaml_data(DATA_PATH) + + if diff: + finding = check_single_entry(diff) + if finding: + findings.append(finding) + + finding = check_required_fields(diff, head) + if finding: + findings.append(finding) + + finding = check_position(diff, head) + if finding: + findings.append(finding) + + finding = check_open_source(diff) + if finding: + findings.append(finding) + + name_index = build_name_index(head) + url_index = build_url_index(head) + + finding = check_duplicate_name(diff, name_index) + if finding: + findings.append(finding) + + finding = check_duplicate_url(diff, url_index) + if finding: + findings.append(finding) + + finding = check_description_length(diff) + if finding: + findings.append(finding) + + finding = check_opensource_github(diff) + if finding: + findings.append(finding) + except Exception: + pass + + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-pr-meta.py b/lib/checks/check-pr-meta.py new file mode 100644 index 0000000..aa1379e --- /dev/null +++ b/lib/checks/check-pr-meta.py @@ -0,0 +1,139 @@ +"""Checks PR metadata: title format, draft status, template completeness, and checkboxes.""" + +import json +import os +import re +import sys + +FINDINGS_PATH = "/tmp/findings-compliance.json" + +BAD_TITLES = {"update readme.md", "update awesome-privacy.yml"} + +TITLE_MSG = ( + "The pull request title does not follow the format defined in our guidelines." + " Please rename it to `[Add/Remove/Update] [software name] in [software section]`" +) +DRAFT_MSG = ( + "Please avoid opening WIP pull requests." + " Your PR should be 100% ready and complete before submitting" +) +TEMPLATE_MSG = ( + "Please fill in pull request template in full." + " You can find a copy of this" + " [here](https://github.com/Lissy93/awesome-privacy/blob/main/.github/PULL_REQUEST_TEMPLATE.md)" +) +CHECKBOX_MSG = ( + "Ensure you have completed the checklist (put a tick the checkboxes with `[x]`)," + " to confirm that you've read the contributing guidelines, checked your submission," + " indicated your affiliation and agree to follow our CoC" +) +README_MSG = ( + "Do not edit the README directly. This file is auto-generated from the" + " content in `awesome-privacy.yml`, and so your changes will be overridden!" + " Instead, only modify the YAML file, and be sure to follow our Contributing Guidelines." +) + + +def extract_section(body, header): + """Extract content between a ### header and the next delimiter.""" + pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)" + match = re.search(pattern, body, re.DOTALL) + return match.group(1) if match else None + + +def strip_html_comments(text): + """Remove HTML comments from text.""" + return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL).strip() + + +def check_title(title): + """Return a finding if the PR title matches a known-bad pattern.""" + if title and title.strip().lower() in BAD_TITLES: + return TITLE_MSG + return None + + +def check_draft(draft_str): + """Return a finding if the PR is in draft state.""" + if str(draft_str).lower() == "true": + return DRAFT_MSG + return None + + +def check_template(body): + """Return a finding if required template sections are missing or empty.""" + for header in ("Type", "Changes", "Checklist"): + content = extract_section(body, header) + if content is None or not strip_html_comments(content): + return TEMPLATE_MSG + return None + + +def check_checkboxes(body): + """Return a finding if any checklist checkboxes are unchecked.""" + section = extract_section(body, "Checklist") + if section is None: + return None + checked = re.findall(r"- \[x\]", section, re.IGNORECASE) + unchecked = re.findall(r"- \[ \]", section) + if not checked and not unchecked: + return None + if unchecked: + return CHECKBOX_MSG + return None + + +def check_readme(readme_failed): + """Return a finding if the README check reported a failure.""" + if readme_failed == "true": + return README_MSG + return None + + +def write_findings(findings): + """Write the findings list to the output JSON file.""" + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + + +def main(): + findings = [] + critical = False + try: + title = os.environ.get("PR_TITLE", "") + body = os.environ.get("PR_BODY", "") + draft = os.environ.get("PR_DRAFT", "false") + readme_failed = os.environ.get("README_FAILED", "false") + + finding = check_title(title) + if finding: + findings.append(finding) + + finding = check_draft(draft) + if finding: + findings.append(finding) + + if not body or not body.strip(): + findings.append(TEMPLATE_MSG) + critical = True + else: + finding = check_template(body) + if finding: + findings.append(finding) + critical = True + finding = check_checkboxes(body) + if finding: + findings.append(finding) + + finding = check_readme(readme_failed) + if finding: + findings.append(finding) + except Exception: + pass + + write_findings(findings) + sys.exit(1 if critical else 0) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/check-project.py b/lib/checks/check-project.py new file mode 100644 index 0000000..abb2d87 --- /dev/null +++ b/lib/checks/check-project.py @@ -0,0 +1,227 @@ +"""Checks project health: URL reachability, GitHub repo stars, activity, and author match.""" + +import json +import os +import sys +from datetime import datetime, timezone + +import requests +import yaml + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") +DIFF_PATH = "/tmp/pr-diff.json" +FINDINGS_PATH = "/tmp/findings-project.json" + +TIMEOUT = 10 +USER_AGENT = "awesome-privacy-ci/1.0" +MIN_STARS = 100 +INACTIVE_DAYS = 90 + +LINK_MSG = ( + "Our automated checks were unable to verify the link(s) you included" + " were reachable, so please double check this yourself" +) +AUTHOR_MSG = ( + "Looks like you are the author of this package. Please ensure that you" + " have clearly disclosed this in your PR body for transparency" +) +STARS_MSG = ( + "It looks like your submission is adding a quite small project." + " In some circumstances we may ask you to resubmit this once the project" + " is more mature and has a proven track record of good practices and maintenance." +) +ACTIVITY_MSG = ( + "Please confirm that the project you are adding is actively maintained," + " as it looks to not have had any recent updates in the past 3 months." +) + + +def load_diff(path): + """Load the diff JSON, returning None on any error.""" + try: + with open(path) as f: + return json.load(f) + except Exception: + return None + + +def check_url(url): + """Return True if the URL is reachable, True on any error (no false positives).""" + try: + resp = requests.head( + url, timeout=TIMEOUT, allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + ) + if resp.status_code >= 400: + resp = requests.get( + url, timeout=TIMEOUT, allow_redirects=True, + headers={"User-Agent": USER_AGENT}, stream=True, + ) + resp.close() + return resp.status_code < 400 + except Exception: + return True + + +def parse_github_field(value): + """Parse a github field into (owner, repo), or (None, None) on failure.""" + if not value: + return None, None + if value.startswith("https://github.com/"): + parts = value.removeprefix("https://github.com/").strip("/").split("/") + if len(parts) >= 2: + return parts[0], parts[1] + return None, None + if "/" in value: + parts = value.split("/") + if len(parts) == 2: + return parts[0], parts[1] + return None, None + + +def fetch_repo(owner, repo, token): + """Fetch GitHub repo metadata, returning None on any error.""" + try: + headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT} + if token: + headers["Authorization"] = f"token {token}" + resp = requests.get( + f"https://api.github.com/repos/{owner}/{repo}", + headers=headers, timeout=TIMEOUT, + ) + if resp.status_code == 200: + return resp.json() + except Exception: + pass + return None + + +def load_yaml_data(): + """Load the head YAML, returning None on any error.""" + try: + with open(DATA_PATH) as f: + return yaml.safe_load(f) + except Exception: + return None + + +def find_service_in_head(head, category, section, service_name): + """Look up a service in the head YAML by path.""" + if not head: + return None + for cat in head.get("categories", []): + if cat.get("name") == category: + for sec in cat.get("sections", []): + if sec.get("name") == section: + for svc in sec.get("services", []): + if svc.get("name") == service_name: + return svc + return None + + +def get_services(diff, key): + """Safely extract a service list from the diff.""" + return diff.get("services", {}).get(key, []) + + +def check_links(diff, head): + """Return LINK_MSG if any service URL or icon URL is unreachable.""" + for svc in get_services(diff, "added"): + fields = svc.get("fields", {}) + url = fields.get("url") + if url and not check_url(url): + return LINK_MSG + icon = fields.get("icon") + if icon and not check_url(icon): + return LINK_MSG + for svc in get_services(diff, "modified"): + changed = svc.get("changed_fields", []) + if "url" not in changed and "icon" not in changed: + continue + head_svc = find_service_in_head( + head, svc["category"], svc["section"], svc["service"] + ) + if head_svc: + if "url" in changed: + url = head_svc.get("url") + if url and not check_url(url): + return LINK_MSG + if "icon" in changed: + icon = head_svc.get("icon") + if icon and not check_url(icon): + return LINK_MSG + return None + + +def check_repo_signals(diff, pr_user, token): + """Check GitHub repo author match, stars, and activity for added services.""" + findings = [] + if not token: + return findings + cache = {} + for svc in get_services(diff, "added"): + gh = svc.get("fields", {}).get("github") + owner, repo = parse_github_field(gh) + if not owner: + continue + cache_key = f"{owner}/{repo}" + if cache_key not in cache: + cache[cache_key] = fetch_repo(owner, repo, token) + data = cache[cache_key] + if not data: + continue + + repo_owner = data.get("owner", {}) + if ( + pr_user + and repo_owner.get("type") == "User" + and repo_owner.get("login", "").lower() == pr_user.lower() + and AUTHOR_MSG not in findings + ): + findings.append(AUTHOR_MSG) + + stars = data.get("stargazers_count", 0) + if stars < MIN_STARS and STARS_MSG not in findings: + findings.append(STARS_MSG) + + pushed = data.get("pushed_at") + if pushed and ACTIVITY_MSG not in findings: + try: + pushed_dt = datetime.fromisoformat(pushed.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + if (now - pushed_dt).days > INACTIVE_DAYS: + findings.append(ACTIVITY_MSG) + except Exception: + pass + + return findings + + +def main(): + findings = [] + try: + diff = load_diff(DIFF_PATH) + if not diff: + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + sys.exit(0) + + head = load_yaml_data() + finding = check_links(diff, head) + if finding: + findings.append(finding) + + pr_user = os.environ.get("PR_USER", "") + token = os.environ.get("GITHUB_TOKEN", "") + findings.extend(check_repo_signals(diff, pr_user, token)) + except Exception: + pass + + with open(FINDINGS_PATH, "w") as f: + json.dump(findings, f) + sys.exit(0) + + +if __name__ == "__main__": + main() 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-yaml-diff.py b/lib/checks/check-yaml-diff.py new file mode 100644 index 0000000..a29e096 --- /dev/null +++ b/lib/checks/check-yaml-diff.py @@ -0,0 +1,207 @@ +"""Analyzes the diff between base and head versions of awesome-privacy.yml. +Enforces the single-entry rule and outputs a JSON diff to /tmp/pr-diff.json. +""" + +import argparse +import json +import os +import subprocess +import sys + +import yaml + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml") +DIFF_OUTPUT_PATH = "/tmp/pr-diff.json" + +EXIT_PASS = 0 +EXIT_RULE_VIOLATION = 1 +EXIT_RUNTIME_ERROR = 2 + +_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR") +red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s) +green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s) +yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s) + + +def load_base_yaml(base_ref): + """Load the YAML from the base ref using git show.""" + try: + result = subprocess.run( + ["git", "show", f"{base_ref}:awesome-privacy.yml"], + capture_output=True, text=True, check=True, cwd=PROJECT_ROOT, + ) + return yaml.safe_load(result.stdout) + except subprocess.CalledProcessError: + print(yellow("awesome-privacy.yml not found in base ref, treating as empty"), file=sys.stderr) + return {"categories": []} + except yaml.YAMLError as e: + print(red(f"Failed to parse base YAML: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + +def load_head_yaml(): + """Load the YAML from the current working tree.""" + try: + with open(DATA_PATH) as f: + return yaml.safe_load(f) + except (FileNotFoundError, yaml.YAMLError) as e: + print(red(f"Failed to load head YAML: {e}"), file=sys.stderr) + sys.exit(EXIT_RUNTIME_ERROR) + + +def build_index(data, depth): + """Build a keyed index at the given depth (3=services, 2=sections, 1=categories).""" + index = {} + for cat in data.get("categories", []): + cn = cat.get("name", "") + if depth == 1: + index[cn] = {k: v for k, v in cat.items() if k != "sections"} + continue + for sec in cat.get("sections", []): + sn = sec.get("name", "") + if depth == 2: + index[(cn, sn)] = {k: v for k, v in sec.items() if k != "services"} + continue + for svc in sec.get("services", []): + index[(cn, sn, svc.get("name", ""))] = svc + return index + + +def diff_index(base_idx, head_idx): + """Return (added_keys, removed_keys, modified_keys_with_changed_fields).""" + base_keys, head_keys = set(base_idx), set(head_idx) + added = sorted(head_keys - base_keys) + removed = sorted(base_keys - head_keys) + modified = [] + for key in sorted(base_keys & head_keys): + if base_idx[key] != head_idx[key]: + all_fields = set(base_idx[key]) | set(head_idx[key]) + changed = sorted(f for f in all_fields if base_idx[key].get(f) != head_idx[key].get(f)) + modified.append((key, changed)) + return added, removed, modified + + +def write_github_output(name, value): + """Write a value to $GITHUB_OUTPUT.""" + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{name}={value}\n") + + +def fmt_path(key): + """Format a tuple key as a readable path.""" + return " → ".join(key) if isinstance(key, tuple) else key + + +def write_step_summary(diff_result): + """Write a bullet-point Markdown summary to $GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + + lines = ["## YAML Diff Analysis\n"] + bullets = [] + + for svc in diff_result["services"]["added"]: + bullets.append(f"- Added **{svc['service']}** in {svc['category']} → {svc['section']}") + for svc in diff_result["services"]["removed"]: + bullets.append(f"- Removed **{svc['service']}** from {svc['category']} → {svc['section']}") + for svc in diff_result["services"]["modified"]: + fields = ", ".join(f"`{f}`" for f in svc["changed_fields"]) + bullets.append(f"- Modified {fields} in {svc['category']} → {svc['section']} → {svc['service']}") + for change in diff_result["sections"]: + ct = change["change_type"] + path = f"{change['category']} → {change['section']}" + if ct == "added_section": + bullets.append(f"- Added section **{change['section']}** in {change['category']}") + elif ct == "removed_section": + bullets.append(f"- Removed section **{change['section']}** from {change['category']}") + else: + fields = ", ".join(f"`{f}`" for f in change.get("changed_fields", [])) + bullets.append(f"- Modified section metadata ({fields}) in {path}") + for change in diff_result["categories"]: + if change["change_type"] == "added_category": + bullets.append(f"- Added category **{change['category']}**") + else: + bullets.append(f"- Removed category **{change['category']}**") + + if bullets: + lines.extend(bullets) + else: + lines.append("No changes detected in `awesome-privacy.yml`.") + + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--base-ref", required=True) + args = parser.parse_args() + + base = load_base_yaml(args.base_ref) + head = load_head_yaml() + + svc_added, svc_removed, svc_modified = diff_index( + build_index(base, 3), build_index(head, 3), + ) + sec_added, sec_removed, sec_modified = diff_index( + build_index(base, 2), build_index(head, 2), + ) + cat_added, cat_removed, _ = diff_index( + build_index(base, 1), build_index(head, 1), + ) + + added = [{"category": k[0], "section": k[1], "service": k[2], + "fields": build_index(head, 3)[k]} for k in svc_added] + removed = [{"category": k[0], "section": k[1], "service": k[2]} for k in svc_removed] + modified = [{"category": k[0], "section": k[1], "service": k[2], + "changed_fields": cf} for k, cf in svc_modified] + + sections = [] + for k in sec_added: + sections.append({"category": k[0], "section": k[1], "change_type": "added_section"}) + for k in sec_removed: + sections.append({"category": k[0], "section": k[1], "change_type": "removed_section"}) + for k, cf in sec_modified: + sections.append({"category": k[0], "section": k[1], + "change_type": "modified_section_metadata", "changed_fields": cf}) + + categories = [] + for k in cat_added: + categories.append({"category": k, "change_type": "added_category"}) + for k in cat_removed: + categories.append({"category": k, "change_type": "removed_category"}) + + diff_result = { + "services": {"added": added, "removed": removed, "modified": modified}, + "sections": sections, + "categories": categories, + } + + with open(DIFF_OUTPUT_PATH, "w") as f: + json.dump(diff_result, f, indent=2) + + write_github_output("has_service_changes", str(bool(added or removed or modified)).lower()) + write_step_summary(diff_result) + + added_count = len(added) + if added_count > 1: + print(red(f"Single-entry rule violation: {added_count} service additions found."), file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + added_sections = [s for s in sections if s["change_type"] == "added_section"] + if added_count == 0 and len(added_sections) > 1: + print(red(f"Single-entry rule violation: {len(added_sections)} section additions found."), file=sys.stderr) + sys.exit(EXIT_RULE_VIOLATION) + + total = len(added) + len(removed) + len(modified) + print(green(f"Single-entry rule passed. {total} service " + f"({added_count} added), {len(sections)} section, " + f"{len(categories)} category change(s).")) + sys.exit(EXIT_PASS) + + +if __name__ == "__main__": + main() diff --git a/lib/checks/detect-changes.py b/lib/checks/detect-changes.py new file mode 100644 index 0000000..9fa591d --- /dev/null +++ b/lib/checks/detect-changes.py @@ -0,0 +1,46 @@ +""" +Detects which files changed between the PR base and HEAD. +Sets GitHub Actions output: yaml_changed. +""" + +import argparse +import os +import subprocess +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +YAML_FILE = "awesome-privacy.yml" + + +def write_github_output(name, value): + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{name}={value}\n") + + +def main(): + parser = argparse.ArgumentParser(description="Detect changed files in a PR") + parser.add_argument("--base-ref", required=True, help="Base git ref to diff against") + args = parser.parse_args() + + result = subprocess.run( + ["git", "diff", "--name-only", f"{args.base_ref}..HEAD"], + capture_output=True, text=True, check=True, + cwd=PROJECT_ROOT, + ) + + changed_files = [f for f in result.stdout.strip().splitlines() if f] + + print("Changed files:") + for f in changed_files: + print(f" {f}") + + yaml_changed = YAML_FILE in changed_files + write_github_output("yaml_changed", str(yaml_changed).lower()) + print(f"yaml_changed={yaml_changed}") + + +if __name__ == "__main__": + main() diff --git a/lib/checks/format-comment.py b/lib/checks/format-comment.py new file mode 100644 index 0000000..d2789b1 --- /dev/null +++ b/lib/checks/format-comment.py @@ -0,0 +1,97 @@ +"""Aggregates findings from all check jobs into a formatted PR comment.""" + +import json +import os +import sys + +ARTIFACTS_DIR = "/tmp/artifacts" +OUTPUT_DIR = "/tmp/pr-meta" + +CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md" + +COMMENT_TEMPLATE = """<!-- pr-check-bot --> +Hello @{user} + +Thank you for contributing to Awesome Privacy! We will review your PR shortly. In the meantime, please ensure that your submission is inline with our guidelines in our [Contributing Requirements]({contributing}). + +Looks like there could be some issues in your PR. Please double check that: + +{findings} + +> [!NOTE] +> I am a bot, and sometimes make mistakes in my suggestions. But a human will review your submission shortly!""" + + +def load_findings(filename): + """Load a findings JSON array from the artifacts directory, or empty list on error.""" + try: + with open(os.path.join(ARTIFACTS_DIR, filename)) as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def collect_findings(): + """Gather all findings in display order: compliance, data, project.""" + all_findings = [] + all_findings.extend(load_findings("findings-compliance.json")) + all_findings.extend(load_findings("findings-data.json")) + all_findings.extend(load_findings("findings-project.json")) + return all_findings + + +def format_comment(findings, user): + """Build the markdown comment from findings.""" + bullet_list = "\n".join(f"- {f}" for f in findings) + return COMMENT_TEMPLATE.format( + user=user, contributing=CONTRIBUTING, findings=bullet_list, + ) + + +def write_step_summary(findings): + """Write a summary to GITHUB_STEP_SUMMARY.""" + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_file: + return + lines = ["## PR Check Summary\n"] + if findings: + lines.append(f"āš ļø Found {len(findings)} issue(s):\n") + for f in findings: + lines.append(f"- {f}") + else: + lines.append("āœ… All checks passed.\n") + with open(summary_file, "a") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + try: + user = os.environ.get("PR_USER", "contributor") + pr_number = os.environ.get("PR_NUMBER", "") + run_id = os.environ.get("RUN_ID", "") + + os.makedirs(OUTPUT_DIR, exist_ok=True) + + if pr_number: + with open(os.path.join(OUTPUT_DIR, "number.txt"), "w") as f: + f.write(pr_number) + if run_id: + with open(os.path.join(OUTPUT_DIR, "run-id.txt"), "w") as f: + f.write(run_id) + + findings = collect_findings() + write_step_summary(findings) + + if findings: + comment = format_comment(findings, user) + with open(os.path.join(OUTPUT_DIR, "comment.md"), "w") as f: + f.write(comment) + except Exception: + pass + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/lib/requirements.txt b/lib/requirements.txt index 5a0d1aa..f59b908 100644 --- a/lib/requirements.txt +++ b/lib/requirements.txt @@ -1,5 +1,3 @@ PyYAML==6.0.1 -requests==2.31.0 -jsonschema -pyyaml -termcolor +jsonschema==4.23.0 +requests==2.32.3 diff --git a/lib/schema.json b/lib/schema.json index 19ab787..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", "nullable": true }, - "icon": { "type": "string", "nullable": true }, - "followWith": { "type": "string", "nullable": true }, - "securityAudited": { "type": "boolean", "nullable": true }, - "openSource": { "type": "boolean", "nullable": true }, - "acceptsCrypto": { "type": "boolean", "nullable": true }, - "tosdrId": { "type": "number", "nullable": true }, - "iosApp": { "type": "string", "nullable": true }, - "androidApp": { "type": "string", "nullable": true }, - "discordInvite": { "type": "string", "nullable": true }, - "subreddit": { "type": "string", "nullable": true } + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "description": { "type": "string", "minLength": 10, "maxLength": 1500 }, + "url": { + "type": "string", + "anyOf": [ + { "pattern": "^https?://" }, + { "maxLength": 0 } + ] + }, + "github": { + "type": ["string", "null"], + "pattern": "^([a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+|https://github\\.com/.+)$" + }, + "icon": { + "type": ["string", "null"], + "pattern": "^https?://.+" + }, + "followWith": { "type": ["string", "null"], "minLength": 1, "maxLength": 100 }, + "securityAudited": { "type": ["boolean", "null"] }, + "openSource": { "type": ["boolean", "null"] }, + "acceptsCrypto": { "type": ["boolean", "null"] }, + "tosdrId": { "type": ["integer", "null"], "minimum": 1 }, + "iosApp": { + "type": ["string", "null"], + "pattern": "^https://apps\\.apple\\.com/" + }, + "androidApp": { + "type": ["string", "null"], + "pattern": "^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$" + }, + "discordInvite": { + "type": ["string", "null"], + "pattern": "^(https://discord\\.gg/[a-zA-Z0-9]+|[a-zA-Z0-9]+|)$" + }, + "subreddit": { + "type": ["string", "null"], + "pattern": "^[a-zA-Z0-9_]+$", + "minLength": 1, + "maxLength": 50 + } }, "required": ["name", "description", "url"], "additionalProperties": false } }, - "intro": { "type": "string", "nullable": true }, + "intro": { "type": ["string", "null"], "minLength": 1 }, "notableMentions": { "oneOf": [ { @@ -46,24 +72,29 @@ "items": { "type": "object", "properties": { - "name": { "type": "string" }, - "description": { "type": "string" }, - "url": { "type": "string" } + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "description": { "type": "string", "minLength": 1 }, + "url": { "type": "string", "pattern": "^https?://" } }, "required": ["name", "url"], "additionalProperties": false } }, - { "type": "string" } - ], - "nullable": true + { "type": "string", "minLength": 1 }, + { "type": "null" } + ] }, - "furtherInfo": { "type": "string", "nullable": true }, - "wordOfWarning": { "type": "string", "nullable": true }, + "furtherInfo": { "type": ["string", "null"], "minLength": 1 }, + "wordOfWarning": { "type": ["string", "null"], "minLength": 1 }, "alternativeTo": { - "type": "array", - "items": { "type": "string" }, - "nullable": true + "oneOf": [ + { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 100 }, + "minItems": 1 + }, + { "type": "null" } + ] } }, "required": ["name", "services"], 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__":