mirror of
https://github.com/Lissy93/awesome-privacy.git
synced 2026-03-11 08:55:33 +00:00
commit
a6a8682af6
25 changed files with 1609 additions and 505 deletions
13
.github/CONTRIBUTING.md
vendored
13
.github/CONTRIBUTING.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
42
.github/workflows/check-domain.yml
vendored
42
.github/workflows/check-domain.yml
vendored
|
|
@ -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
|
||||
44
.github/workflows/compile-pdf.yml
vendored
44
.github/workflows/compile-pdf.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
34
.github/workflows/credits.yml
vendored
34
.github/workflows/credits.yml
vendored
|
|
@ -1,34 +0,0 @@
|
|||
# Inserts list of contributors and community members into ./docs/credits.md
|
||||
name: 📊 Generate Contributor Credits
|
||||
on:
|
||||
workflow_dispatch: # Manual dispatch
|
||||
schedule:
|
||||
- cron: '0 5 * * 6' # Every Saturday morning.
|
||||
jobs:
|
||||
# Job #1 - Inserts sponsors into README
|
||||
insert-sponsors:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v2
|
||||
- name: Generate Sponsors in Credits 💖
|
||||
uses: JamesIves/github-sponsors-readme-action@1.0.5
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
file: '.github/README.md'
|
||||
# Job #2 - Inserts contributors into README
|
||||
insert-credits:
|
||||
runs-on: ubuntu-latest
|
||||
name: Inserts contributors into credits.md
|
||||
steps:
|
||||
- name: Contribute List - Credits Page
|
||||
uses: akhilmhdh/contributors-readme-action@v2.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
image_size: 80
|
||||
readme_path: .github/README.md
|
||||
columns_per_row: 6
|
||||
commit_message: 'Updates contributors list'
|
||||
committer_username: liss-bot
|
||||
committer_email: liss-bot@d0h.co
|
||||
16
.github/workflows/mirror.yml
vendored
Normal file
16
.github/workflows/mirror.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Syncs repo to the Codeberg mirror
|
||||
name: 🪞 Mirror
|
||||
on:
|
||||
schedule: [{ cron: '0 5 * * 0' }]
|
||||
push: { tags: ['v*'] }
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: lissy93/repo-mirror-action@main
|
||||
with:
|
||||
ssh_key: ${{ secrets.CODEBERG_SSH }}
|
||||
host: git@codeberg.org
|
||||
user: alicia
|
||||
repo: awesome-privacy
|
||||
156
.github/workflows/pr-check.yml
vendored
Normal file
156
.github/workflows/pr-check.yml
vendored
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
name: PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
paths:
|
||||
- 'awesome-privacy.yml'
|
||||
- '.github/README.md'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
pr-compliance:
|
||||
name: PR Compliance
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Check README edits
|
||||
id: readme
|
||||
continue-on-error: true
|
||||
run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }}
|
||||
- name: Check PR metadata
|
||||
id: meta
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_DRAFT: ${{ github.event.pull_request.draft }}
|
||||
README_FAILED: ${{ steps.readme.outcome == 'failure' && 'true' || 'false' }}
|
||||
run: python lib/checks/check-pr-meta.py
|
||||
- name: Upload findings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: findings-compliance
|
||||
path: /tmp/findings-compliance.json
|
||||
if-no-files-found: ignore
|
||||
- name: Fail if critical
|
||||
if: steps.readme.outcome == 'failure' || steps.meta.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
data-validation:
|
||||
name: Data Validation
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
yaml_changed: ${{ steps.changes.outputs.yaml_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Detect changes
|
||||
id: changes
|
||||
run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }}
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.yaml_changed == 'true'
|
||||
run: pip install -q -r lib/requirements.txt
|
||||
- name: Schema validation
|
||||
if: steps.changes.outputs.yaml_changed == 'true'
|
||||
id: schema
|
||||
continue-on-error: true
|
||||
run: make validate
|
||||
- name: YAML diff
|
||||
if: steps.changes.outputs.yaml_changed == 'true'
|
||||
id: diff
|
||||
continue-on-error: true
|
||||
run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }}
|
||||
- name: Check additions
|
||||
if: steps.changes.outputs.yaml_changed == 'true'
|
||||
env:
|
||||
SCHEMA_OUTCOME: ${{ steps.schema.outcome }}
|
||||
run: python lib/checks/check-additions.py
|
||||
- name: Upload diff data
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-diff
|
||||
path: /tmp/pr-diff.json
|
||||
if-no-files-found: ignore
|
||||
- name: Upload findings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: findings-data
|
||||
path: /tmp/findings-data.json
|
||||
if-no-files-found: ignore
|
||||
- name: Fail if critical
|
||||
if: steps.changes.outputs.yaml_changed == 'true' && (steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure')
|
||||
run: exit 1
|
||||
|
||||
submission-eligibility:
|
||||
name: Submission Eligibility
|
||||
needs: data-validation
|
||||
if: "!cancelled() && needs.data-validation.outputs.yaml_changed == 'true'"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install -q -r lib/requirements.txt
|
||||
- name: Download diff data
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-diff
|
||||
path: /tmp
|
||||
continue-on-error: true
|
||||
- name: Check project health
|
||||
env:
|
||||
PR_USER: ${{ github.event.pull_request.user.login }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: python lib/checks/check-project.py
|
||||
- name: Upload findings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: findings-project
|
||||
path: /tmp/findings-project.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
summary:
|
||||
name: Summary
|
||||
if: always()
|
||||
needs: [pr-compliance, data-validation, submission-eligibility]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Download all findings
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: findings-*
|
||||
path: /tmp/artifacts
|
||||
merge-multiple: true
|
||||
continue-on-error: true
|
||||
- name: Format comment
|
||||
env:
|
||||
PR_USER: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: python lib/checks/format-comment.py
|
||||
- name: Upload PR metadata
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-meta
|
||||
path: /tmp/pr-meta/
|
||||
94
.github/workflows/pr-comment.yml
vendored
Normal file
94
.github/workflows/pr-comment.yml
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
name: PR Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR Check"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Post PR comment
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Download PR metadata
|
||||
id: download
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-meta
|
||||
path: pr-meta
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const marker = '<!-- pr-check-bot -->';
|
||||
|
||||
// Check if there are findings to post
|
||||
const commentFile = 'pr-meta/comment.md';
|
||||
if (!fs.existsSync(commentFile)) {
|
||||
console.log('No findings to post — skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the PR number
|
||||
let prNumber;
|
||||
const numberFile = 'pr-meta/number.txt';
|
||||
if (fs.existsSync(numberFile)) {
|
||||
prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim());
|
||||
}
|
||||
if (!prNumber) {
|
||||
// workflow_run.pull_requests is empty for fork PRs, so
|
||||
// fall back to searching by head SHA if needed
|
||||
const prs = context.payload.workflow_run.pull_requests;
|
||||
if (prs && prs.length > 0) {
|
||||
prNumber = prs[0].number;
|
||||
} else {
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const { data: prList } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 100,
|
||||
});
|
||||
const match = prList.find(pr => pr.head.sha === headSha);
|
||||
if (!match) {
|
||||
console.log(`No open PR found for SHA ${headSha} — skipping.`);
|
||||
return;
|
||||
}
|
||||
prNumber = match.number;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if we already commented on this PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
if (comments.some(c => c.body.includes(marker))) {
|
||||
console.log('Bot comment already exists — skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Post the comment
|
||||
const body = fs.readFileSync(commentFile, 'utf8').trim();
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
25
.github/workflows/pr-labeler.yml
vendored
25
.github/workflows/pr-labeler.yml
vendored
|
|
@ -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"] }
|
||||
]
|
||||
20
.github/workflows/spell-check.yml
vendored
20
.github/workflows/spell-check.yml
vendored
|
|
@ -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
|
||||
17
.github/workflows/sync-mirror.yml
vendored
17
.github/workflows/sync-mirror.yml
vendored
|
|
@ -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 }}
|
||||
40
.github/workflows/ticket-check.yml
vendored
40
.github/workflows/ticket-check.yml
vendored
|
|
@ -1,40 +0,0 @@
|
|||
# Checks newly opened issues contain enough info, and follow the required format
|
||||
name: 🎫 Issue Validator
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
jobs:
|
||||
check-title:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Check Default Title
|
||||
if: "endsWith(github.event.issue.title, '<title>')"
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
Please ensure that your ticket has an appropriate title
|
||||
- name: Check Title Contains Categroy
|
||||
if: "!(startsWith(github.event.issue.title, '[') && contains(github.event.issue.title, ']'))"
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
Please ensure that your ticket's title is preceded with a category.
|
||||
For example, `[ADDITION]`, `[AMENDMENT]`, `[REMOVAL]` or `[QUESTION]`.
|
||||
- name: Check Quality Checklist
|
||||
if: "contains(github.event.issue.body, '[ ]') || !(contains(github.event.issue.body, '[X]') || contains(github.event.issue.body, '[x]'))"
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
Please ensure that you've followed the issue template fully.
|
||||
It's important that you complete the quality & transparency checklist.
|
||||
|
||||
|
||||
|
||||
|
||||
90
.github/workflows/validate-pr.yml
vendored
90
.github/workflows/validate-pr.yml
vendored
|
|
@ -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.
|
||||
|
||||
|
||||
18
.github/workflows/welcome-non-stargazers.yml
vendored
18
.github/workflows/welcome-non-stargazers.yml
vendored
|
|
@ -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>_
|
||||
2
Makefile
2
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
275
lib/checks/check-additions.py
Normal file
275
lib/checks/check-additions.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"""Validates data quality for added/modified services using the diff JSON."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
|
||||
DIFF_PATH = "/tmp/pr-diff.json"
|
||||
FINDINGS_PATH = "/tmp/findings-data.json"
|
||||
|
||||
REQUIRED_FIELDS = ("name", "description", "url", "icon")
|
||||
|
||||
CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md"
|
||||
|
||||
SCHEMA_MSG = (
|
||||
"Some of the schema checks have failed. Please check that your addition"
|
||||
" contains all the required fields, with acceptable values, nothing"
|
||||
" additional and that it is following valid YAML syntax"
|
||||
)
|
||||
MULTIPLE_MSG = "Please make just one addition per pull request"
|
||||
MISSING_TPL = (
|
||||
"Did you include all required fields? Looks like {fields} is missing or"
|
||||
f" invalid. Please see the [required fields]({CONTRIBUTING}#service-fields)"
|
||||
" for available fields."
|
||||
)
|
||||
POSITION_MSG = (
|
||||
"New entries must be added to the end of the section, unless otherwise requested"
|
||||
)
|
||||
OPENSOURCE_MSG = (
|
||||
"You indicated this app/service is not open source. This will likely make"
|
||||
" it ineligible for listing on Awesome Privacy in accordance with our"
|
||||
f" [Requirements]({CONTRIBUTING}#requirements)."
|
||||
" Please ensure that this is justified in your PR body."
|
||||
)
|
||||
DUPLICATE_NAME_MSG = (
|
||||
"A service named `{name}` already exists (in {location})."
|
||||
" If this is a different service, please clarify in your PR description"
|
||||
)
|
||||
DUPLICATE_URL_MSG = (
|
||||
"The URL `{url}` is already associated with `{existing}`."
|
||||
" Please check this isn't a duplicate submission"
|
||||
)
|
||||
DESC_LENGTH_MSG = (
|
||||
"Description length ({length} chars) is outside the recommended 50\u2013250"
|
||||
f" character range. Please see our [Contributing Guidelines]({CONTRIBUTING}#description)"
|
||||
)
|
||||
OPENSOURCE_GITHUB_MSG = (
|
||||
"You marked this service as open source but didn't include a `github` field."
|
||||
" Please add the repository link"
|
||||
)
|
||||
|
||||
|
||||
def load_json(path):
|
||||
"""Load JSON from a file, returning None on any error."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_yaml_data(path):
|
||||
"""Load YAML from a file, returning None on any error."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def find_section_services(head, category, section):
|
||||
"""Return the services list for a category/section pair, or None."""
|
||||
for cat in head.get("categories", []):
|
||||
if cat.get("name") == category:
|
||||
for sec in cat.get("sections", []):
|
||||
if sec.get("name") == section:
|
||||
return sec.get("services", [])
|
||||
return None
|
||||
|
||||
|
||||
def find_service_fields(head, category, section, service_name):
|
||||
"""Look up a service's fields in the head YAML."""
|
||||
services = find_section_services(head, category, section)
|
||||
if services:
|
||||
for svc in services:
|
||||
if svc.get("name") == service_name:
|
||||
return svc
|
||||
return None
|
||||
|
||||
|
||||
def check_required_fields(diff, head):
|
||||
"""Return a finding if any added/modified service is missing required fields."""
|
||||
missing = set()
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
fields = svc.get("fields", {})
|
||||
for f in REQUIRED_FIELDS:
|
||||
if fields.get(f) is None:
|
||||
missing.add(f)
|
||||
for svc in diff.get("services", {}).get("modified", []):
|
||||
if not head:
|
||||
continue
|
||||
changed = svc.get("changed_fields", [])
|
||||
fields = find_service_fields(
|
||||
head, svc["category"], svc["section"], svc["service"]
|
||||
)
|
||||
if fields:
|
||||
for f in REQUIRED_FIELDS:
|
||||
if f in changed and fields.get(f) is None:
|
||||
missing.add(f)
|
||||
if missing:
|
||||
names = ", ".join(f"`{f}`" for f in sorted(missing))
|
||||
return MISSING_TPL.format(fields=names)
|
||||
return None
|
||||
|
||||
|
||||
def check_position(diff, head):
|
||||
"""Return a finding if a newly added service is not at the end of its section."""
|
||||
if not head:
|
||||
return None
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
services = find_section_services(head, svc["category"], svc["section"])
|
||||
if services and services[-1].get("name") != svc["service"]:
|
||||
return POSITION_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_open_source(diff):
|
||||
"""Return a finding if an added service has openSource missing or not true."""
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
fields = svc.get("fields", {})
|
||||
if fields.get("openSource") is not True:
|
||||
return OPENSOURCE_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_single_entry(diff):
|
||||
"""Return a finding if the diff adds multiple new services or sections."""
|
||||
services = diff.get("services", {})
|
||||
added_count = len(services.get("added", []))
|
||||
if added_count > 1:
|
||||
return MULTIPLE_MSG
|
||||
if added_count == 0:
|
||||
added_sections = [s for s in diff.get("sections", [])
|
||||
if s.get("change_type") == "added_section"]
|
||||
if len(added_sections) > 1:
|
||||
return MULTIPLE_MSG
|
||||
return None
|
||||
|
||||
|
||||
def build_name_index(head):
|
||||
"""Build {lowercase_name: "category > section"} from all services."""
|
||||
index = {}
|
||||
if not head:
|
||||
return index
|
||||
for cat in head.get("categories", []):
|
||||
cn = cat.get("name", "")
|
||||
for sec in cat.get("sections", []):
|
||||
sn = sec.get("name", "")
|
||||
for svc in sec.get("services", []):
|
||||
name = svc.get("name", "").lower().strip()
|
||||
if name:
|
||||
index[name] = f"{cn} > {sn}"
|
||||
return index
|
||||
|
||||
|
||||
def build_url_index(head):
|
||||
"""Build {url: service_name} from all services, skipping empty URLs."""
|
||||
index = {}
|
||||
if not head:
|
||||
return index
|
||||
for cat in head.get("categories", []):
|
||||
for sec in cat.get("sections", []):
|
||||
for svc in sec.get("services", []):
|
||||
url = svc.get("url", "")
|
||||
if url:
|
||||
index[url] = svc.get("name", "")
|
||||
return index
|
||||
|
||||
|
||||
def check_duplicate_name(diff, name_index):
|
||||
"""Return a finding if an added service name already exists in the YAML."""
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
name = svc.get("fields", {}).get("name", "").lower().strip()
|
||||
if name and name in name_index:
|
||||
return DUPLICATE_NAME_MSG.format(
|
||||
name=svc["fields"]["name"], location=name_index[name],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def check_duplicate_url(diff, url_index):
|
||||
"""Return a finding if an added service URL already exists in the YAML."""
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
url = svc.get("fields", {}).get("url", "")
|
||||
if url and url in url_index:
|
||||
return DUPLICATE_URL_MSG.format(url=url, existing=url_index[url])
|
||||
return None
|
||||
|
||||
|
||||
def check_description_length(diff):
|
||||
"""Return a finding if an added service description is outside 50-250 chars."""
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
desc = svc.get("fields", {}).get("description", "")
|
||||
length = len(desc)
|
||||
if length < 50 or length > 250:
|
||||
return DESC_LENGTH_MSG.format(length=length)
|
||||
return None
|
||||
|
||||
|
||||
def check_opensource_github(diff):
|
||||
"""Return a finding if an added service is open source but has no github field."""
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
fields = svc.get("fields", {})
|
||||
if fields.get("openSource") is True and not fields.get("github"):
|
||||
return OPENSOURCE_GITHUB_MSG
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
findings = []
|
||||
try:
|
||||
if os.environ.get("SCHEMA_OUTCOME") == "failure":
|
||||
findings.append(SCHEMA_MSG)
|
||||
|
||||
diff = load_json(DIFF_PATH)
|
||||
head = load_yaml_data(DATA_PATH)
|
||||
|
||||
if diff:
|
||||
finding = check_single_entry(diff)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_required_fields(diff, head)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_position(diff, head)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_open_source(diff)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
name_index = build_name_index(head)
|
||||
url_index = build_url_index(head)
|
||||
|
||||
finding = check_duplicate_name(diff, name_index)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_duplicate_url(diff, url_index)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_description_length(diff)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_opensource_github(diff)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with open(FINDINGS_PATH, "w") as f:
|
||||
json.dump(findings, f)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
139
lib/checks/check-pr-meta.py
Normal file
139
lib/checks/check-pr-meta.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""Checks PR metadata: title format, draft status, template completeness, and checkboxes."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
FINDINGS_PATH = "/tmp/findings-compliance.json"
|
||||
|
||||
BAD_TITLES = {"update readme.md", "update awesome-privacy.yml"}
|
||||
|
||||
TITLE_MSG = (
|
||||
"The pull request title does not follow the format defined in our guidelines."
|
||||
" Please rename it to `[Add/Remove/Update] [software name] in [software section]`"
|
||||
)
|
||||
DRAFT_MSG = (
|
||||
"Please avoid opening WIP pull requests."
|
||||
" Your PR should be 100% ready and complete before submitting"
|
||||
)
|
||||
TEMPLATE_MSG = (
|
||||
"Please fill in pull request template in full."
|
||||
" You can find a copy of this"
|
||||
" [here](https://github.com/Lissy93/awesome-privacy/blob/main/.github/PULL_REQUEST_TEMPLATE.md)"
|
||||
)
|
||||
CHECKBOX_MSG = (
|
||||
"Ensure you have completed the checklist (put a tick the checkboxes with `[x]`),"
|
||||
" to confirm that you've read the contributing guidelines, checked your submission,"
|
||||
" indicated your affiliation and agree to follow our CoC"
|
||||
)
|
||||
README_MSG = (
|
||||
"Do not edit the README directly. This file is auto-generated from the"
|
||||
" content in `awesome-privacy.yml`, and so your changes will be overridden!"
|
||||
" Instead, only modify the YAML file, and be sure to follow our Contributing Guidelines."
|
||||
)
|
||||
|
||||
|
||||
def extract_section(body, header):
|
||||
"""Extract content between a ### header and the next delimiter."""
|
||||
pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)"
|
||||
match = re.search(pattern, body, re.DOTALL)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def strip_html_comments(text):
|
||||
"""Remove HTML comments from text."""
|
||||
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL).strip()
|
||||
|
||||
|
||||
def check_title(title):
|
||||
"""Return a finding if the PR title matches a known-bad pattern."""
|
||||
if title and title.strip().lower() in BAD_TITLES:
|
||||
return TITLE_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_draft(draft_str):
|
||||
"""Return a finding if the PR is in draft state."""
|
||||
if str(draft_str).lower() == "true":
|
||||
return DRAFT_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_template(body):
|
||||
"""Return a finding if required template sections are missing or empty."""
|
||||
for header in ("Type", "Changes", "Checklist"):
|
||||
content = extract_section(body, header)
|
||||
if content is None or not strip_html_comments(content):
|
||||
return TEMPLATE_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_checkboxes(body):
|
||||
"""Return a finding if any checklist checkboxes are unchecked."""
|
||||
section = extract_section(body, "Checklist")
|
||||
if section is None:
|
||||
return None
|
||||
checked = re.findall(r"- \[x\]", section, re.IGNORECASE)
|
||||
unchecked = re.findall(r"- \[ \]", section)
|
||||
if not checked and not unchecked:
|
||||
return None
|
||||
if unchecked:
|
||||
return CHECKBOX_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_readme(readme_failed):
|
||||
"""Return a finding if the README check reported a failure."""
|
||||
if readme_failed == "true":
|
||||
return README_MSG
|
||||
return None
|
||||
|
||||
|
||||
def write_findings(findings):
|
||||
"""Write the findings list to the output JSON file."""
|
||||
with open(FINDINGS_PATH, "w") as f:
|
||||
json.dump(findings, f)
|
||||
|
||||
|
||||
def main():
|
||||
findings = []
|
||||
critical = False
|
||||
try:
|
||||
title = os.environ.get("PR_TITLE", "")
|
||||
body = os.environ.get("PR_BODY", "")
|
||||
draft = os.environ.get("PR_DRAFT", "false")
|
||||
readme_failed = os.environ.get("README_FAILED", "false")
|
||||
|
||||
finding = check_title(title)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_draft(draft)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
if not body or not body.strip():
|
||||
findings.append(TEMPLATE_MSG)
|
||||
critical = True
|
||||
else:
|
||||
finding = check_template(body)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
critical = True
|
||||
finding = check_checkboxes(body)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_readme(readme_failed)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
write_findings(findings)
|
||||
sys.exit(1 if critical else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
227
lib/checks/check-project.py
Normal file
227
lib/checks/check-project.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Checks project health: URL reachability, GitHub repo stars, activity, and author match."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
|
||||
DIFF_PATH = "/tmp/pr-diff.json"
|
||||
FINDINGS_PATH = "/tmp/findings-project.json"
|
||||
|
||||
TIMEOUT = 10
|
||||
USER_AGENT = "awesome-privacy-ci/1.0"
|
||||
MIN_STARS = 100
|
||||
INACTIVE_DAYS = 90
|
||||
|
||||
LINK_MSG = (
|
||||
"Our automated checks were unable to verify the link(s) you included"
|
||||
" were reachable, so please double check this yourself"
|
||||
)
|
||||
AUTHOR_MSG = (
|
||||
"Looks like you are the author of this package. Please ensure that you"
|
||||
" have clearly disclosed this in your PR body for transparency"
|
||||
)
|
||||
STARS_MSG = (
|
||||
"It looks like your submission is adding a quite small project."
|
||||
" In some circumstances we may ask you to resubmit this once the project"
|
||||
" is more mature and has a proven track record of good practices and maintenance."
|
||||
)
|
||||
ACTIVITY_MSG = (
|
||||
"Please confirm that the project you are adding is actively maintained,"
|
||||
" as it looks to not have had any recent updates in the past 3 months."
|
||||
)
|
||||
|
||||
|
||||
def load_diff(path):
|
||||
"""Load the diff JSON, returning None on any error."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def check_url(url):
|
||||
"""Return True if the URL is reachable, True on any error (no false positives)."""
|
||||
try:
|
||||
resp = requests.head(
|
||||
url, timeout=TIMEOUT, allow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
resp = requests.get(
|
||||
url, timeout=TIMEOUT, allow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT}, stream=True,
|
||||
)
|
||||
resp.close()
|
||||
return resp.status_code < 400
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def parse_github_field(value):
|
||||
"""Parse a github field into (owner, repo), or (None, None) on failure."""
|
||||
if not value:
|
||||
return None, None
|
||||
if value.startswith("https://github.com/"):
|
||||
parts = value.removeprefix("https://github.com/").strip("/").split("/")
|
||||
if len(parts) >= 2:
|
||||
return parts[0], parts[1]
|
||||
return None, None
|
||||
if "/" in value:
|
||||
parts = value.split("/")
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return None, None
|
||||
|
||||
|
||||
def fetch_repo(owner, repo, token):
|
||||
"""Fetch GitHub repo metadata, returning None on any error."""
|
||||
try:
|
||||
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
resp = requests.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers=headers, timeout=TIMEOUT,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def load_yaml_data():
|
||||
"""Load the head YAML, returning None on any error."""
|
||||
try:
|
||||
with open(DATA_PATH) as f:
|
||||
return yaml.safe_load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def find_service_in_head(head, category, section, service_name):
|
||||
"""Look up a service in the head YAML by path."""
|
||||
if not head:
|
||||
return None
|
||||
for cat in head.get("categories", []):
|
||||
if cat.get("name") == category:
|
||||
for sec in cat.get("sections", []):
|
||||
if sec.get("name") == section:
|
||||
for svc in sec.get("services", []):
|
||||
if svc.get("name") == service_name:
|
||||
return svc
|
||||
return None
|
||||
|
||||
|
||||
def get_services(diff, key):
|
||||
"""Safely extract a service list from the diff."""
|
||||
return diff.get("services", {}).get(key, [])
|
||||
|
||||
|
||||
def check_links(diff, head):
|
||||
"""Return LINK_MSG if any service URL or icon URL is unreachable."""
|
||||
for svc in get_services(diff, "added"):
|
||||
fields = svc.get("fields", {})
|
||||
url = fields.get("url")
|
||||
if url and not check_url(url):
|
||||
return LINK_MSG
|
||||
icon = fields.get("icon")
|
||||
if icon and not check_url(icon):
|
||||
return LINK_MSG
|
||||
for svc in get_services(diff, "modified"):
|
||||
changed = svc.get("changed_fields", [])
|
||||
if "url" not in changed and "icon" not in changed:
|
||||
continue
|
||||
head_svc = find_service_in_head(
|
||||
head, svc["category"], svc["section"], svc["service"]
|
||||
)
|
||||
if head_svc:
|
||||
if "url" in changed:
|
||||
url = head_svc.get("url")
|
||||
if url and not check_url(url):
|
||||
return LINK_MSG
|
||||
if "icon" in changed:
|
||||
icon = head_svc.get("icon")
|
||||
if icon and not check_url(icon):
|
||||
return LINK_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_repo_signals(diff, pr_user, token):
|
||||
"""Check GitHub repo author match, stars, and activity for added services."""
|
||||
findings = []
|
||||
if not token:
|
||||
return findings
|
||||
cache = {}
|
||||
for svc in get_services(diff, "added"):
|
||||
gh = svc.get("fields", {}).get("github")
|
||||
owner, repo = parse_github_field(gh)
|
||||
if not owner:
|
||||
continue
|
||||
cache_key = f"{owner}/{repo}"
|
||||
if cache_key not in cache:
|
||||
cache[cache_key] = fetch_repo(owner, repo, token)
|
||||
data = cache[cache_key]
|
||||
if not data:
|
||||
continue
|
||||
|
||||
repo_owner = data.get("owner", {})
|
||||
if (
|
||||
pr_user
|
||||
and repo_owner.get("type") == "User"
|
||||
and repo_owner.get("login", "").lower() == pr_user.lower()
|
||||
and AUTHOR_MSG not in findings
|
||||
):
|
||||
findings.append(AUTHOR_MSG)
|
||||
|
||||
stars = data.get("stargazers_count", 0)
|
||||
if stars < MIN_STARS and STARS_MSG not in findings:
|
||||
findings.append(STARS_MSG)
|
||||
|
||||
pushed = data.get("pushed_at")
|
||||
if pushed and ACTIVITY_MSG not in findings:
|
||||
try:
|
||||
pushed_dt = datetime.fromisoformat(pushed.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
if (now - pushed_dt).days > INACTIVE_DAYS:
|
||||
findings.append(ACTIVITY_MSG)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def main():
|
||||
findings = []
|
||||
try:
|
||||
diff = load_diff(DIFF_PATH)
|
||||
if not diff:
|
||||
with open(FINDINGS_PATH, "w") as f:
|
||||
json.dump(findings, f)
|
||||
sys.exit(0)
|
||||
|
||||
head = load_yaml_data()
|
||||
finding = check_links(diff, head)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
pr_user = os.environ.get("PR_USER", "")
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
findings.extend(check_repo_signals(diff, pr_user, token))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with open(FINDINGS_PATH, "w") as f:
|
||||
json.dump(findings, f)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
123
lib/checks/check-readme-edits.py
Normal file
123
lib/checks/check-readme-edits.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""
|
||||
Fails if the PR directly edits the auto-generated section of the README.
|
||||
The generated section is between <!-- awesome-privacy-start --> and <!-- awesome-privacy-end -->.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
README_PATH = ".github/README.md"
|
||||
README_ABS = os.path.join(PROJECT_ROOT, README_PATH)
|
||||
|
||||
# Exit codes
|
||||
EXIT_PASS = 0
|
||||
EXIT_FAIL = 1
|
||||
EXIT_RUNTIME_ERROR = 2
|
||||
|
||||
# ANSI color helpers
|
||||
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
|
||||
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
|
||||
|
||||
def get_changed_files(base_ref):
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{base_ref}..HEAD"],
|
||||
capture_output=True, text=True, check=True,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
return result.stdout.strip().splitlines()
|
||||
|
||||
|
||||
def get_marker_lines():
|
||||
"""Find the line numbers of the start/end markers in the README."""
|
||||
try:
|
||||
with open(README_ABS, "r") as f:
|
||||
lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
return None, None
|
||||
|
||||
start_line = None
|
||||
end_line = None
|
||||
for i, line in enumerate(lines, start=1):
|
||||
if "<!-- awesome-privacy-start -->" in line:
|
||||
start_line = i
|
||||
if "<!-- awesome-privacy-end -->" in line:
|
||||
end_line = i
|
||||
|
||||
return start_line, end_line
|
||||
|
||||
|
||||
def get_changed_line_numbers(base_ref):
|
||||
"""Parse git diff hunk headers to find which lines were changed in the README."""
|
||||
result = subprocess.run(
|
||||
["git", "diff", "-U0", f"{base_ref}..HEAD", "--", README_PATH],
|
||||
capture_output=True, text=True, check=True,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
|
||||
changed_lines = []
|
||||
for line in result.stdout.splitlines():
|
||||
# Match hunk headers like @@ -10,5 +12,7 @@
|
||||
match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", line)
|
||||
if match:
|
||||
start = int(match.group(1))
|
||||
count = int(match.group(2)) if match.group(2) else 1
|
||||
for n in range(start, start + count):
|
||||
changed_lines.append(n)
|
||||
|
||||
return changed_lines
|
||||
|
||||
|
||||
def write_step_summary():
|
||||
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if not summary_file:
|
||||
return
|
||||
|
||||
lines = [
|
||||
"## Direct README Edit Detected\n",
|
||||
"This PR directly modifies the auto-generated section of `.github/README.md` "
|
||||
"(between `<!-- awesome-privacy-start -->` and `<!-- awesome-privacy-end -->`).\n",
|
||||
"**Please edit `awesome-privacy.yml` instead.** The README is regenerated automatically from that file.\n",
|
||||
]
|
||||
|
||||
with open(summary_file, "a") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Check for direct README edits to generated section")
|
||||
parser.add_argument("--base-ref", required=True, help="Base git ref to diff against")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Skip if README wasn't changed
|
||||
changed_files = get_changed_files(args.base_ref)
|
||||
if README_PATH not in changed_files:
|
||||
print(green("README not modified, skipping."))
|
||||
sys.exit(EXIT_PASS)
|
||||
|
||||
# Find marker lines
|
||||
start_line, end_line = get_marker_lines()
|
||||
if start_line is None or end_line is None:
|
||||
print("Could not find generated-section markers in README, skipping check.")
|
||||
sys.exit(EXIT_PASS)
|
||||
|
||||
# Check if any changed lines fall within the generated section
|
||||
changed_lines = get_changed_line_numbers(args.base_ref)
|
||||
for line_num in changed_lines:
|
||||
if start_line <= line_num <= end_line:
|
||||
print(red("Direct edits to the generated section of the README are not allowed."), file=sys.stderr)
|
||||
print(red("Edit awesome-privacy.yml instead and the README will be regenerated."), file=sys.stderr)
|
||||
write_step_summary()
|
||||
sys.exit(EXIT_FAIL)
|
||||
|
||||
print(green("README changes are outside the generated section, OK."))
|
||||
sys.exit(EXIT_PASS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
207
lib/checks/check-yaml-diff.py
Normal file
207
lib/checks/check-yaml-diff.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""Analyzes the diff between base and head versions of awesome-privacy.yml.
|
||||
Enforces the single-entry rule and outputs a JSON diff to /tmp/pr-diff.json.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
|
||||
DIFF_OUTPUT_PATH = "/tmp/pr-diff.json"
|
||||
|
||||
EXIT_PASS = 0
|
||||
EXIT_RULE_VIOLATION = 1
|
||||
EXIT_RUNTIME_ERROR = 2
|
||||
|
||||
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
|
||||
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
|
||||
|
||||
def load_base_yaml(base_ref):
|
||||
"""Load the YAML from the base ref using git show."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "show", f"{base_ref}:awesome-privacy.yml"],
|
||||
capture_output=True, text=True, check=True, cwd=PROJECT_ROOT,
|
||||
)
|
||||
return yaml.safe_load(result.stdout)
|
||||
except subprocess.CalledProcessError:
|
||||
print(yellow("awesome-privacy.yml not found in base ref, treating as empty"), file=sys.stderr)
|
||||
return {"categories": []}
|
||||
except yaml.YAMLError as e:
|
||||
print(red(f"Failed to parse base YAML: {e}"), file=sys.stderr)
|
||||
sys.exit(EXIT_RUNTIME_ERROR)
|
||||
|
||||
|
||||
def load_head_yaml():
|
||||
"""Load the YAML from the current working tree."""
|
||||
try:
|
||||
with open(DATA_PATH) as f:
|
||||
return yaml.safe_load(f)
|
||||
except (FileNotFoundError, yaml.YAMLError) as e:
|
||||
print(red(f"Failed to load head YAML: {e}"), file=sys.stderr)
|
||||
sys.exit(EXIT_RUNTIME_ERROR)
|
||||
|
||||
|
||||
def build_index(data, depth):
|
||||
"""Build a keyed index at the given depth (3=services, 2=sections, 1=categories)."""
|
||||
index = {}
|
||||
for cat in data.get("categories", []):
|
||||
cn = cat.get("name", "")
|
||||
if depth == 1:
|
||||
index[cn] = {k: v for k, v in cat.items() if k != "sections"}
|
||||
continue
|
||||
for sec in cat.get("sections", []):
|
||||
sn = sec.get("name", "")
|
||||
if depth == 2:
|
||||
index[(cn, sn)] = {k: v for k, v in sec.items() if k != "services"}
|
||||
continue
|
||||
for svc in sec.get("services", []):
|
||||
index[(cn, sn, svc.get("name", ""))] = svc
|
||||
return index
|
||||
|
||||
|
||||
def diff_index(base_idx, head_idx):
|
||||
"""Return (added_keys, removed_keys, modified_keys_with_changed_fields)."""
|
||||
base_keys, head_keys = set(base_idx), set(head_idx)
|
||||
added = sorted(head_keys - base_keys)
|
||||
removed = sorted(base_keys - head_keys)
|
||||
modified = []
|
||||
for key in sorted(base_keys & head_keys):
|
||||
if base_idx[key] != head_idx[key]:
|
||||
all_fields = set(base_idx[key]) | set(head_idx[key])
|
||||
changed = sorted(f for f in all_fields if base_idx[key].get(f) != head_idx[key].get(f))
|
||||
modified.append((key, changed))
|
||||
return added, removed, modified
|
||||
|
||||
|
||||
def write_github_output(name, value):
|
||||
"""Write a value to $GITHUB_OUTPUT."""
|
||||
output_file = os.environ.get("GITHUB_OUTPUT")
|
||||
if output_file:
|
||||
with open(output_file, "a") as f:
|
||||
f.write(f"{name}={value}\n")
|
||||
|
||||
|
||||
def fmt_path(key):
|
||||
"""Format a tuple key as a readable path."""
|
||||
return " → ".join(key) if isinstance(key, tuple) else key
|
||||
|
||||
|
||||
def write_step_summary(diff_result):
|
||||
"""Write a bullet-point Markdown summary to $GITHUB_STEP_SUMMARY."""
|
||||
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if not summary_file:
|
||||
return
|
||||
|
||||
lines = ["## YAML Diff Analysis\n"]
|
||||
bullets = []
|
||||
|
||||
for svc in diff_result["services"]["added"]:
|
||||
bullets.append(f"- Added **{svc['service']}** in {svc['category']} → {svc['section']}")
|
||||
for svc in diff_result["services"]["removed"]:
|
||||
bullets.append(f"- Removed **{svc['service']}** from {svc['category']} → {svc['section']}")
|
||||
for svc in diff_result["services"]["modified"]:
|
||||
fields = ", ".join(f"`{f}`" for f in svc["changed_fields"])
|
||||
bullets.append(f"- Modified {fields} in {svc['category']} → {svc['section']} → {svc['service']}")
|
||||
for change in diff_result["sections"]:
|
||||
ct = change["change_type"]
|
||||
path = f"{change['category']} → {change['section']}"
|
||||
if ct == "added_section":
|
||||
bullets.append(f"- Added section **{change['section']}** in {change['category']}")
|
||||
elif ct == "removed_section":
|
||||
bullets.append(f"- Removed section **{change['section']}** from {change['category']}")
|
||||
else:
|
||||
fields = ", ".join(f"`{f}`" for f in change.get("changed_fields", []))
|
||||
bullets.append(f"- Modified section metadata ({fields}) in {path}")
|
||||
for change in diff_result["categories"]:
|
||||
if change["change_type"] == "added_category":
|
||||
bullets.append(f"- Added category **{change['category']}**")
|
||||
else:
|
||||
bullets.append(f"- Removed category **{change['category']}**")
|
||||
|
||||
if bullets:
|
||||
lines.extend(bullets)
|
||||
else:
|
||||
lines.append("No changes detected in `awesome-privacy.yml`.")
|
||||
|
||||
with open(summary_file, "a") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-ref", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
base = load_base_yaml(args.base_ref)
|
||||
head = load_head_yaml()
|
||||
|
||||
svc_added, svc_removed, svc_modified = diff_index(
|
||||
build_index(base, 3), build_index(head, 3),
|
||||
)
|
||||
sec_added, sec_removed, sec_modified = diff_index(
|
||||
build_index(base, 2), build_index(head, 2),
|
||||
)
|
||||
cat_added, cat_removed, _ = diff_index(
|
||||
build_index(base, 1), build_index(head, 1),
|
||||
)
|
||||
|
||||
added = [{"category": k[0], "section": k[1], "service": k[2],
|
||||
"fields": build_index(head, 3)[k]} for k in svc_added]
|
||||
removed = [{"category": k[0], "section": k[1], "service": k[2]} for k in svc_removed]
|
||||
modified = [{"category": k[0], "section": k[1], "service": k[2],
|
||||
"changed_fields": cf} for k, cf in svc_modified]
|
||||
|
||||
sections = []
|
||||
for k in sec_added:
|
||||
sections.append({"category": k[0], "section": k[1], "change_type": "added_section"})
|
||||
for k in sec_removed:
|
||||
sections.append({"category": k[0], "section": k[1], "change_type": "removed_section"})
|
||||
for k, cf in sec_modified:
|
||||
sections.append({"category": k[0], "section": k[1],
|
||||
"change_type": "modified_section_metadata", "changed_fields": cf})
|
||||
|
||||
categories = []
|
||||
for k in cat_added:
|
||||
categories.append({"category": k, "change_type": "added_category"})
|
||||
for k in cat_removed:
|
||||
categories.append({"category": k, "change_type": "removed_category"})
|
||||
|
||||
diff_result = {
|
||||
"services": {"added": added, "removed": removed, "modified": modified},
|
||||
"sections": sections,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
with open(DIFF_OUTPUT_PATH, "w") as f:
|
||||
json.dump(diff_result, f, indent=2)
|
||||
|
||||
write_github_output("has_service_changes", str(bool(added or removed or modified)).lower())
|
||||
write_step_summary(diff_result)
|
||||
|
||||
added_count = len(added)
|
||||
if added_count > 1:
|
||||
print(red(f"Single-entry rule violation: {added_count} service additions found."), file=sys.stderr)
|
||||
sys.exit(EXIT_RULE_VIOLATION)
|
||||
added_sections = [s for s in sections if s["change_type"] == "added_section"]
|
||||
if added_count == 0 and len(added_sections) > 1:
|
||||
print(red(f"Single-entry rule violation: {len(added_sections)} section additions found."), file=sys.stderr)
|
||||
sys.exit(EXIT_RULE_VIOLATION)
|
||||
|
||||
total = len(added) + len(removed) + len(modified)
|
||||
print(green(f"Single-entry rule passed. {total} service "
|
||||
f"({added_count} added), {len(sections)} section, "
|
||||
f"{len(categories)} category change(s)."))
|
||||
sys.exit(EXIT_PASS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
46
lib/checks/detect-changes.py
Normal file
46
lib/checks/detect-changes.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""
|
||||
Detects which files changed between the PR base and HEAD.
|
||||
Sets GitHub Actions output: yaml_changed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
YAML_FILE = "awesome-privacy.yml"
|
||||
|
||||
|
||||
def write_github_output(name, value):
|
||||
output_file = os.environ.get("GITHUB_OUTPUT")
|
||||
if output_file:
|
||||
with open(output_file, "a") as f:
|
||||
f.write(f"{name}={value}\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Detect changed files in a PR")
|
||||
parser.add_argument("--base-ref", required=True, help="Base git ref to diff against")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{args.base_ref}..HEAD"],
|
||||
capture_output=True, text=True, check=True,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
|
||||
changed_files = [f for f in result.stdout.strip().splitlines() if f]
|
||||
|
||||
print("Changed files:")
|
||||
for f in changed_files:
|
||||
print(f" {f}")
|
||||
|
||||
yaml_changed = YAML_FILE in changed_files
|
||||
write_github_output("yaml_changed", str(yaml_changed).lower())
|
||||
print(f"yaml_changed={yaml_changed}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
97
lib/checks/format-comment.py
Normal file
97
lib/checks/format-comment.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Aggregates findings from all check jobs into a formatted PR comment."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ARTIFACTS_DIR = "/tmp/artifacts"
|
||||
OUTPUT_DIR = "/tmp/pr-meta"
|
||||
|
||||
CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md"
|
||||
|
||||
COMMENT_TEMPLATE = """<!-- pr-check-bot -->
|
||||
Hello @{user}
|
||||
|
||||
Thank you for contributing to Awesome Privacy! We will review your PR shortly. In the meantime, please ensure that your submission is inline with our guidelines in our [Contributing Requirements]({contributing}).
|
||||
|
||||
Looks like there could be some issues in your PR. Please double check that:
|
||||
|
||||
{findings}
|
||||
|
||||
> [!NOTE]
|
||||
> I am a bot, and sometimes make mistakes in my suggestions. But a human will review your submission shortly!"""
|
||||
|
||||
|
||||
def load_findings(filename):
|
||||
"""Load a findings JSON array from the artifacts directory, or empty list on error."""
|
||||
try:
|
||||
with open(os.path.join(ARTIFACTS_DIR, filename)) as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def collect_findings():
|
||||
"""Gather all findings in display order: compliance, data, project."""
|
||||
all_findings = []
|
||||
all_findings.extend(load_findings("findings-compliance.json"))
|
||||
all_findings.extend(load_findings("findings-data.json"))
|
||||
all_findings.extend(load_findings("findings-project.json"))
|
||||
return all_findings
|
||||
|
||||
|
||||
def format_comment(findings, user):
|
||||
"""Build the markdown comment from findings."""
|
||||
bullet_list = "\n".join(f"- {f}" for f in findings)
|
||||
return COMMENT_TEMPLATE.format(
|
||||
user=user, contributing=CONTRIBUTING, findings=bullet_list,
|
||||
)
|
||||
|
||||
|
||||
def write_step_summary(findings):
|
||||
"""Write a summary to GITHUB_STEP_SUMMARY."""
|
||||
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if not summary_file:
|
||||
return
|
||||
lines = ["## PR Check Summary\n"]
|
||||
if findings:
|
||||
lines.append(f"⚠️ Found {len(findings)} issue(s):\n")
|
||||
for f in findings:
|
||||
lines.append(f"- {f}")
|
||||
else:
|
||||
lines.append("✅ All checks passed.\n")
|
||||
with open(summary_file, "a") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
user = os.environ.get("PR_USER", "contributor")
|
||||
pr_number = os.environ.get("PR_NUMBER", "")
|
||||
run_id = os.environ.get("RUN_ID", "")
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
if pr_number:
|
||||
with open(os.path.join(OUTPUT_DIR, "number.txt"), "w") as f:
|
||||
f.write(pr_number)
|
||||
if run_id:
|
||||
with open(os.path.join(OUTPUT_DIR, "run-id.txt"), "w") as f:
|
||||
f.write(run_id)
|
||||
|
||||
findings = collect_findings()
|
||||
write_step_summary(findings)
|
||||
|
||||
if findings:
|
||||
comment = format_comment(findings, user)
|
||||
with open(os.path.join(OUTPUT_DIR, "comment.md"), "w") as f:
|
||||
f.write(comment)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
PyYAML==6.0.1
|
||||
requests==2.31.0
|
||||
jsonschema
|
||||
pyyaml
|
||||
termcolor
|
||||
jsonschema==4.23.0
|
||||
requests==2.32.3
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Reference in a new issue