mirror of
https://github.com/Anon-Planet/thgtoa.git
synced 2026-06-21 04:58:04 +02:00
Compare commits
1 Commits
d1817e9049
..
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| f667d020d5 |
@@ -1,9 +0,0 @@
|
|||||||
[tool.commitizen]
|
|
||||||
name = "cz_conventional_commits"
|
|
||||||
|
|
||||||
# enforce sign-off below as well
|
|
||||||
extra_arguments = ["-S", "--signoff"]
|
|
||||||
|
|
||||||
# harmless redundancy
|
|
||||||
[tool.commitizen.customize]
|
|
||||||
schema_pattern = '^(feat|add|fix|bugfix|revert|security|perf|refactor|change|chore|ci|docs|style|test|build)(\(.+\))?(!)?: .{1,72}(\n.*)*\nSigned-off-by: .+ <.+@.+>'
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: 📖 Build PDF
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "docs/**"
|
||||||
|
- "mkdocs.yml"
|
||||||
|
- "scripts/build_guide_pdf.py"
|
||||||
|
- ".github/workflows/build-pdf.yml"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "docs/**"
|
||||||
|
- "mkdocs.yml"
|
||||||
|
- "scripts/build_guide_pdf.py"
|
||||||
|
- ".github/workflows/build-pdf.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pdf:
|
||||||
|
name: MkDocs + print to PDF
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install MkDocs Material
|
||||||
|
run: pip install mkdocs-material
|
||||||
|
|
||||||
|
- name: Install Chromium
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends chromium
|
||||||
|
|
||||||
|
- name: Build PDF
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
run: python scripts/build_guide_pdf.py
|
||||||
|
|
||||||
|
- name: Upload PDF artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: guide-pdf
|
||||||
|
path: export/guide.pdf
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
name: 🗑️ DEPRECATED — Build & Sign & Release (combined)
|
|
||||||
|
|
||||||
# DEPRECATED — replaced by build.yml, sign.yml, and release.yml
|
|
||||||
# This workflow is disabled. It is kept only as a reference until the
|
|
||||||
# split workflows have been confirmed stable in production.
|
|
||||||
# Do not trigger this workflow.
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
_disabled:
|
|
||||||
description: 'This workflow is deprecated. Use build.yml → sign.yml → release.yml instead.'
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
noop:
|
|
||||||
name: Deprecated — no-op
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: ❌ Workflow is deprecated
|
|
||||||
run: |
|
|
||||||
echo "This workflow is deprecated."
|
|
||||||
echo "Use build.yml → sign.yml → release.yml instead."
|
|
||||||
exit 1
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# 1. Push to main → build.yml runs automatically → note the run ID
|
|
||||||
# 2. Manually trigger sign.yml with that build run ID → note the sign run ID
|
|
||||||
# 3. Manually trigger release.yml with: version=v1.2.5, sign_run_id=<id>
|
|
||||||
# 4. Manually trigger changelog.yml with: version=v1.2.5
|
|
||||||
|
|
||||||
name: 📖 Build PDFs
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
build_mode:
|
|
||||||
description: 'PDF build mode'
|
|
||||||
required: true
|
|
||||||
default: 'both'
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- light
|
|
||||||
- dark
|
|
||||||
- both
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "docs/**"
|
|
||||||
- "mkdocs.yml"
|
|
||||||
- "scripts/**"
|
|
||||||
- ".github/workflows/build.yml"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build PDFs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
build_mode: ${{ steps.mode.outputs.build_mode }}
|
|
||||||
run_id: ${{ github.run_id }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 🛠️ Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: 🐍 Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.13"
|
|
||||||
|
|
||||||
- name: 📦 Install Python dependencies
|
|
||||||
run: pip install "mkdocs-material[imaging]" pillow numpy
|
|
||||||
|
|
||||||
- name: 🖼️ Install poppler and qpdf
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y poppler-utils qpdf
|
|
||||||
|
|
||||||
- name: Setup Chrome
|
|
||||||
uses: browser-actions/setup-chrome@v2
|
|
||||||
with:
|
|
||||||
chrome-version: 120
|
|
||||||
install-dependencies: true
|
|
||||||
install-chromedriver: true
|
|
||||||
|
|
||||||
- name: 🖨️ Resolve build mode
|
|
||||||
id: mode
|
|
||||||
run: |
|
|
||||||
MODE="${{ inputs.build_mode || 'both' }}"
|
|
||||||
echo "build_mode=$MODE" >> $GITHUB_OUTPUT
|
|
||||||
echo "Building in mode: $MODE"
|
|
||||||
|
|
||||||
- name: 🖨️ Build PDFs
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
run: python scripts/build_guide_pdf.py --${{ steps.mode.outputs.build_mode }}
|
|
||||||
|
|
||||||
- name: 📤 Upload PDF artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: pdfs
|
|
||||||
path: |
|
|
||||||
export/thgtoa.pdf
|
|
||||||
export/thgtoa-dark.pdf
|
|
||||||
if-no-files-found: warn
|
|
||||||
retention-days: 90
|
|
||||||
compression-level: 0
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
name: 📝 Update Changelog
|
|
||||||
|
|
||||||
# Manual only — run after a release is published. Provide the exact version
|
|
||||||
# string (e.g. v1.2.4) to prepend to the changelog. Version is required to
|
|
||||||
# prevent silent auto-increment drift from release tags.
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Version string to record (e.g. v1.2.4) — required'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
dry_run:
|
|
||||||
description: 'Dry run — print entry without committing'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write # commit changelog back to main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
changelog:
|
|
||||||
name: Prepend changelog entry
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 🛠️ Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
# Use a PAT so the commit triggers downstream workflows (GITHUB_TOKEN won't)
|
|
||||||
token: ${{ secrets.CHANGELOG_PAT || secrets.GITHUB_TOKEN }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🐍 Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.13"
|
|
||||||
|
|
||||||
- name: 📝 Generate and prepend changelog entry
|
|
||||||
env:
|
|
||||||
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
|
||||||
MANUAL_VERSION: ${{ inputs.version }}
|
|
||||||
GH_SHA: ${{ github.sha }}
|
|
||||||
GH_REF: ${{ github.ref_name }}
|
|
||||||
TRIGGERING_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
|
||||||
run: python scripts/update_changelog.py
|
|
||||||
|
|
||||||
- name: 📤 Commit changelog
|
|
||||||
if: ${{ inputs.dry_run != 'true' }}
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add docs/changelog/index.md
|
|
||||||
# Only commit if there's actually a change
|
|
||||||
git diff --cached --quiet && echo "No changelog change to commit." || \
|
|
||||||
git commit -m "docs: update changelog [skip ci]"
|
|
||||||
git push
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
name: 🚀 Release
|
|
||||||
|
|
||||||
# Manual only — run this deliberately after build and sign are confirmed good.
|
|
||||||
# Provide the sign.yml run ID to pull artifacts from. The release tag is
|
|
||||||
# generated automatically as release-YYYYMMDD-<short-sha> — no version input
|
|
||||||
# needed, no semver drift possible.
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
sign_run_id:
|
|
||||||
description: 'sign.yml run ID to pull signatures and PDFs from'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
prerelease:
|
|
||||||
description: 'Mark as pre-release?'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write # create releases and tags
|
|
||||||
actions: read # download artifacts from other runs
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
name: Publish GitHub Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 🛠️ Checkout (for pgp/)
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
sparse-checkout: pgp
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Download artifacts from the specified sign run
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
- name: 📥 Download signatures artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: signatures
|
|
||||||
path: release/
|
|
||||||
run-id: ${{ inputs.sign_run_id }}
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: 📥 Download signed PDFs artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: pdfs-signed
|
|
||||||
path: release/
|
|
||||||
run-id: ${{ inputs.sign_run_id }}
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: 📋 List release assets
|
|
||||||
run: ls -lh release/
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Read hashes for the release body
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
- name: "#️⃣ Read hashes"
|
|
||||||
id: hashes
|
|
||||||
run: |
|
|
||||||
read_hash() { cat "release/$1" 2>/dev/null || echo "(not built)"; }
|
|
||||||
echo "light_sha256=$(read_hash thgtoa.pdf.sha256)" >> $GITHUB_OUTPUT
|
|
||||||
echo "dark_sha256=$(read_hash thgtoa-dark.pdf.sha256)" >> $GITHUB_OUTPUT
|
|
||||||
echo "light_b2=$(read_hash thgtoa.pdf.b2sum)" >> $GITHUB_OUTPUT
|
|
||||||
echo "dark_b2=$(read_hash thgtoa-dark.pdf.b2sum)" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# VirusTotal
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
- name: 🦠 Upload PDFs to VirusTotal
|
|
||||||
id: vt
|
|
||||||
uses: crazy-max/ghaction-virustotal@v5
|
|
||||||
with:
|
|
||||||
vt_api_key: ${{ secrets.VT_API_KEY }}
|
|
||||||
files: |
|
|
||||||
release/thgtoa.pdf
|
|
||||||
release/thgtoa-dark.pdf
|
|
||||||
|
|
||||||
- name: 🔗 Build VT report URLs
|
|
||||||
id: vt_urls
|
|
||||||
run: |
|
|
||||||
light_hash=$(cat release/thgtoa.pdf.sha256 2>/dev/null || echo "")
|
|
||||||
dark_hash=$(cat release/thgtoa-dark.pdf.sha256 2>/dev/null || echo "")
|
|
||||||
if [ -n "$light_hash" ]; then
|
|
||||||
echo "light_vt=https://www.virustotal.com/gui/file/${light_hash}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "light_vt=(not built)" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
if [ -n "$dark_hash" ]; then
|
|
||||||
echo "dark_vt=https://www.virustotal.com/gui/file/${dark_hash}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "dark_vt=(not built)" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Generate release tag — timestamp + short SHA, always unique
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
- name: 🏷️ Generate release tag
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
|
||||||
DATE=$(date -u +'%Y%m%d')
|
|
||||||
TAG="release-${DATE}-${SHORT_SHA}"
|
|
||||||
NAME="Release ${DATE} (${SHORT_SHA})"
|
|
||||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
|
||||||
echo "name=$NAME" >> $GITHUB_OUTPUT
|
|
||||||
echo "Tag: $TAG"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Create GitHub Release
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
- name: 🚀 Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ steps.tag.outputs.tag }}
|
|
||||||
name: ${{ steps.tag.outputs.name }}
|
|
||||||
prerelease: ${{ inputs.prerelease || false }}
|
|
||||||
draft: false
|
|
||||||
fail_on_unmatched_files: false
|
|
||||||
body: |
|
|
||||||
## 📖 The Hitchhiker's Guide to Online Anonymity
|
|
||||||
|
|
||||||
Built from [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}) on `${{ github.ref_name }}`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📄 Release assets
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `thgtoa.pdf` | Light mode PDF |
|
|
||||||
| `thgtoa-dark.pdf` | Dark mode PDF (hacker theme) |
|
|
||||||
| `sha256sums.txt` | SHA-256 checksums (both files) |
|
|
||||||
| `b2sums.txt` | BLAKE2b checksums (both files) |
|
|
||||||
| `thgtoa.pdf.sha256` | SHA-256 — light PDF |
|
|
||||||
| `thgtoa-dark.pdf.sha256` | SHA-256 — dark PDF |
|
|
||||||
| `thgtoa.pdf.b2sum` | BLAKE2b — light PDF |
|
|
||||||
| `thgtoa-dark.pdf.b2sum` | BLAKE2b — dark PDF |
|
|
||||||
| `*.asc` | GPG detached signatures (ASCII armor) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### #️⃣ Hashes
|
|
||||||
|
|
||||||
**thgtoa.pdf** (light)
|
|
||||||
```text
|
|
||||||
SHA-256 ${{ steps.hashes.outputs.light_sha256 }}
|
|
||||||
BLAKE2b ${{ steps.hashes.outputs.light_b2 }}
|
|
||||||
```
|
|
||||||
|
|
||||||
**thgtoa-dark.pdf** (dark)
|
|
||||||
```text
|
|
||||||
SHA-256 ${{ steps.hashes.outputs.dark_sha256 }}
|
|
||||||
BLAKE2b ${{ steps.hashes.outputs.dark_b2 }}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔏 Verifying GPG signatures
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Import the release signing key
|
|
||||||
gpg --import pgp/anonymousplanet-release.asc
|
|
||||||
|
|
||||||
# Verify PDFs
|
|
||||||
gpg --verify thgtoa.pdf.asc thgtoa.pdf
|
|
||||||
gpg --verify thgtoa-dark.pdf.asc thgtoa-dark.pdf
|
|
||||||
|
|
||||||
# Verify hash files
|
|
||||||
gpg --verify sha256sums.txt.asc sha256sums.txt
|
|
||||||
gpg --verify b2sums.txt.asc b2sums.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🦠 VirusTotal scans
|
|
||||||
|
|
||||||
| File | Report |
|
|
||||||
|------|--------|
|
|
||||||
| `thgtoa.pdf` | ${{ steps.vt_urls.outputs.light_vt }} |
|
|
||||||
| `thgtoa-dark.pdf` | ${{ steps.vt_urls.outputs.dark_vt }} |
|
|
||||||
|
|
||||||
files: |
|
|
||||||
release/thgtoa.pdf
|
|
||||||
release/thgtoa-dark.pdf
|
|
||||||
release/sha256sums.txt
|
|
||||||
release/b2sums.txt
|
|
||||||
release/thgtoa.pdf.sha256
|
|
||||||
release/thgtoa-dark.pdf.sha256
|
|
||||||
release/thgtoa.pdf.b2sum
|
|
||||||
release/thgtoa-dark.pdf.b2sum
|
|
||||||
release/thgtoa.pdf.asc
|
|
||||||
release/thgtoa-dark.pdf.asc
|
|
||||||
release/sha256sums.txt.asc
|
|
||||||
release/b2sums.txt.asc
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
name: 🔏 Sign PDFs
|
|
||||||
|
|
||||||
# Can be triggered:
|
|
||||||
# 1. Automatically after build.yml completes on main
|
|
||||||
# 2. Manually, pointing at a specific build run to pull PDFs from
|
|
||||||
on:
|
|
||||||
# workflow_run:
|
|
||||||
# workflows: ["📖 Build PDFs"]
|
|
||||||
# types: [completed]
|
|
||||||
# branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
build_run_id:
|
|
||||||
description: 'build.yml run ID to download PDFs from'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
actions: read # download artifacts from other runs
|
|
||||||
contents: write # needed to commit export/ files back to the repo
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sign:
|
|
||||||
name: Hash & Sign PDFs
|
|
||||||
# On workflow_run, only proceed if the build actually succeeded
|
|
||||||
# if: >
|
|
||||||
# github.event_name == 'workflow_dispatch' ||
|
|
||||||
# github.event.workflow_run.conclusion == 'success'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
light_sha256: ${{ steps.hashes.outputs.light_sha256 }}
|
|
||||||
dark_sha256: ${{ steps.hashes.outputs.dark_sha256 }}
|
|
||||||
light_b2: ${{ steps.hashes.outputs.light_b2 }}
|
|
||||||
dark_b2: ${{ steps.hashes.outputs.dark_b2 }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 🛠️ Checkout (for pgp/ key reference only)
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
sparse-checkout: pgp
|
|
||||||
|
|
||||||
- name: 📥 Download PDF artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: pdfs
|
|
||||||
path: export/
|
|
||||||
run-id: ${{ inputs.build_run_id }}
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: 📋 List downloaded files
|
|
||||||
run: ls -lh export/
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Hash — extensions match export/ conventions: .sha256, .b2sum
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
- name: "#️⃣ Hash PDFs"
|
|
||||||
id: hashes
|
|
||||||
run: |
|
|
||||||
cd export
|
|
||||||
|
|
||||||
for f in thgtoa.pdf thgtoa-dark.pdf; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
sha256sum "$f" | awk '{print $1}' > "${f}.sha256"
|
|
||||||
b2sum "$f" | awk '{print $1}' > "${f}.b2sum"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Combined summary files
|
|
||||||
sha256sum thgtoa.pdf thgtoa-dark.pdf 2>/dev/null > sha256sums.txt || \
|
|
||||||
sha256sum thgtoa.pdf 2>/dev/null > sha256sums.txt
|
|
||||||
b2sum thgtoa.pdf thgtoa-dark.pdf 2>/dev/null > b2sums.txt || \
|
|
||||||
b2sum thgtoa.pdf 2>/dev/null > b2sums.txt
|
|
||||||
|
|
||||||
light_sha256=$(cat thgtoa.pdf.sha256 2>/dev/null || echo "")
|
|
||||||
dark_sha256=$(cat thgtoa-dark.pdf.sha256 2>/dev/null || echo "")
|
|
||||||
light_b2=$(cat thgtoa.pdf.b2sum 2>/dev/null || echo "")
|
|
||||||
dark_b2=$(cat thgtoa-dark.pdf.b2sum 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
echo "light_sha256=$light_sha256" >> $GITHUB_OUTPUT
|
|
||||||
echo "dark_sha256=$dark_sha256" >> $GITHUB_OUTPUT
|
|
||||||
echo "light_b2=$light_b2" >> $GITHUB_OUTPUT
|
|
||||||
echo "dark_b2=$dark_b2" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
echo "--- SHA-256 ---"
|
|
||||||
cat sha256sums.txt
|
|
||||||
echo "--- BLAKE2b ---"
|
|
||||||
cat b2sums.txt
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# GPG sign — detached ASCII-armor signatures use .asc extension
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
- name: 🔑 Install GPG
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y gnupg
|
|
||||||
|
|
||||||
- name: 🔏 Import GPG signing key
|
|
||||||
env:
|
|
||||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
|
||||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
|
||||||
run: |
|
|
||||||
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
|
|
||||||
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
|
|
||||||
--pinentry-mode loopback --list-secret-keys
|
|
||||||
|
|
||||||
- name: 🔏 Sign PDFs and hash files
|
|
||||||
env:
|
|
||||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
|
||||||
run: |
|
|
||||||
sign() {
|
|
||||||
local file="$1"
|
|
||||||
[ -f "$file" ] || return 0
|
|
||||||
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
|
|
||||||
--pinentry-mode loopback \
|
|
||||||
--detach-sign --armor --output "${file}.asc" "$file"
|
|
||||||
echo "Signed: $file → ${file}.asc"
|
|
||||||
}
|
|
||||||
sign export/thgtoa.pdf
|
|
||||||
sign export/thgtoa-dark.pdf
|
|
||||||
sign export/sha256sums.txt
|
|
||||||
sign export/b2sums.txt
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Commit export/ back to main
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
- name: 📦 Checkout full repo for commit
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
fetch-depth: 0
|
|
||||||
path: repo
|
|
||||||
|
|
||||||
- name: 📂 Copy export files into repo
|
|
||||||
run: cp -v export/* repo/export/
|
|
||||||
|
|
||||||
- name: 🔏 Configure SSH commit signing
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${{ secrets.ACTIONS_SSH_SIGNING_KEY }}" > ~/.ssh/signing_key
|
|
||||||
chmod 600 ~/.ssh/signing_key
|
|
||||||
git config --global gpg.format ssh
|
|
||||||
git config --global user.signingKey ~/.ssh/signing_key
|
|
||||||
git config --global commit.gpgSign true
|
|
||||||
git config --global user.name "github-actions[bot]"
|
|
||||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: 📤 Commit and push export/ to main
|
|
||||||
working-directory: repo
|
|
||||||
run: |
|
|
||||||
git add export/
|
|
||||||
if git diff --cached --quiet; then
|
|
||||||
echo "Nothing to commit — export/ is already up to date."
|
|
||||||
else
|
|
||||||
git commit -S -m "chore(export): update PDFs, hashes and signatures [skip ci]"
|
|
||||||
git push origin main
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Upload artifacts for release.yml to consume
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
- name: 📤 Upload signatures artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: signatures
|
|
||||||
path: |
|
|
||||||
export/sha256sums.txt
|
|
||||||
export/b2sums.txt
|
|
||||||
export/thgtoa.pdf.sha256
|
|
||||||
export/thgtoa-dark.pdf.sha256
|
|
||||||
export/thgtoa.pdf.b2sum
|
|
||||||
export/thgtoa-dark.pdf.b2sum
|
|
||||||
export/thgtoa.pdf.asc
|
|
||||||
export/thgtoa-dark.pdf.asc
|
|
||||||
export/sha256sums.txt.asc
|
|
||||||
export/b2sums.txt.asc
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 90
|
|
||||||
compression-level: 0
|
|
||||||
|
|
||||||
- name: 📤 Upload signed PDFs artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: pdfs-signed
|
|
||||||
path: |
|
|
||||||
export/thgtoa.pdf
|
|
||||||
export/thgtoa-dark.pdf
|
|
||||||
if-no-files-found: warn
|
|
||||||
retention-days: 90
|
|
||||||
compression-level: 0
|
|
||||||
+1
-8
@@ -19,11 +19,4 @@ ENV/
|
|||||||
site/
|
site/
|
||||||
_site/
|
_site/
|
||||||
_site_test/
|
_site_test/
|
||||||
build/
|
export/
|
||||||
|
|
||||||
# Export directory — track only hashes and signatures, not the PDFs themselves
|
|
||||||
export/thgtoa.pdf.sha256
|
|
||||||
export/thgtoa-dark.pdf.sha256
|
|
||||||
export/thgtoa.pdf.b2sum
|
|
||||||
export/thgtoa-dark.pdf.b2sum
|
|
||||||
export/*.asc
|
|
||||||
|
|||||||
+18
-1
@@ -62,7 +62,24 @@ MD012:
|
|||||||
# Consecutive blank lines
|
# Consecutive blank lines
|
||||||
maximum: 1
|
maximum: 1
|
||||||
# MD013/line-length - Line length
|
# MD013/line-length - Line length
|
||||||
MD013: false
|
#
|
||||||
|
MD013:
|
||||||
|
# Number of characters
|
||||||
|
line_length: 80
|
||||||
|
# Number of characters for headings
|
||||||
|
heading_line_length: 80
|
||||||
|
# Number of characters for code blocks
|
||||||
|
code_block_line_length: 160
|
||||||
|
# Include code blocks
|
||||||
|
code_blocks: false
|
||||||
|
# Include tables
|
||||||
|
tables: false
|
||||||
|
# Include headings
|
||||||
|
headings: true
|
||||||
|
# Strict length checking (e.g. allow for longer URLs)
|
||||||
|
strict: false
|
||||||
|
# Stern length checking
|
||||||
|
stern: false
|
||||||
|
|
||||||
# MD014/commands-show-output - Dollar signs used before commands without showing output
|
# MD014/commands-show-output - Dollar signs used before commands without showing output
|
||||||
# TODO: set false for now but we should consider enabling it
|
# TODO: set false for now but we should consider enabling it
|
||||||
|
|||||||
+5
-12
@@ -10,21 +10,14 @@ repos:
|
|||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
- id: check-symlinks
|
- id: check-symlinks
|
||||||
|
- id: detect-private-key
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
args: [--fix=lf]
|
args: [--fix=lf]
|
||||||
|
|
||||||
- repo: https://github.com/commitizen-tools/commitizen
|
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||||
rev: v4.8.3
|
rev: v0.41.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: commitizen
|
- id: markdownlint
|
||||||
stages: [commit-msg]
|
- id: markdownlint-fix
|
||||||
|
|
||||||
- repo: local
|
|
||||||
hooks:
|
|
||||||
- id: require-commit-body
|
|
||||||
name: Avoid bad commits
|
|
||||||
language: script
|
|
||||||
entry: scripts/hooks/require-commit-body.sh
|
|
||||||
stages: [commit-msg]
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-04-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- GitHub Actions workflow **Build guide PDF** (`.github/workflows/build-pdf.yml`): installs Chromium on `ubuntu-latest`, runs `scripts/build_guide_pdf.py`, uploads `export/guide.pdf` as the `guide-pdf` artifact. Runs on `workflow_dispatch`, on pushes to `main` that touch docs or build inputs, and on matching pull requests.
|
||||||
|
|
||||||
|
- `scripts/build_guide_pdf.py` to build the MkDocs site and render the guide to a single PDF (`export/guide.pdf` by default) using a Chromium-based browser (Chrome or Edge) headless print-to-PDF.
|
||||||
|
- `docs/stylesheets/extra.css` and `extra_css` in `mkdocs.yml` for shared site styling.
|
||||||
|
- This `CHANGELOG.md`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `README.md` “Ways to read or export the guide”: hosted link, local `mkdocs serve`, PDF build via the script, ODT note, raw Markdown link.
|
||||||
|
- Guide landing layout: wrap the opening block in `docs/guide/index.md` with a `guide-intro-lead` container so the logo and first sections share one layout context for web and print.
|
||||||
|
- `.gitignore` to exclude local build outputs `export/`, `site/`, and `_site_test/`.
|
||||||
|
- `scripts/build_guide_pdf.py`: when the `CI` environment variable is set, pass Chromium flags (`--no-sandbox`, `--disable-setuid-sandbox`, `--disable-dev-shm-usage`) so headless print works on typical CI images.
|
||||||
|
- `README.md`: note the **Build guide PDF** GitHub Actions workflow and the `guide-pdf` artifact.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `docs/guide/index.md`: replace broken reference-style internal links (`[label][label:]`) with working same-page fragment links to the correct headings; correct the mismatched “Real-Name System” cross-reference; fix a broken footnote marker on the “free (unallocated) space of your hard drive” list item.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/Anon-Planet/thgtoa/compare/v1.2.1...HEAD
|
||||||
|
[1.2.1]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.1
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
Welcome.
|
Welcome.
|
||||||
|
|
||||||
This is a guide with the aim of providing an introduction to various online tracking techniques, online ID verification techniques, and detailed guidance to creating and maintaining (truly) anonymous online identities. <span style="color: red">**It is written with hope for activists, journalists, scientists, lawyers, whistle-blowers, and good people being oppressed, censored, harassed anywhere!**</span> This guide has no affiliation with the [Anonymous](https://en.wikipedia.org/wiki/Anonymous_(hacker_group)) <sup>[[Wikiless]](https://wikiless.com/wiki/Anonymous_(hacker_group))</sup> <sup>[[Archive.org]](https://web.archive.org/web/https://en.wikipedia.org/wiki/Anonymous_(hacker_group))</sup> collective/movement.
|
**[IMPORTANT RECOMMENDATION FOR UKRAINIANS. ВАЖЛИВА РЕКОМЕНДАЦІЯ ДЛЯ УКРАЇНЦІВ](briar.html)**
|
||||||
|
|
||||||
|
This is a maintained guide with the aim of providing an introduction to various online tracking techniques, online ID verification techniques, and detailed guidance to creating and maintaining (truly) anonymous online identities. <span style="color: red">**It is written with hope for activists, journalists, scientists, lawyers, whistle-blowers, and good people being oppressed, censored, harassed anywhere!**</span> This guide has no affiliation with the [Anonymous](https://en.wikipedia.org/wiki/Anonymous_(hacker_group)) <sup>[[Wikiless]](https://wikiless.com/wiki/Anonymous_(hacker_group))</sup> <sup>[[Archive.org]](https://web.archive.org/web/https://en.wikipedia.org/wiki/Anonymous_(hacker_group))</sup> collective/movement.
|
||||||
|
|
||||||
This guide is an open-source non-profit initiative, [licensed](LICENSE.html) under **Creative Commons Attribution-NonCommercial 4.0 International** ([cc-by-nc-4.0](https://creativecommons.org/licenses/by-nc/4.0/) <sup>[[Archive.org]](https://web.archive.org/web/https://creativecommons.org/licenses/by-nc/4.0/)</sup>) and is **not sponsored/endorsed by any commercial/governmental entity**. This means that you are free to use our guide for pretty much any purpose **excluding commercially** as long as you do attribute it. There are no ads or any affiliate links.
|
This guide is an open-source non-profit initiative, [licensed](LICENSE.html) under **Creative Commons Attribution-NonCommercial 4.0 International** ([cc-by-nc-4.0](https://creativecommons.org/licenses/by-nc/4.0/) <sup>[[Archive.org]](https://web.archive.org/web/https://creativecommons.org/licenses/by-nc/4.0/)</sup>) and is **not sponsored/endorsed by any commercial/governmental entity**. This means that you are free to use our guide for pretty much any purpose **excluding commercially** as long as you do attribute it. There are no ads or any affiliate links.
|
||||||
|
|
||||||
@@ -10,3 +12,27 @@ This guide is an open-source non-profit initiative, [licensed](LICENSE.html) und
|
|||||||
|
|
||||||
- **In your browser:** [Hitchhiker's Guide](https://www.anonymousplanet.org/guide/) (hosted site). After a local build you can also open `site/guide/index.html` directly.
|
- **In your browser:** [Hitchhiker's Guide](https://www.anonymousplanet.org/guide/) (hosted site). After a local build you can also open `site/guide/index.html` directly.
|
||||||
- **Local HTML preview:** from the repository root, with Python 3 and [MkDocs Material](https://squidfunk.github.io/mkdocs-material/getting-started/) installed (`pip install mkdocs-material`), run `mkdocs serve` and open the URL printed in the terminal (for example `http://127.0.0.1:8000`).
|
- **Local HTML preview:** from the repository root, with Python 3 and [MkDocs Material](https://squidfunk.github.io/mkdocs-material/getting-started/) installed (`pip install mkdocs-material`), run `mkdocs serve` and open the URL printed in the terminal (for example `http://127.0.0.1:8000`).
|
||||||
|
- **PDF (local build):** from the repository root, using the same environment, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/build_guide_pdf.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `mkdocs build` (output defaults to `./site`), then uses **Google Chrome** or **Microsoft Edge** in headless mode to print `site/guide/index.html` to **`export/guide.pdf`** (images and styling preserved). If the site is already built: `python scripts/build_guide_pdf.py --skip-mkdocs`. Other options: `--site-dir`, `--pdf`, and `python scripts/build_guide_pdf.py --help`.
|
||||||
|
|
||||||
|
On **GitHub Actions**, the [Build guide PDF](https://github.com/Anon-Planet/thgtoa/actions/workflows/build-pdf.yml) workflow does the same using Chromium on Ubuntu when you push to `main` or open a pull request that touches the guide or build inputs; download the **`guide-pdf`** artifact from a successful run. You can also run it manually (**Actions** → **Build guide PDF** → **Run workflow**).
|
||||||
|
- **OpenDocument (ODT):** not produced by this repository (previous hosted export removed).
|
||||||
|
- **Raw Markdown (very large):** [docs/guide/index.md on GitHub](https://raw.githubusercontent.com/Anon-Planet/thgtoa/refs/heads/main/docs/guide/index.md)
|
||||||
|
|
||||||
|
**Mirrors:**
|
||||||
|
- <del>Hidden service: <http://thgtoa3jzy3doku7hkna32htpghjijefscwvh4dyjgfydbbjkeiohgid.onion/></del> **Host down**
|
||||||
|
|
||||||
|
Feel free to submit issues using Github Issues with the repository link above. Criticism, opinions, and ideas are welcome!
|
||||||
|
|
||||||
|
**Follow or contact us on:**
|
||||||
|
|
||||||
|
Discussion Channels:
|
||||||
|
- Matrix room: <https://matrix.to/#/#anonymity:anonymousplanet.net>
|
||||||
|
- Matrix space: <https://matrix.to/#/#psa:anonymousplanet.net>
|
||||||
|
|
||||||
|
Have a good read and feel free to share and/or recommend it!
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
title: "Anonymous Planet"
|
title: "About Anonymous Planet"
|
||||||
description: We are the maintainers of the Hitchhiker's Guide and the PSA Matrix space.
|
description: We are the maintainers of the Hitchhiker's Guide and the PSA Matrix space.
|
||||||
schema:
|
schema:
|
||||||
"@context": https://schema.org
|
"@context": https://schema.org
|
||||||
@@ -7,7 +7,7 @@ schema:
|
|||||||
"@id": https://www.anonymousplanet.org/
|
"@id": https://www.anonymousplanet.org/
|
||||||
name: Anonymous Planet
|
name: Anonymous Planet
|
||||||
url: https://www.anonymousplanet.org/about/
|
url: https://www.anonymousplanet.org/about/
|
||||||
logo: ../media/profile.png
|
logo: ../media/favicon.png
|
||||||
sameAs:
|
sameAs:
|
||||||
- https://github.com/Anon-Planet
|
- https://github.com/Anon-Planet
|
||||||
- https://opencollective.com/anonymousplanetorg
|
- https://opencollective.com/anonymousplanetorg
|
||||||
@@ -15,7 +15,7 @@ schema:
|
|||||||
---
|
---
|
||||||
{ align=right }
|
{ align=right }
|
||||||
|
|
||||||
**Anonymous Planet** are the maintainers of the [_Hitchhiker's Guide_](../guide/index.md) and the [_PSA Community_](https://psa.anonymousplanet.org). It is responsible for maintaining the projects and code repositories. This project is part of our ongoing efforts to provide open-source tools and resources for the community, with regular updates and improvements added to the changelog.
|
**Anonymous Planet** are the maintainers of the [_Hitchhiker's Guide_](https://anonymousplanet.org/guide.html) and the [_PSA Community_](https://psa.anonymousplanet.org). It is responsible for maintaining the projects and code repositories.
|
||||||
|
|
||||||
The purpose: providing an introduction to various online tracking techniques, online ID verification techniques, and detailed guidance to creating and maintaining (truly) anonymous online identities. It is written with the hopes that good people (e.g., activists, journalists, scientists, lawyers, whistle-blowers, etc.) will be able to fight oppression, censorship and harassment! The website and projects are free (as in freedom) and not affiliated with any donor or projects discussed.
|
The purpose: providing an introduction to various online tracking techniques, online ID verification techniques, and detailed guidance to creating and maintaining (truly) anonymous online identities. It is written with the hopes that good people (e.g., activists, journalists, scientists, lawyers, whistle-blowers, etc.) will be able to fight oppression, censorship and harassment! The website and projects are free (as in freedom) and not affiliated with any donor or projects discussed.
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Release Notes"
|
|
||||||
description: "Release Notes"
|
|
||||||
schema:
|
|
||||||
"@context": https://schema.org
|
|
||||||
"@type": Organization
|
|
||||||
"@id": https://www.anonymousplanet.org/
|
|
||||||
name: Anonymous Planet
|
|
||||||
url: https://www.anonymousplanet.org/authors/
|
|
||||||
logo: ../media/profile.png
|
|
||||||
sameAs:
|
|
||||||
- https://github.com/Anon-Planet
|
|
||||||
- https://opencollective.com/anonymousplanetorg
|
|
||||||
- https://mastodon.social/@anonymousplanet
|
|
||||||
---
|
|
||||||
|
|
||||||
# Release Notes
|
|
||||||
|
|
||||||
Notable changes to the guide and its tooling. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [v1.2.3]
|
|
||||||
|
|
||||||
CI/CD pipeline split into independent stages, dark PDF quality improved, release signing automated, and the changelog now updates itself on every build. Skipping v1.2.2 which was a placeholder and contained broken Python unsuitable for a tag/release.
|
|
||||||
|
|
||||||
!!! success "Added"
|
|
||||||
|
|
||||||
- **Dark mode PDF** (`scripts/convert.py`): pixel-level converter replaces the broken `--prefers-color-scheme=dark` Chromium flag. Produces a 200 DPI hacker-themed PDF (`#1f1f31` background, `#e0e0e0` text, `#5e8bde` links) with batched page processing to avoid OOM on large documents.
|
|
||||||
- **Three independent CI workflows** replacing the old monolithic `build-sign-release.yml`:
|
|
||||||
- `build.yml` — builds PDFs and uploads them as an artifact; no secrets required, can be re-run freely.
|
|
||||||
- `sign.yml` — downloads the PDF artifact, computes SHA-256 and BLAKE2b hashes, GPG-signs all outputs, and uploads a `signatures` artifact. Can be re-run against any historical build.
|
|
||||||
- `release.yml` — downloads both artifacts, uploads to VirusTotal, and publishes a tagged GitHub Release with all 12 assets attached. Can be triggered manually against any previous sign run.
|
|
||||||
- **`scripts/update_changelog.py`**: reads `git log` since the last version tag, categorises commits by conventional-commit prefix, and prepends a new entry to this file automatically after each successful build.
|
|
||||||
- **`changelog.yml`** workflow: commits the auto-generated changelog entry back to `main` after every build, with `dry_run` and `manual_version` dispatch inputs for safe local testing.
|
|
||||||
- **`scripts/tag_release.py`**: interactive guided helper for maintainers to create GPG-signed annotated tags. Checks clean tree and branch, auto-increments the version, pulls the message from the changelog, resolves the release signing key, creates and verifies the tag, then prints the push command.
|
|
||||||
- **`docs/code/develop.md`**: full developer reference covering prerequisites, local build instructions, the pipeline flow, all required GitHub Secrets, the release process, verification steps, and a troubleshooting section for every known CI failure mode.
|
|
||||||
|
|
||||||
!!! warning "Changed"
|
|
||||||
|
|
||||||
- `build-sign-release.yml` deprecated — push triggers removed, manual dispatch only. Will be deleted once in-flight runs complete.
|
|
||||||
- The full pipeline (build → sign → release → changelog) now chains automatically via `workflow_run` on every push to `main`.
|
|
||||||
- GPG signing uses `--pinentry-mode loopback` and `--passphrase-fd 0` to avoid interactive prompts on headless runners.
|
|
||||||
- VirusTotal scans moved to the release stage so they run once per release, not once per build.
|
|
||||||
- `.gitignore` updated to track `.b2` per-file hash files alongside existing `.sha256` and `.sig` entries.
|
|
||||||
- Stale information removed from the guide; deprecated ODT section in Appendix A6 commented out.
|
|
||||||
- Footer copyright information corrected.
|
|
||||||
|
|
||||||
!!! bug "Fixed"
|
|
||||||
|
|
||||||
- `_save_images_as_pdf` in `convert.py` was passing raw PNG files to `qpdf --pages`, which only accepts PDF inputs. Fixed by quantizing each page to palette mode (256 colours, FASTOCTREE) and saving as a single-page PDF before merging.
|
|
||||||
- `convert.py` now fails immediately with install instructions if `pdftoppm` or `qpdf` are missing, instead of crashing with an unhelpful `FileNotFoundError`.
|
|
||||||
- Pillow `KeyError: 'JPEG'` on CI resolved by installing `mkdocs-material[imaging]` and using palette-mode PDF encoding instead of RGB+JPEG.
|
|
||||||
- Orphaned footnote citations `[^536]` and `[^537]` (Australian privacy law and the Identify and Disrupt Act) restored at the key disclosure law paragraph in the guide.
|
|
||||||
- Broken internal links and mismatched cross-references throughout the guide corrected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [v1.2.1]
|
|
||||||
|
|
||||||
First automated PDF build and the start of the CI pipeline.
|
|
||||||
|
|
||||||
!!! success "Added"
|
|
||||||
|
|
||||||
- `scripts/build_guide_pdf.py`: builds the MkDocs site and renders the full guide to a single PDF via headless Chromium (Chrome or Edge). Supports `--dark`, `--light`, and `--both` modes.
|
|
||||||
- GitHub Actions workflow that installs Chromium, runs the build script, and uploads `export/thgtoa.pdf` as an artifact on every push to `main` or manual dispatch.
|
|
||||||
- `docs/stylesheets/extra.css` for shared site styling.
|
|
||||||
- This changelog.
|
|
||||||
|
|
||||||
!!! warning "Changed"
|
|
||||||
|
|
||||||
- `README.md` updated with instructions for local PDF export and a note about the GitHub Actions artifact.
|
|
||||||
- `.gitignore` updated to exclude local build outputs (`export/`, `site/`, `_site_test/`).
|
|
||||||
|
|
||||||
!!! bug "Fixed"
|
|
||||||
|
|
||||||
- Broken reference-style internal links throughout `docs/guide/index.md` replaced with correct fragment links.
|
|
||||||
- Broken footnote marker on the "free (unallocated) space" list item in the guide.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[v1.2.3]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.3
|
|
||||||
[v1.2.1]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.1
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
# Developer Guide
|
|
||||||
|
|
||||||
This page covers everything you need to contribute to the project, run the build pipeline locally, configure GitHub Secrets, and publish a release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Install these before anything else.
|
|
||||||
|
|
||||||
=== "Linux / macOS"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Python 3.11+
|
|
||||||
python3 --version
|
|
||||||
|
|
||||||
# poppler (pdftoppm) and qpdf
|
|
||||||
sudo apt install poppler-utils qpdf # Debian / Ubuntu
|
|
||||||
brew install poppler qpdf # macOS
|
|
||||||
|
|
||||||
# GPG
|
|
||||||
sudo apt install gnupg # Debian / Ubuntu
|
|
||||||
brew install gnupg # macOS
|
|
||||||
|
|
||||||
# Python dependencies
|
|
||||||
pip install "mkdocs-material[imaging]" pillow numpy
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "Windows"
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Python 3.11+ from https://python.org
|
|
||||||
|
|
||||||
# poppler — download from https://github.com/oschwartz10612/poppler-windows/releases
|
|
||||||
# Extract and add the bin\ folder to PATH
|
|
||||||
|
|
||||||
# qpdf — download from https://github.com/qpdf/qpdf/releases
|
|
||||||
# Extract and add the bin\ folder to PATH
|
|
||||||
|
|
||||||
# GPG — download Gpg4win from https://gpg4win.org
|
|
||||||
|
|
||||||
# Python dependencies
|
|
||||||
pip install "mkdocs-material[imaging]" pillow numpy
|
|
||||||
```
|
|
||||||
|
|
||||||
You also need **Google Chrome** or **Microsoft Edge** installed for the light-mode PDF build (headless Chromium).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository layout
|
|
||||||
|
|
||||||
```
|
|
||||||
.github/
|
|
||||||
workflows/
|
|
||||||
build.yml ← builds PDFs, uploads artifact
|
|
||||||
sign.yml ← hashes + GPG signs, uploads signatures artifact
|
|
||||||
release.yml ← publishes GitHub Release with all assets
|
|
||||||
changelog.yml ← prepends a new entry to docs/changelog/index.md
|
|
||||||
publish.yml ← deploys MkDocs site to GitHub Pages
|
|
||||||
build-sign-release.yml← DEPRECATED — fails on trigger, kept for reference
|
|
||||||
docs/
|
|
||||||
guide/index.md ← the guide (single Markdown file)
|
|
||||||
changelog/ ← release notes
|
|
||||||
code/ ← this page
|
|
||||||
export/ ← PDF output (PDFs gitignored; .sha256, .b2sum, .asc tracked)
|
|
||||||
pgp/ ← public signing keys
|
|
||||||
scripts/
|
|
||||||
build_guide_pdf.py ← MkDocs + Chromium PDF builder
|
|
||||||
convert.py ← pixel-based dark mode PDF converter
|
|
||||||
update_changelog.py ← auto-generates changelog entries from git log
|
|
||||||
setup_workflow.py ← GitHub Secrets setup assistant
|
|
||||||
verify_pdf.py ← signature verification helper
|
|
||||||
archived/
|
|
||||||
tag_release.py ← ARCHIVED — GPG tag helper (not used in current flow)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Building locally
|
|
||||||
|
|
||||||
### Build both PDFs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/build_guide_pdf.py --both
|
|
||||||
```
|
|
||||||
|
|
||||||
This builds the MkDocs site, renders it to `export/thgtoa.pdf` via headless Chromium, then calls `scripts/convert.py` to produce `export/thgtoa-dark.pdf`.
|
|
||||||
|
|
||||||
| Flag | Effect |
|
|
||||||
|------|--------|
|
|
||||||
| `--both` | Light PDF then dark PDF |
|
|
||||||
| (no flag) | Light PDF only |
|
|
||||||
| `--dark` | Dark PDF only (light PDF must already exist) |
|
|
||||||
|
|
||||||
### Build only the dark PDF from an existing light PDF
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/convert.py export/thgtoa.pdf export/thgtoa-dark.pdf
|
|
||||||
```
|
|
||||||
|
|
||||||
Options:
|
|
||||||
|
|
||||||
| Flag | Default | Description |
|
|
||||||
|------|---------|-------------|
|
|
||||||
| `--dpi` | `200` | Rasterization DPI. 150 = smaller file, 300 = sharper but slow |
|
|
||||||
| `--batch-size` | `50` | Pages per batch. Reduce if you hit OOM |
|
|
||||||
| `--bg` | `1f1f31` | Background colour (hex) |
|
|
||||||
| `--text` | `e0e0e0` | Body text colour (hex) |
|
|
||||||
| `--link` | `5e8bde` | Link / blue element colour (hex) |
|
|
||||||
|
|
||||||
### Preview the MkDocs site
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdocs serve
|
|
||||||
```
|
|
||||||
|
|
||||||
Opens at `http://127.0.0.1:8000`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CI/CD pipeline overview
|
|
||||||
|
|
||||||
The pipeline is fully manual after the initial build — no step automatically triggers the next. This prevents version mismatches between what was built, what was signed, and what gets released.
|
|
||||||
|
|
||||||
```
|
|
||||||
push to main (or manual trigger)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
build.yml
|
|
||||||
Builds thgtoa.pdf + thgtoa-dark.pdf.
|
|
||||||
Uploads artifact: pdfs
|
|
||||||
Note the run ID.
|
|
||||||
│
|
|
||||||
│ ← manually trigger sign.yml with the build run ID
|
|
||||||
▼
|
|
||||||
sign.yml
|
|
||||||
Downloads pdfs artifact. Hashes (SHA-256 + BLAKE2b) and GPG-signs
|
|
||||||
all files. Commits export/ back to main. Uploads artifacts:
|
|
||||||
signatures, pdfs-signed
|
|
||||||
Note the run ID.
|
|
||||||
│
|
|
||||||
│ ← manually trigger release.yml with the sign run ID
|
|
||||||
▼
|
|
||||||
release.yml
|
|
||||||
Downloads signatures + pdfs-signed artifacts. Runs VirusTotal.
|
|
||||||
Creates GitHub Release tagged release-YYYYMMDD-<short-sha>.
|
|
||||||
│
|
|
||||||
│ ← manually trigger changelog.yml with the version string
|
|
||||||
▼
|
|
||||||
changelog.yml
|
|
||||||
Runs update_changelog.py, prepends a new ## [vX.Y.Z] entry,
|
|
||||||
commits back to main.
|
|
||||||
```
|
|
||||||
|
|
||||||
Each stage is independent. If signing fails (e.g. an expired key), re-run only `sign.yml` pointing at the existing build artifact — no need to rebuild the PDFs.
|
|
||||||
|
|
||||||
!!! warning "Before you push"
|
|
||||||
|
|
||||||
- Make sure the working tree is clean (`git status`)
|
|
||||||
- Run `mkdocs build` locally if you changed `docs/` to catch broken links before CI does
|
|
||||||
- If you added new footnotes, verify they have both a definition `[^N]:` and at least one inline citation `[^N]`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Release process (step by step)
|
|
||||||
|
|
||||||
### 1. Trigger a build
|
|
||||||
|
|
||||||
Push to `main` — `build.yml` runs automatically when `docs/`, `mkdocs.yml`, or `scripts/` change. You can also trigger it manually from **Actions → Build PDFs → Run workflow**.
|
|
||||||
|
|
||||||
Once it completes successfully, **note the run ID** from the URL or the Actions list.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Sign the PDFs
|
|
||||||
|
|
||||||
Go to **Actions → Sign PDFs → Run workflow**.
|
|
||||||
|
|
||||||
| Input | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| `build_run_id` | The run ID from step 1 |
|
|
||||||
|
|
||||||
`sign.yml` will:
|
|
||||||
|
|
||||||
- Download the PDFs artifact from the build run
|
|
||||||
- Compute SHA-256 and BLAKE2b hashes, writing `thgtoa.pdf.sha256`, `thgtoa.pdf.b2sum`, `sha256sums.txt`, `b2sums.txt`, and the dark equivalents
|
|
||||||
- GPG-sign all PDFs and hash files, writing `.asc` detached signature files
|
|
||||||
- Commit the updated `export/` directory back to `main`
|
|
||||||
- Upload two artifacts: `signatures` and `pdfs-signed`
|
|
||||||
|
|
||||||
Once it completes successfully, **note the run ID**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Publish the release
|
|
||||||
|
|
||||||
Go to **Actions → Release → Run workflow**.
|
|
||||||
|
|
||||||
| Input | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| `sign_run_id` | The run ID from step 2 |
|
|
||||||
| `prerelease` | `false` for a normal release |
|
|
||||||
|
|
||||||
`release.yml` will:
|
|
||||||
|
|
||||||
- Download `signatures` and `pdfs-signed` artifacts from the sign run
|
|
||||||
- Upload both PDFs to VirusTotal
|
|
||||||
- Auto-generate a release tag in the format `release-YYYYMMDD-<short-sha>` (e.g. `release-20260527-abc1234`)
|
|
||||||
- Create a GitHub Release with all PDFs, hash files, and signatures attached, and the VirusTotal report URLs in the body
|
|
||||||
|
|
||||||
No version number needs to be chosen at this step — the tag is derived from the date and commit SHA, so it is always unique and always traceable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Update the changelog
|
|
||||||
|
|
||||||
Go to **Actions → Update Changelog → Run workflow**.
|
|
||||||
|
|
||||||
| Input | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| `version` | The human-readable version string, e.g. `v1.2.4` |
|
|
||||||
| `dry_run` | `true` to preview without committing |
|
|
||||||
|
|
||||||
`changelog.yml` runs `scripts/update_changelog.py`, which:
|
|
||||||
|
|
||||||
- Reads git log since the last `## [vX.Y.Z]` heading in the changelog
|
|
||||||
- Categorises commits into Added / Changed / Fixed using conventional-commit prefixes
|
|
||||||
- Prepends a new `## [version]` admonition block to `docs/changelog/index.md`
|
|
||||||
- Commits the result back to `main`
|
|
||||||
|
|
||||||
The version string is the only human decision in the release process. It goes into the changelog only — it does not affect the release tag.
|
|
||||||
|
|
||||||
!!! tip "Previewing the changelog entry"
|
|
||||||
Run with `dry_run: true` first to review the generated entry before it is committed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Release tag format
|
|
||||||
|
|
||||||
Release tags use the format `release-YYYYMMDD-<short-sha>`, for example:
|
|
||||||
|
|
||||||
```
|
|
||||||
release-20260527-abc1234
|
|
||||||
```
|
|
||||||
|
|
||||||
This format is always unique, requires no version decision at release time, and is directly traceable to the commit that was built. The version string (e.g. `v1.2.4`) is a separate, human-assigned label that lives only in the changelog.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commit message format
|
|
||||||
|
|
||||||
All commits must follow the [Conventional Commits](https://www.conventionalcommits.org) format. This is enforced by the `commitizen` pre-commit hook.
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>(<scope>): <description>
|
|
||||||
```
|
|
||||||
|
|
||||||
Accepted types and their changelog bucket:
|
|
||||||
|
|
||||||
| Type | Bucket |
|
|
||||||
|------|--------|
|
|
||||||
| `feat`, `feature`, `add` | Added |
|
|
||||||
| `fix`, `bugfix`, `revert`, `security` | Fixed |
|
|
||||||
| `perf`, `refactor`, `change`, `chore`, `ci`, `docs`, `style`, `test`, `build` | Changed |
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
feat: add dark-mode PDF export
|
|
||||||
fix(scripts): handle locked PDF on Windows
|
|
||||||
docs: update developer workflow guide
|
|
||||||
chore(ci): pin Chrome version to 120
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GitHub Secrets
|
|
||||||
|
|
||||||
Configure these in **Settings → Secrets and variables → Actions** before the pipeline will fully work. The build step requires no secrets; signing and releasing require all of them.
|
|
||||||
|
|
||||||
### `GPG_PRIVATE_KEY`
|
|
||||||
|
|
||||||
The ASCII-armored private key used to sign PDFs and hash files.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gpg --armor --export-secret-keys C3023DBEA3FB38C438BA1EEDCEC60AEDE8B992A2
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy the entire output (including `-----BEGIN PGP PRIVATE KEY BLOCK-----` and the closing line) and paste it as the secret value.
|
|
||||||
|
|
||||||
!!! danger "Key security"
|
|
||||||
This is the release signing key. Only repository admins should have access to it. Never commit it to the repository or share it outside of GitHub Secrets.
|
|
||||||
|
|
||||||
### `GPG_PASSPHRASE`
|
|
||||||
|
|
||||||
The passphrase protecting the private key above. Must match exactly — no trailing newline.
|
|
||||||
|
|
||||||
### `ACTIONS_SSH_SIGNING_KEY`
|
|
||||||
|
|
||||||
An SSH private key used by `sign.yml` to sign the commit that pushes `export/` back to `main`. Generate a dedicated key for this:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh-keygen -t ed25519 -C "github-actions signing key" -f actions_signing_key
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the **private key** as the `ACTIONS_SSH_SIGNING_KEY` secret, and the **public key** to the repository's Deploy Keys (Settings → Deploy Keys) with write access.
|
|
||||||
|
|
||||||
### `VT_API_KEY`
|
|
||||||
|
|
||||||
A [VirusTotal](https://www.virustotal.com) API key with file upload permissions. Used by `release.yml` to scan both PDFs before publishing. Get one by creating a free account at `virustotal.com` → API key under your profile. The free tier (4 lookups/minute, 500/day) is sufficient.
|
|
||||||
|
|
||||||
### `CHANGELOG_PAT`
|
|
||||||
|
|
||||||
A GitHub Personal Access Token with `contents: write` scope on this repository. Needed because `changelog.yml` commits back to `main` — commits made with the default `GITHUB_TOKEN` do not trigger further workflow runs (GitHub loop-prevention). A PAT bypasses this. If absent, falls back to `GITHUB_TOKEN` — the commit still happens, it just won't trigger downstream workflows.
|
|
||||||
|
|
||||||
**Creating one:** GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens → set Contents to Read and write for this repo only.
|
|
||||||
|
|
||||||
### Secrets summary
|
|
||||||
|
|
||||||
| Secret | Required by | What happens if missing |
|
|
||||||
|--------|------------|------------------------|
|
|
||||||
| `GPG_PRIVATE_KEY` | `sign.yml` | Signing step fails — no `.asc` files produced |
|
|
||||||
| `GPG_PASSPHRASE` | `sign.yml` | GPG import succeeds but signing fails |
|
|
||||||
| `ACTIONS_SSH_SIGNING_KEY` | `sign.yml` | Export commit is unsigned (may fail if branch protection requires signed commits) |
|
|
||||||
| `VT_API_KEY` | `release.yml` | VirusTotal step fails — release is not published |
|
|
||||||
| `CHANGELOG_PAT` | `changelog.yml` | Falls back to `GITHUB_TOKEN` — changelog updates but commit won't trigger downstream workflows |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verifying a release
|
|
||||||
|
|
||||||
Anyone can verify the authenticity of a release download.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Import the release signing key
|
|
||||||
gpg --import pgp/anonymousplanet-release.asc
|
|
||||||
|
|
||||||
# Verify the PDFs
|
|
||||||
gpg --verify thgtoa.pdf.asc thgtoa.pdf
|
|
||||||
gpg --verify thgtoa-dark.pdf.asc thgtoa-dark.pdf
|
|
||||||
|
|
||||||
# Verify the hash files
|
|
||||||
gpg --verify sha256sums.txt.asc sha256sums.txt
|
|
||||||
gpg --verify b2sums.txt.asc b2sums.txt
|
|
||||||
|
|
||||||
# Check the PDF hashes match
|
|
||||||
sha256sum -c sha256sums.txt
|
|
||||||
b2sum -c b2sums.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
A successful verify looks like:
|
|
||||||
|
|
||||||
```
|
|
||||||
gpg: Signature made ...
|
|
||||||
gpg: Good signature from "Anonymous Planet (Release) ..."
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**`cairosvg` missing during MkDocs build**
|
|
||||||
Install the imaging extras: `pip install "mkdocs-material[imaging]"`. Required by the `social` plugin.
|
|
||||||
|
|
||||||
**`KeyError: 'JPEG'` in convert.py**
|
|
||||||
Pillow needs libjpeg. Reinstall after installing the system lib: `sudo apt install libjpeg-dev && pip install --force-reinstall pillow`.
|
|
||||||
|
|
||||||
**`qpdf: can't find PDF header`**
|
|
||||||
Ensure you are on the current version of `convert.py` — qpdf only accepts PDF inputs, not PNG.
|
|
||||||
|
|
||||||
**GPG signing fails on CI with `No secret key`**
|
|
||||||
The `GPG_PRIVATE_KEY` secret is missing or malformed. Re-export with `gpg --armor --export-secret-keys <fingerprint>` and paste the full block including header and footer lines.
|
|
||||||
|
|
||||||
**GPG signing fails with `Bad passphrase`**
|
|
||||||
The `GPG_PASSPHRASE` secret has a trailing space or newline. Paste it again with no surrounding whitespace.
|
|
||||||
|
|
||||||
**`release.yml` fails on VirusTotal**
|
|
||||||
The `VT_API_KEY` is missing, invalid, or over the rate limit (500 requests/day on the free tier). Check the secret and re-run after a few minutes.
|
|
||||||
|
|
||||||
**`sign.yml` fails downloading PDF artifact**
|
|
||||||
The `build_run_id` is wrong, or the artifact has expired (90-day retention). Trigger a new build and use the fresh run ID.
|
|
||||||
|
|
||||||
**Changelog already contains version X**
|
|
||||||
`update_changelog.py` will error if `MANUAL_VERSION` is set to a version already in the changelog. Choose the next version string.
|
|
||||||
|
|
||||||
**Footnote warnings from MkDocs (`link '#fnref:N' has no anchor`)**
|
|
||||||
A footnote definition `[^N]:` exists without a matching inline citation. Add the citation or remove the orphaned definition.
|
|
||||||
+17
-6
@@ -7,15 +7,15 @@ schema:
|
|||||||
"@id": https://www.anonymousplanet.org/
|
"@id": https://www.anonymousplanet.org/
|
||||||
name: Anonymous Planet
|
name: Anonymous Planet
|
||||||
url: https://www.anonymousplanet.org/guide/
|
url: https://www.anonymousplanet.org/guide/
|
||||||
logo: ../media/profile.png
|
logo: ../media/favicon.ico
|
||||||
sameAs:
|
sameAs:
|
||||||
- https://github.com/Anon-Planet
|
- https://github.com/Anon-Planet
|
||||||
- https://opencollective.com/anonymousplanetorg
|
- https://opencollective.com/anonymousplanetorg
|
||||||
---
|
---
|
||||||
<div class="pdf-title-page" aria-hidden="true">
|
<div class="pdf-title-page" aria-hidden="true">
|
||||||
<p class="pdf-title-page__title">The Hitchhiker's Guide to Online Anonymity</p>
|
<p class="pdf-title-page__title">The Hitchhiker's Guide to Online Anonymity</p>
|
||||||
<p class="pdf-title-page__subtitle"><em>(Or "How I learned to start worrying and love privacy and anonymity")</em></p>
|
<p class="pdf-title-page__subtitle"><em>(Or "How I learned to start worrying and love privacy anonymity")</em></p>
|
||||||
<p class="pdf-title-page__meta">v1.2.3, May 2026 by Anonymous Planet</p>
|
<p class="pdf-title-page__meta">Version 1.2.1, April 2026 by Anonymous Planet</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="guide-intro-lead" markdown="1">
|
<div class="guide-intro-lead" markdown="1">
|
||||||

|

|
||||||
@@ -8583,7 +8583,7 @@ It is recommended that you learn about the common ways people mess up OPSEC <htt
|
|||||||
|
|
||||||
- Contact a lawyer if possible and hope for the best and if you cannot contact one (yet), **try to remain silent (if your country allows it) until you have a lawyer to help you and if your law allows you to remain silent.**
|
- Contact a lawyer if possible and hope for the best and if you cannot contact one (yet), **try to remain silent (if your country allows it) until you have a lawyer to help you and if your law allows you to remain silent.**
|
||||||
|
|
||||||
Keep in mind that many countries have specific laws to compel you to reveal your passwords that could override your "right to remain silent". See this Wikipedia article: <https://en.wikipedia.org/wiki/Key_disclosure_law> <sup>[[Wikiless]](https://wikiless.com/wiki/Key_disclosure_law)</sup> <sup>[[Archive.org]](https://web.archive.org/web/https://en.wikipedia.org/wiki/Key_disclosure_law)</sup> and this other visual resource with law references <https://www.gp-digital.org/world-map-of-encryption/> <sup>[[Archive.org]](https://web.archive.org/web/https://www.gp-digital.org/world-map-of-encryption/)</sup>. Australia in particular has broad privacy laws[^536] and passed the Surveillance Legislation Amendment (Identify and Disrupt) Act 2021[^537], which grants authorities powers to modify, add, copy, and delete data on a suspect's devices and accounts.
|
Keep in mind that many countries have specific laws to compel you to reveal your passwords that could override your "right to remain silent". See this Wikipedia article: <https://en.wikipedia.org/wiki/Key_disclosure_law> <sup>[[Wikiless]](https://wikiless.com/wiki/Key_disclosure_law)</sup> <sup>[[Archive.org]](https://web.archive.org/web/https://en.wikipedia.org/wiki/Key_disclosure_law)</sup> and this other visual resource with law references <https://www.gp-digital.org/world-map-of-encryption/> <sup>[[Archive.org]](https://web.archive.org/web/https://www.gp-digital.org/world-map-of-encryption/)</sup>.
|
||||||
|
|
||||||
## A small final editorial note
|
## A small final editorial note
|
||||||
|
|
||||||
@@ -10043,17 +10043,28 @@ Again, regarding the PDFs of this guide and as explained in the README of my rep
|
|||||||
|
|
||||||
- Run "python pdfid.py file-to-check.pdf" and you should see these at 0 in the case of the PDF files in this repository:
|
- Run "python pdfid.py file-to-check.pdf" and you should see these at 0 in the case of the PDF files in this repository:
|
||||||
|
|
||||||
```text
|
```
|
||||||
|
|
||||||
/JS 0 #This indicates the presence of Javascript
|
/JS 0 #This indicates the presence of Javascript
|
||||||
|
|
||||||
/JavaScript 0 #This indicates the presence of Javascript
|
/JavaScript 0 #This indicates the presence of Javascript
|
||||||
|
|
||||||
/AA 0 #This indicates the presence of automatic action on opening
|
/AA 0 #This indicates the presence of automatic action on opening
|
||||||
|
|
||||||
/OpenAction 0 #This indicates the presence of automatic action on opening
|
/OpenAction 0 #This indicates the presence of automatic action on opening
|
||||||
|
|
||||||
/AcroForm 0 #This indicates the presence of AcroForm which could contain JavaScript
|
/AcroForm 0 #This indicates the presence of AcroForm which could contain JavaScript
|
||||||
|
|
||||||
/JBIG2Decode 0 #This indicates the use of JBIG2 compression which could be used for obfuscating content
|
/JBIG2Decode 0 #This indicates the use of JBIG2 compression which could be used for obfuscating content
|
||||||
|
|
||||||
/RichMedia 0 #This indicates the presence of rich media within the PDF such as Flash
|
/RichMedia 0 #This indicates the presence of rich media within the PDF such as Flash
|
||||||
|
|
||||||
/Launch 0 #This counts the launch actions
|
/Launch 0 #This counts the launch actions
|
||||||
|
|
||||||
/EmbeddedFile 0 #This indicates there are embedded files within the PDF
|
/EmbeddedFile 0 #This indicates there are embedded files within the PDF
|
||||||
|
|
||||||
/XFA 0 #This indicates the presence of XML Forms within the PDF
|
/XFA 0 #This indicates the presence of XML Forms within the PDF
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Now, what if you think the PDF is still suspicious? Fear not ... there are more things you can do to ensure it is not malicious:
|
Now, what if you think the PDF is still suspicious? Fear not ... there are more things you can do to ensure it is not malicious:
|
||||||
@@ -11333,7 +11344,7 @@ If you want to compare an older version of the PDF with a newer version, conside
|
|||||||
|
|
||||||
- <https://draftable.com/compare>
|
- <https://draftable.com/compare>
|
||||||
|
|
||||||
<!-- If you want to compare the older version of the ODT format with a newer version, use the LibreWriter compare features as explained here: <https://help.libreoffice.org/7.1/en-US/text/shared/guide/redlining_doccompare.html> <sup>[[Archive.org]](https://web.archive.org/web/https://help.libreoffice.org/7.1/en-US/text/shared/guide/redlining_doccompare.html)</sup> -->
|
If you want to compare the older version of the ODT format with a newer version, use the LibreWriter compare features as explained here: <https://help.libreoffice.org/7.1/en-US/text/shared/guide/redlining_doccompare.html> <sup>[[Archive.org]](https://web.archive.org/web/https://help.libreoffice.org/7.1/en-US/text/shared/guide/redlining_doccompare.html)</sup>
|
||||||
|
|
||||||
# Appendix A7: Crypto Swapping Services without Registration and KYC
|
# Appendix A7: Crypto Swapping Services without Registration and KYC
|
||||||
|
|
||||||
|
|||||||
+6
-10
@@ -7,23 +7,19 @@ schema:
|
|||||||
"@id": https://www.anonymousplanet.org/
|
"@id": https://www.anonymousplanet.org/
|
||||||
name: Anonymous Planet
|
name: Anonymous Planet
|
||||||
url: https://www.anonymousplanet.org/authors/
|
url: https://www.anonymousplanet.org/authors/
|
||||||
logo: ../media/profile.png
|
logo: ../media/favicon.png
|
||||||
sameAs:
|
sameAs:
|
||||||
- https://github.com/Anon-Planet
|
- https://github.com/Anon-Planet
|
||||||
- https://opencollective.com/anonymousplanetorg
|
- https://opencollective.com/anonymousplanetorg
|
||||||
- https://mastodon.social/@anonymousplanet
|
- https://mastodon.social/@anonymousplanet
|
||||||
---
|
---
|
||||||
|
|
||||||
# **Hello, and welcome to the Hitchhiker's Guide.**
|
|
||||||
|
|
||||||
**9FA5 436D 0EE3 6098 5157 3825 17EC A05F 768D EDF6**
|
|
||||||
|
|
||||||
You'll use it to [**verify the checksum** and **GPG signature** of all files for authenticity.](verify/index.md)
|
|
||||||
Please share this project if you enjoy it and you think it might be useful to others.
|
|
||||||
|
|
||||||
{ align=right }
|
{ align=right }
|
||||||
|
|
||||||
Anonymous Planet is a collective of volunteers.
|
**Welcome to the Hitchhiker's Guide.**
|
||||||
|
|
||||||
|
Please share this project if you enjoy it and you think it might be useful to others.
|
||||||
|
|
||||||
|
Anonymous Planet is a collective of volunteers and contributors. No one person is considered more valuable than another, and no one person should be viewed as having "more impact" on Anonymous Planet.
|
||||||
|
|
||||||
??? person "Das Kolburn"
|
??? person "Das Kolburn"
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ schema:
|
|||||||
"@id": https://www.anonymousplanet.org/
|
"@id": https://www.anonymousplanet.org/
|
||||||
name: Anonymous Planet
|
name: Anonymous Planet
|
||||||
url: https://www.anonymousplanet.org/mirrors/
|
url: https://www.anonymousplanet.org/mirrors/
|
||||||
logo: ../media/profile.png
|
logo: ../media/favicon.png
|
||||||
sameAs:
|
sameAs:
|
||||||
- https://github.com/Anon-Planet
|
- https://github.com/Anon-Planet
|
||||||
- https://opencollective.com/anonymousplanetorg
|
- https://opencollective.com/anonymousplanetorg
|
||||||
@@ -27,9 +27,9 @@ schema:
|
|||||||
|
|
||||||
!!! Note "PDF export (single file)"
|
!!! Note "PDF export (single file)"
|
||||||
|
|
||||||
The guide is also available as a **PDF** (images and layout preserved). It is built automatically in GitHub Actions: open [**Build guide PDF**](https://github.com/Anon-Planet/thgtoa/actions/workflows/build-sign-release.yml) on the [**source repository**](https://github.com/Anon-Planet/thgtoa), pick a successful run, and download the **`thgtoa`** and **`thgtoa-dark`** artifacts. You can start a fresh build anytime (**Actions** → **Build guide PDF** → **Run workflow**).
|
The guide is also available as a **PDF** (images and layout preserved). It is built automatically in GitHub Actions: open [**Build guide PDF**](https://github.com/Anon-Planet/thgtoa/actions/workflows/build-pdf.yml) on the [**thgtoa** source repository](https://github.com/Anon-Planet/thgtoa), pick a successful run, and download the **`guide-pdf`** artifact. You can start a fresh build anytime (**Actions** → **Build guide PDF** → **Run workflow**).
|
||||||
|
|
||||||
To produce the same file locally, clone the repository and run `python3 scripts/build_guide_pdf.py --both` (Python, [MkDocs Material](https://squidfunk.github.io/mkdocs-material/getting-started/), and **Google Chrome** or **Microsoft Edge** required). More detail is in the [repository README](https://github.com/Anon-Planet/thgtoa#ways-to-read-or-export-the-guide).
|
To produce the same file locally, clone the repository and run `python scripts/build_guide_pdf.py` (Python, [MkDocs Material](https://squidfunk.github.io/mkdocs-material/getting-started/), and **Google Chrome** or **Microsoft Edge** required). More detail is in the [repository README](https://github.com/Anon-Planet/thgtoa#ways-to-read-or-export-the-guide).
|
||||||
|
|
||||||
!!! Note "Our official git mirrors"
|
!!! Note "Our official git mirrors"
|
||||||
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
/* Generate dark mode PDF of the HTML at guide/index.html */
|
|
||||||
|
|
||||||
/*
|
|
||||||
DARK_MODE_PDF.CSS
|
|
||||||
Use this stylesheet when generating a PDF from HTML.
|
|
||||||
*/
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* Color Palette */
|
|
||||||
--bg-color: #121212; /* Deep dark grey (easier on eyes than pure black) */
|
|
||||||
--text-primary: #e0e0e0; /* Off-white for readability */
|
|
||||||
--text-secondary: #a0a0a0; /* Grey for captions/metadata */
|
|
||||||
--accent-color: #bb86fc; /* Light purple accent (optional) */
|
|
||||||
--border-color: #333333; /* Subtle borders */
|
|
||||||
|
|
||||||
/* Fonts - System fonts ensure best rendering across PDF engines */
|
|
||||||
--font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- RESET & BASE STYLES --- */
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-main);
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- TYPOGRAPHY & HEADINGS --- */
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-top: 1.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--text-secondary); /* Slightly dimmer text for body copy */
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--accent-color);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- CONTAINER & LAYOUT --- */
|
|
||||||
.container {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 40px auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cards / Sections with dark backgrounds */
|
|
||||||
.card {
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- TABLES (Common in PDFs) --- */
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 20px 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background-color: #2c2c2c;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- IMAGES --- */
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
margin: 20px 0;
|
|
||||||
/* This ensures high contrast images don't get washed out */
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- CRITICAL FOR PDF GENERATORS --- */
|
|
||||||
/* Forces the browser/PDF engine to print background colors and graphics */
|
|
||||||
@media print {
|
|
||||||
@page {
|
|
||||||
size: A4; /* Change to 'Letter' if preferred */
|
|
||||||
margin: 20mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--bg-color) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card, table th {
|
|
||||||
-webkit-print-color-adjust: exact !important; /* Chrome/Safari */
|
|
||||||
print-color-adjust: exact !important; /* Firefox/Standard */
|
|
||||||
background-color: #1e1e1e !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent page breaks in the middle of a sentence or card if possible */
|
|
||||||
.card {
|
|
||||||
break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide elements you don't want in PDF (like navigation bars) */
|
|
||||||
nav, footer, button {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Verify"
|
|
||||||
description: How to verify the authenticity of our files and check virus scans
|
|
||||||
---
|
|
||||||
|
|
||||||
# PDF Verification Guide
|
|
||||||
|
|
||||||
## Files Provided
|
|
||||||
|
|
||||||
For each PDF release, you'll receive:
|
|
||||||
|
|
||||||
- **PDF file** (`thgtoa.pdf` or `thgtoa-dark.pdf`) - The actual document
|
|
||||||
- **Signature file** (`.sig`) - GPG detached signature for authenticity verification
|
|
||||||
- **Hash file** (`.sha256`) - SHA256 checksum for integrity verification
|
|
||||||
|
|
||||||
## Quick Verification
|
|
||||||
|
|
||||||
### Using Python Script (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verify everything (hashes, signatures, and optionally VirusTotal)
|
|
||||||
python scripts/verify_pdf.py --all
|
|
||||||
|
|
||||||
# Only verify hashes
|
|
||||||
python scripts/verify_pdf.py --hashes
|
|
||||||
|
|
||||||
# Only verify GPG signatures
|
|
||||||
python scripts/verify_pdf.py --signatures
|
|
||||||
|
|
||||||
# Check VirusTotal scan status (requires VT_API_KEY environment variable)
|
|
||||||
python scripts/verify_pdf.py --vt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Verification
|
|
||||||
|
|
||||||
#### 1. Verify SHA256 Hash
|
|
||||||
|
|
||||||
**Linux/macOS:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/repo
|
|
||||||
sha256sum -c sha256sum-light.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows (PowerShell):**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Get-FileHash -Algorithm SHA256 export\thgtoa.pdf | Select-Object Hash
|
|
||||||
# Compare with the hash in thgtoa.pdf.sha256
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Verify GPG Signature
|
|
||||||
|
|
||||||
First, import the public key:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gpg --import pgp/anonymousplanet-master.asc
|
|
||||||
```
|
|
||||||
|
|
||||||
Then verify the signature:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gpg --verify export/thgtoa.pdf.sig export/thgtoa.pdf
|
|
||||||
gpg --verify export/thgtoa-dark.pdf.sig export/thgtoa-dark.pdf
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output for successful verification:
|
|
||||||
|
|
||||||
```text
|
|
||||||
gpg: Signature made Mon 20 Apr 2026 01:46:40 AM EDT
|
|
||||||
gpg: using EDDSA key 9FA5436D0EE360985157382517ECA05F768DEDF6
|
|
||||||
gpg: Good signature from "Anonymous Planet Master Signing Key" [unknown]
|
|
||||||
gpg: WARNING: This key is not certified with a trusted signature!
|
|
||||||
gpg: There is no indication that the signature belongs to the owner.
|
|
||||||
Primary key fingerprint: 9FA5 436D 0EE3 6098 5157 3825 17EC A05F 768D EDF6
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Check VirusTotal Status
|
|
||||||
|
|
||||||
Visit the VirusTotal report links (automatically generated in release notes):
|
|
||||||
- Light mode: `https://www.virustotal.com/gui/file/[hash]`
|
|
||||||
- Dark mode: `https://www.virustotal.com/gui/file/[hash]`
|
|
||||||
|
|
||||||
Or use the Python script with API key:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export VT_API_KEY=your_vt_api_key
|
|
||||||
python scripts/verify_pdf.py --vt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Automated Verification in CI/CD
|
|
||||||
|
|
||||||
The GitHub Actions workflows automatically:
|
|
||||||
|
|
||||||
1. **Build PDFs** from MkDocs source
|
|
||||||
2. **Generate SHA256 hashes** and save to root directory
|
|
||||||
3. **Sign files with GPG** using the repository's private key
|
|
||||||
4. **Scan with VirusTotal** and update release notes
|
|
||||||
5. **Create releases** with all verification artifacts
|
|
||||||
|
|
||||||
## Security Best Practices
|
|
||||||
|
|
||||||
1. **Always verify signatures** before opening PDFs from untrusted sources
|
|
||||||
2. **Check hashes** to ensure files weren't corrupted during download
|
|
||||||
3. **Review VirusTotal results** for any suspicious detections
|
|
||||||
4. **Import keys securely** - verify key fingerprints with the project maintainers
|
|
||||||
5. **Keep verification scripts updated** to match current security standards
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Good signature" but wrong owner?
|
|
||||||
|
|
||||||
- Ensure you imported the correct public key
|
|
||||||
- Check the key fingerprint matches the official one from the repository
|
|
||||||
|
|
||||||
### Hash mismatch?
|
|
||||||
|
|
||||||
- Re-download the file (corruption during transfer)
|
|
||||||
- Verify you're checking against the correct hash file
|
|
||||||
- Check for disk errors on your system
|
|
||||||
|
|
||||||
### GPG not found?
|
|
||||||
|
|
||||||
- Install GPG: `sudo apt install gnupg` (Debian/Ubuntu) or `brew install gnupg` (macOS)
|
|
||||||
- On Windows, use [Gpg4win](https://www.gpg4win.org/)
|
|
||||||
|
|
||||||
## Key Information
|
|
||||||
|
|
||||||
**Signing Key:** Anonymous Planet Master Signing Key ("MSK")
|
|
||||||
**Key ID:** See `pgp/anonymousplanet-master.asc` for details
|
|
||||||
**Fingerprint:** Verify from the repository's official documentation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_For questions or issues with verification, please open an issue on GitHub._
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
7ed217a5e21353a79e0cea393f20d1c3d12549a9a3ed8b968cbbb7ca487f0db5d381af06fdbf09bed8f15066074a841fd79106bdb810e447cd123016e129da76 thgtoa.pdf
|
|
||||||
c84086635084be469074df60b5083e4e72cbabaa2687ef55d5bdd9876efc202a56b70d07e8ac177a6456cdb8bb248cd52550f9f32ba0a263be4aefdca841df95 thgtoa-dark.pdf
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
f1fdb5ae16f197747f648ecf539906361eb7110d118cb9dae69b3ca0c1927313 thgtoa.pdf
|
|
||||||
17e06922819a8805e42ad0a1cdec9e15ab7fcac0ee49aac6ce77aa2ca5102f59 thgtoa-dark.pdf
|
|
||||||
-66289
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
|
||||||
|
|
||||||
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
|
|
||||||
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7fY6QAD/YCGJqs9HiRllFrF9EluE
|
|
||||||
Ga4XUEQ/R6Q2zc+X6lX856sBAJIpxeMxUmMUXyr3xBAHxUf5eV+nQYkQQMKI81L1
|
|
||||||
x8gL
|
|
||||||
=VX6l
|
|
||||||
-----END PGP SIGNATURE-----
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
f212d0425b38d5cd10da6dc804b60f143da23d4b07051aae31d0966082519b300af0e1c423683e0223738b33b138c687232b1c8bd68cf643777bbc5b588152bd ./export/thgtoa-dark.pdf
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
|
||||||
|
|
||||||
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
|
|
||||||
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7fbdDgEAoSslLR47ydW/3r1wJOPY
|
|
||||||
X/waLkVbkGZpHqwd4RjywwcA/3B7Ci+jUg+yP5TRsuChagEhwyO5vw2DxSlUGoB4
|
|
||||||
+ksH
|
|
||||||
=2ja9
|
|
||||||
-----END PGP SIGNATURE-----
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
|
||||||
|
|
||||||
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
|
|
||||||
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7faErgD/Svj1G+B7gmrZQ6AsLZ5J
|
|
||||||
HfeldxjmrXE99dig1iHtl5IBAMndZZb+95TO03IZ9eLGfYuyTz4GCUanmftsY9yv
|
|
||||||
LAIN
|
|
||||||
=MEd0
|
|
||||||
-----END PGP SIGNATURE-----
|
|
||||||
Binary file not shown.
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
|
||||||
|
|
||||||
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
|
|
||||||
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7favvgEAvFFSB5NrsrKMYvGG5ZYB
|
|
||||||
iLIyt8Sn1rZmlVkibssMPq0BAImpZe8S7hWNkbukyEC4sLbKiOYvjbVipQHnrIUV
|
|
||||||
xPMH
|
|
||||||
=0hnj
|
|
||||||
-----END PGP SIGNATURE-----
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
436ed0df78c299f95b8d5ff94f43f26ec2e7825d92d843fc15419630d55ed5e0c98485e738c12715a2b6242633faae38e8a98935b361d44ddde97a1692cb01a1 ./export/thgtoa.pdf
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
|
||||||
|
|
||||||
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
|
|
||||||
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7fatsgEAixDzH+zTnKYMEx3sikWp
|
|
||||||
dsNTiHTU6wJY/brVJIU879UBAJntBIq72vqwKtMb/ZlVvomdDvKVllZw8ZsYBz1n
|
|
||||||
aTkM
|
|
||||||
=vkgy
|
|
||||||
-----END PGP SIGNATURE-----
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
|
||||||
|
|
||||||
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
|
|
||||||
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7faAGQEAyEhVKrRoXIsV3E5f1FZg
|
|
||||||
8fcsmbxCnKBqxichCkf0dWYBAIvbI146mQLHaNqLDaTIqCUQbkq1aE/YMFDGykUG
|
|
||||||
ngsJ
|
|
||||||
=/0RY
|
|
||||||
-----END PGP SIGNATURE-----
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
## VirusTotal Scan Results
|
|
||||||
|
|
||||||
**Scan Date:** 2026-04-19 01:48 UTC
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### thgtoa.pdf
|
|
||||||
- **SHA256 Hash:** `f82f6f53319315568fc2524b4eaf01126fe52356a20363cd358ad5977388ba28`
|
|
||||||
- **VirusTotal Report:** VT_API_KEY not configured, scan skipped
|
|
||||||
|
|
||||||
### thgtoa-dark.pdf
|
|
||||||
- **SHA256 Hash:** `94a0c8e3b81b0aeeb921029a41713d81b836da893a9bc9f905ca7296e82bd70f`
|
|
||||||
- **VirusTotal Report:** VT_API_KEY not configured, scan skipped
|
|
||||||
|
|
||||||
---
|
|
||||||
*Scan performed automatically by GitHub Actions*
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
|
||||||
|
|
||||||
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
|
|
||||||
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7fYpCgEA209U3QewChp7mdrrFjH1
|
|
||||||
CaBMIk2sCHwRMCcmbMDkNTAA/RIchAKex13ZjZWC9xsJpZEktvBENFsQLsNPReqR
|
|
||||||
UZ8C
|
|
||||||
=TYsa
|
|
||||||
-----END PGP SIGNATURE-----
|
|
||||||
+1
-19
@@ -9,7 +9,6 @@ repo_name: ""
|
|||||||
#edit_uri: ""
|
#edit_uri: ""
|
||||||
theme:
|
theme:
|
||||||
name: material
|
name: material
|
||||||
locale: en
|
|
||||||
favicon: media/profile.png
|
favicon: media/profile.png
|
||||||
icon:
|
icon:
|
||||||
logo: material/bird
|
logo: material/bird
|
||||||
@@ -18,8 +17,6 @@ theme:
|
|||||||
text: Public Sans
|
text: Public Sans
|
||||||
code: Liberation Mono
|
code: Liberation Mono
|
||||||
features:
|
features:
|
||||||
- navigation.instant
|
|
||||||
- navigation.instant.prefetch
|
|
||||||
- navigation.tabs
|
- navigation.tabs
|
||||||
- navigation.sections
|
- navigation.sections
|
||||||
- toc.integrate
|
- toc.integrate
|
||||||
@@ -124,20 +121,5 @@ markdown_extensions:
|
|||||||
permalink: true
|
permalink: true
|
||||||
toc_depth: 3
|
toc_depth: 3
|
||||||
|
|
||||||
nav:
|
|
||||||
- Welcome: index.md
|
|
||||||
- About: about/index.md
|
|
||||||
- Verify: verify/index.md
|
|
||||||
- Guide:
|
|
||||||
- guide/index.md
|
|
||||||
- Code:
|
|
||||||
- code/index.md
|
|
||||||
- Develop: code/develop.md
|
|
||||||
- Contribute: contribute/index.md
|
|
||||||
- Constitution: constitution/index.md
|
|
||||||
- Mirrors: mirrors/index.md
|
|
||||||
- Twitter: twitter/index.md
|
|
||||||
- Releases: changelog/index.md
|
|
||||||
|
|
||||||
copyright: |
|
copyright: |
|
||||||
<a href="https://anonymousplanet.org/">The Hitchhiker's Guide</a> ©2023-2026 by <a href="https://psa.anonymousplanet.org/">Anonymous Planet</a> is licensed under <a href="https://creativecommons.org/licenses/by-nc/4.0/">CC BY-NC 4.0</a><img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" style="max-width: 1em;max-height:1em;margin-left: .2em;"><img src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" style="max-width: 1em;max-height:1em;margin-left: .2em;"><img src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt="" style="max-width: 1em;max-height:1em;margin-left: .2em;"></a>
|
© 2023-2025 <a href="https://anonymousplanet.org/" target="_blank" rel="noopener">Anonymous Planet</a>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# scripts/archived
|
|
||||||
|
|
||||||
Scripts kept for reference but no longer part of the active pipeline.
|
|
||||||
|
|
||||||
| Script | Why archived |
|
|
||||||
|--------|-------------|
|
|
||||||
| `tag_release.py` | Created GPG-signed `vX.Y.Z` annotated tags. Superseded by the `release-YYYYMMDD-<sha>` timestamp tagging built into `release.yml`. Re-enable if semver release tagging is reintroduced. |
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Release tagging helper for Anonymous Planet maintainers.
|
|
||||||
|
|
||||||
Creates a GPG-signed annotated git tag using the release key, with a
|
|
||||||
message derived from the changelog entry for that version.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/tag_release.py # auto-detect next version
|
|
||||||
python scripts/tag_release.py --version v1.2.4
|
|
||||||
python scripts/tag_release.py --version v1.2.4 --key <fingerprint>
|
|
||||||
python scripts/tag_release.py --version v1.2.4 --dry-run
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
1. Checks the working tree is clean and the branch is main
|
|
||||||
2. Resolves the version (auto-increments patch if not given)
|
|
||||||
3. Finds the matching ## [vX.Y.Z] entry in the changelog
|
|
||||||
4. Lists available signing keys and selects the release key
|
|
||||||
5. Creates a signed annotated tag: git tag -s vX.Y.Z -u <key> -m <message>
|
|
||||||
6. Verifies the tag signature
|
|
||||||
7. Prints the push command
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- git
|
|
||||||
- GPG with the release signing key imported
|
|
||||||
"""
|
|
||||||
|
|
||||||
# The Hitchhiker's Guide to Online Anonymity © 2026 by Anonymous Planet is licensed under Creative
|
|
||||||
# Commons Attribution-NonCommercial 4.0 International
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# Default release signing key fingerprint.
|
|
||||||
# Maintainers with a different key can pass --key on the CLI.
|
|
||||||
DEFAULT_SIGNING_KEY = "C3023DBEA3FB38C438BA1EEDCEC60AEDE8B992A2"
|
|
||||||
|
|
||||||
CHANGELOG = Path(__file__).resolve().parent.parent / "docs" / "changelog" / "index.md"
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Helpers
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def repo_root() -> Path:
|
|
||||||
return Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
|
||||||
return subprocess.run(cmd, capture_output=True, text=True, check=check)
|
|
||||||
|
|
||||||
|
|
||||||
def git(*args: str, check: bool = True) -> str:
|
|
||||||
return run(["git", *args], check=check).stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def check_gpg_installed() -> bool:
|
|
||||||
try:
|
|
||||||
return run(["gpg", "--version"], check=False).returncode == 0
|
|
||||||
except FileNotFoundError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Git state checks
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def check_clean_tree() -> bool:
|
|
||||||
"""Return True if the working tree and index are clean."""
|
|
||||||
status = git("status", "--porcelain")
|
|
||||||
return status == ""
|
|
||||||
|
|
||||||
|
|
||||||
def current_branch() -> str:
|
|
||||||
return git("rev-parse", "--abbrev-ref", "HEAD")
|
|
||||||
|
|
||||||
|
|
||||||
def latest_tag() -> str | None:
|
|
||||||
"""Return the most recent vX.Y.Z tag, or None."""
|
|
||||||
out = run(["git", "tag", "--sort=-version:refname", "--list", "v*"], check=False).stdout
|
|
||||||
for line in out.splitlines():
|
|
||||||
if re.match(r"^v\d+\.\d+\.\d+", line.strip()):
|
|
||||||
return line.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def tag_exists(version: str) -> bool:
|
|
||||||
out = run(["git", "tag", "--list", version], check=False).stdout.strip()
|
|
||||||
return bool(out)
|
|
||||||
|
|
||||||
|
|
||||||
def head_sha() -> str:
|
|
||||||
return git("rev-parse", "HEAD")
|
|
||||||
|
|
||||||
|
|
||||||
def short_sha() -> str:
|
|
||||||
return git("rev-parse", "--short", "HEAD")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Version logic
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def auto_increment(tag: str | None) -> str:
|
|
||||||
"""Bump the patch number of tag, or return v1.0.0 if no tag exists."""
|
|
||||||
if not tag:
|
|
||||||
return "v1.0.0"
|
|
||||||
m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", tag)
|
|
||||||
if not m:
|
|
||||||
return "v1.0.0"
|
|
||||||
return f"v{m.group(1)}.{m.group(2)}.{int(m.group(3)) + 1}"
|
|
||||||
|
|
||||||
|
|
||||||
def normalise_version(v: str) -> str:
|
|
||||||
return v if v.startswith("v") else f"v{v}"
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Changelog parsing
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def extract_changelog_entry(version: str) -> str | None:
|
|
||||||
"""Return the raw text of the ## [version] section, or None if not found."""
|
|
||||||
if not CHANGELOG.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
content = CHANGELOG.read_text(encoding="utf-8")
|
|
||||||
# Find the heading for this version
|
|
||||||
pattern = re.compile(
|
|
||||||
rf"^(## \[{re.escape(version)}\].*?)(?=^## \[|\Z)",
|
|
||||||
re.MULTILINE | re.DOTALL,
|
|
||||||
)
|
|
||||||
m = pattern.search(content)
|
|
||||||
if not m:
|
|
||||||
return None
|
|
||||||
return m.group(1).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def changelog_to_tag_message(version: str, entry: str | None, sha: str) -> str:
|
|
||||||
"""Convert a changelog entry into a clean tag message."""
|
|
||||||
if not entry:
|
|
||||||
return f"{version}\n\nNo changelog entry found for {version}.\nCommit: {sha}"
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
current_bucket = None
|
|
||||||
|
|
||||||
for line in entry.splitlines():
|
|
||||||
# Skip the ## [vX.Y.Z] heading — we'll add our own first line
|
|
||||||
if re.match(r"^## \[", line):
|
|
||||||
continue
|
|
||||||
# Skip admonition headers like !!! Note "Added" → use bucket name as section
|
|
||||||
m = re.match(r'^!!!\s+\w+\s+"(.+)"', line)
|
|
||||||
if m:
|
|
||||||
current_bucket = m.group(1)
|
|
||||||
lines.append(f"\n{current_bucket}:")
|
|
||||||
continue
|
|
||||||
# Skip Meta bucket entirely
|
|
||||||
if current_bucket == "Meta":
|
|
||||||
continue
|
|
||||||
# Strip 4-space admonition indent and leading bullet dash
|
|
||||||
stripped = re.sub(r"^ {4}", "", line)
|
|
||||||
stripped = re.sub(r"^- ", " - ", stripped)
|
|
||||||
# Skip blank lines inside admonitions (keep structure clean)
|
|
||||||
if stripped.strip():
|
|
||||||
lines.append(stripped)
|
|
||||||
|
|
||||||
body = "\n".join(lines).strip()
|
|
||||||
return f"{version}\n\n{body}\n\nCommit: {sha}"
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# GPG key selection
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def list_secret_keys() -> list[dict]:
|
|
||||||
"""Return a list of available secret keys with fingerprint and uid."""
|
|
||||||
result = run(["gpg", "--list-secret-keys", "--with-colons"], check=False)
|
|
||||||
keys = []
|
|
||||||
current: dict = {}
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
parts = line.split(":")
|
|
||||||
if parts[0] == "sec":
|
|
||||||
if current:
|
|
||||||
keys.append(current)
|
|
||||||
current = {"fingerprint": None, "uid": None, "key_id": parts[4] if len(parts) > 4 else ""}
|
|
||||||
elif parts[0] == "fpr" and current is not None:
|
|
||||||
if not current.get("fingerprint"):
|
|
||||||
current["fingerprint"] = parts[9] if len(parts) > 9 else ""
|
|
||||||
elif parts[0] == "uid" and current is not None:
|
|
||||||
if not current.get("uid"):
|
|
||||||
current["uid"] = parts[9] if len(parts) > 9 else ""
|
|
||||||
if current:
|
|
||||||
keys.append(current)
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_signing_key(preferred: str) -> str | None:
|
|
||||||
"""
|
|
||||||
Return the fingerprint to use for signing.
|
|
||||||
Prefers the key matching `preferred` (full fingerprint or key ID suffix).
|
|
||||||
Falls back to interactive selection if not found.
|
|
||||||
"""
|
|
||||||
keys = list_secret_keys()
|
|
||||||
if not keys:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Try to match preferred fingerprint/key ID
|
|
||||||
preferred_upper = preferred.upper()
|
|
||||||
for k in keys:
|
|
||||||
fp = (k.get("fingerprint") or "").upper()
|
|
||||||
kid = (k.get("key_id") or "").upper()
|
|
||||||
if preferred_upper in (fp, kid) or fp.endswith(preferred_upper) or kid.endswith(preferred_upper):
|
|
||||||
return k["fingerprint"]
|
|
||||||
|
|
||||||
# Not found — show what's available and ask
|
|
||||||
print("\n⚠ Preferred key not found in keyring. Available keys:\n")
|
|
||||||
for i, k in enumerate(keys, 1):
|
|
||||||
print(f" {i}. {k.get('fingerprint', 'N/A')}")
|
|
||||||
print(f" {k.get('uid', 'Unknown')}\n")
|
|
||||||
|
|
||||||
try:
|
|
||||||
choice = input(f"Select key (1–{len(keys)}) or press Enter to abort: ").strip()
|
|
||||||
if not choice:
|
|
||||||
return None
|
|
||||||
idx = int(choice) - 1
|
|
||||||
if 0 <= idx < len(keys):
|
|
||||||
return keys[idx]["fingerprint"]
|
|
||||||
except (ValueError, EOFError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Tag creation and verification
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def create_signed_tag(version: str, key_fingerprint: str, message: str, dry_run: bool) -> bool:
|
|
||||||
"""Create a GPG-signed annotated tag. Returns True on success."""
|
|
||||||
sha = head_sha()
|
|
||||||
cmd = [
|
|
||||||
"git", "tag",
|
|
||||||
"-s", version,
|
|
||||||
sha,
|
|
||||||
"-u", key_fingerprint,
|
|
||||||
"-m", message,
|
|
||||||
]
|
|
||||||
|
|
||||||
print(f"\n{'─' * 70}")
|
|
||||||
print(f" Tag: {version}")
|
|
||||||
print(f" Commit: {sha[:12]}")
|
|
||||||
print(f" Key: {key_fingerprint}")
|
|
||||||
print(f"{'─' * 70}")
|
|
||||||
print("\nTag message:\n")
|
|
||||||
for line in message.splitlines():
|
|
||||||
print(f" {line}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print("── DRY RUN — tag not created. Remove --dry-run to proceed.\n")
|
|
||||||
return True
|
|
||||||
|
|
||||||
confirm = input("Create this tag? [y/N] ").strip().lower()
|
|
||||||
if confirm != "y":
|
|
||||||
print("Aborted.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
result = run(cmd, check=False)
|
|
||||||
if result.returncode != 0:
|
|
||||||
print(f"\n✗ Tag creation failed:\n{result.stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"\n✓ Tag {version} created.")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def verify_tag(version: str) -> bool:
|
|
||||||
"""Verify the GPG signature on the tag."""
|
|
||||||
result = run(["git", "tag", "-v", version], check=False)
|
|
||||||
output = result.stdout + result.stderr
|
|
||||||
if result.returncode == 0:
|
|
||||||
print("\n✓ Tag signature verified:")
|
|
||||||
for line in output.splitlines():
|
|
||||||
if any(k in line for k in ("gpg:", "Primary", "using", "issuer", "Good")):
|
|
||||||
print(f" {line.strip()}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"\n✗ Tag signature verification failed:\n{output}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Main
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
description="Create a GPG-signed release tag from the changelog."
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--version", "-v",
|
|
||||||
help="Version to tag, e.g. v1.2.4 (default: auto-increment from latest tag)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--key", "-k",
|
|
||||||
default=DEFAULT_SIGNING_KEY,
|
|
||||||
help=f"GPG key fingerprint to sign with (default: {DEFAULT_SIGNING_KEY})",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--dry-run", "-n",
|
|
||||||
action="store_true",
|
|
||||||
help="Print the tag message and exit without creating the tag",
|
|
||||||
)
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
root = repo_root()
|
|
||||||
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print(" RELEASE TAGGER — Anonymous Planet")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
# 1. GPG available?
|
|
||||||
if not check_gpg_installed():
|
|
||||||
print("\n✗ GPG is not installed or not in PATH.")
|
|
||||||
print(" Install with: sudo apt install gnupg | brew install gnupg")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# 2. Working tree clean?
|
|
||||||
if not check_clean_tree():
|
|
||||||
print("\n✗ Working tree is not clean. Commit or stash changes first.")
|
|
||||||
result = run(["git", "status", "--short"], check=False)
|
|
||||||
print(result.stdout)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# 3. On main?
|
|
||||||
branch = current_branch()
|
|
||||||
if branch != "main":
|
|
||||||
print(f"\n⚠ You are on branch '{branch}', not 'main'.")
|
|
||||||
confirm = input(" Tag anyway? [y/N] ").strip().lower()
|
|
||||||
if confirm != "y":
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# 4. Resolve version
|
|
||||||
last_tag = latest_tag()
|
|
||||||
version = normalise_version(args.version) if args.version else auto_increment(last_tag)
|
|
||||||
print(f"\n Latest tag: {last_tag or '(none)'}")
|
|
||||||
print(f" New version: {version}")
|
|
||||||
print(f" Branch: {branch}")
|
|
||||||
print(f" HEAD: {short_sha()}")
|
|
||||||
|
|
||||||
if tag_exists(version) and not args.dry_run:
|
|
||||||
print(f"\n✗ Tag {version} already exists.")
|
|
||||||
print(" Use --version to specify a different version, or delete the tag first:")
|
|
||||||
print(f" git tag -d {version}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# 5. Build tag message from changelog
|
|
||||||
entry = extract_changelog_entry(version)
|
|
||||||
if not entry:
|
|
||||||
print(f"\n⚠ No changelog entry found for {version}.")
|
|
||||||
print(f" Add a '## [{version}]' section to {CHANGELOG.relative_to(root)}")
|
|
||||||
print(" and re-run, or proceed with a minimal message.")
|
|
||||||
confirm = input(" Proceed with minimal message? [y/N] ").strip().lower()
|
|
||||||
if confirm != "y":
|
|
||||||
return 1
|
|
||||||
|
|
||||||
message = changelog_to_tag_message(version, entry, head_sha())
|
|
||||||
|
|
||||||
# 6. Resolve signing key
|
|
||||||
print(f"\n Signing key: {args.key}")
|
|
||||||
key = resolve_signing_key(args.key)
|
|
||||||
if not key:
|
|
||||||
print("\n✗ Could not resolve a signing key. Aborting.")
|
|
||||||
print(" Import the release key with:")
|
|
||||||
print(" gpg --import pgp/anonymousplanet-release.asc")
|
|
||||||
return 1
|
|
||||||
print(f" Resolved to: {key}")
|
|
||||||
|
|
||||||
# 7. Create tag
|
|
||||||
if not create_signed_tag(version, key, message, args.dry_run):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# 8. Verify
|
|
||||||
if not verify_tag(version):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# 9. Push instructions
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print(" ✓ All done. Push the tag with:")
|
|
||||||
print(f"\n git push origin {version}\n")
|
|
||||||
print(" The release.yml workflow can then be triggered manually from")
|
|
||||||
print(" GitHub Actions to publish the GitHub Release for this tag.")
|
|
||||||
print("=" * 70 + "\n")
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
+39
-95
@@ -1,14 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Build light-mode PDF with MkDocs + Chromium, then produce dark-mode PDF via convert.py.
|
"""Build the MkDocs site, then render docs/guide/ to a single PDF via a Chromium-based browser.
|
||||||
|
|
||||||
Usage:
|
Uses headless Chrome/Edge print-to-PDF (embeds images). WeasyPrint-based mkdocs-with-pdf is
|
||||||
python scripts/build_guide_pdf.py # Light PDF only
|
omitted here because it needs GTK/Pango (awkward on Windows).
|
||||||
python scripts/build_guide_pdf.py --dark # Dark PDF only (requires light PDF to exist)
|
|
||||||
python scripts/build_guide_pdf.py --both # Light PDF, then dark PDF
|
|
||||||
|
|
||||||
Examples:
|
Usage (from repo root):
|
||||||
python scripts/build_guide_pdf.py --site-dir build/html --pdf-light export/thgtoa.pdf
|
python scripts/build_guide_pdf.py
|
||||||
python scripts/build_guide_pdf.py --both --pdf-light export/thgtoa.pdf --pdf-dark export/thgtoa-dark.pdf
|
python scripts/build_guide_pdf.py --site-dir build/html --pdf export/guide.pdf
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -67,19 +65,18 @@ def run_mkdocs(site_dir: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path:
|
def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path:
|
||||||
"""Render html_file to pdf_out via headless Chromium.
|
"""Write PDF to ``pdf_out``. Uses a temp file first so an open ``guide.pdf`` on Windows
|
||||||
|
does not block the build: if the final path is locked, writes ``guide-new.pdf`` instead.
|
||||||
Uses a temp file first so an open guide.pdf on Windows does not block the
|
|
||||||
build; if the final path is still locked, writes guide-new.pdf instead.
|
|
||||||
"""
|
"""
|
||||||
pdf_out.parent.mkdir(parents=True, exist_ok=True)
|
pdf_out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
partial = pdf_out.parent / f".{pdf_out.name}.writing"
|
partial = pdf_out.parent / f".{pdf_out.name}.writing"
|
||||||
partial.unlink(missing_ok=True)
|
partial.unlink(missing_ok=True)
|
||||||
|
|
||||||
uri = html_file.resolve().as_uri()
|
uri = html_file.resolve().as_uri()
|
||||||
|
# Chromium headless print; allow time for fonts/images on very large pages.
|
||||||
cmd = [str(browser)]
|
cmd = [str(browser)]
|
||||||
if os.environ.get("CI"):
|
if os.environ.get("CI"):
|
||||||
|
# GitHub Actions / other CI runners often need these for Chromium to start.
|
||||||
cmd += [
|
cmd += [
|
||||||
"--no-sandbox",
|
"--no-sandbox",
|
||||||
"--disable-setuid-sandbox",
|
"--disable-setuid-sandbox",
|
||||||
@@ -92,9 +89,7 @@ def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path:
|
|||||||
f"--print-to-pdf={partial.resolve()}",
|
f"--print-to-pdf={partial.resolve()}",
|
||||||
uri,
|
uri,
|
||||||
]
|
]
|
||||||
|
|
||||||
subprocess.run(cmd, check=True, timeout=600)
|
subprocess.run(cmd, check=True, timeout=600)
|
||||||
|
|
||||||
deadline = time.time() + 120
|
deadline = time.time() + 120
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
if partial.exists() and partial.stat().st_size > 0:
|
if partial.exists() and partial.stat().st_size > 0:
|
||||||
@@ -116,100 +111,49 @@ def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path:
|
|||||||
partial.replace(pdf_out)
|
partial.replace(pdf_out)
|
||||||
return pdf_out
|
return pdf_out
|
||||||
|
|
||||||
# Use scripts/convert.py in place of broken dark-mode hack
|
|
||||||
def build_dark_pdf(light_pdf: Path, dark_pdf: Path) -> Path:
|
|
||||||
"""Convert the light PDF to dark mode using scripts/convert.py."""
|
|
||||||
convert_script = repo_root() / "scripts" / "convert.py"
|
|
||||||
print(f"Converting {light_pdf.name} → {dark_pdf.name} (dark mode)…")
|
|
||||||
subprocess.run(
|
|
||||||
[sys.executable, str(convert_script), str(light_pdf), str(dark_pdf)],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
return dark_pdf
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
root = repo_root()
|
root = repo_root()
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(description="Build MkDocs + single-page guide PDF.")
|
||||||
description="Build MkDocs + single-page guide PDF (light and/or dark mode)."
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--site-dir",
|
"--site-dir",
|
||||||
type=Path,
|
type=Path,
|
||||||
default=root / "build" / "html",
|
default=root / "site",
|
||||||
help="MkDocs output directory (default: ./build/html)",
|
help="MkDocs output directory (default: ./site)",
|
||||||
)
|
)
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--pdf-light",
|
"--pdf",
|
||||||
type=Path,
|
type=Path,
|
||||||
default=root / "export" / "thgtoa.pdf",
|
default=root / "export" / "guide.pdf",
|
||||||
help="Output path for light PDF (default: ./export/thgtoa.pdf)",
|
help="Output PDF path (default: ./export/guide.pdf)",
|
||||||
)
|
)
|
||||||
ap.add_argument(
|
ap.add_argument("--skip-mkdocs", action="store_true", help="Reuse existing site dir; only run print-to-pdf.")
|
||||||
"--pdf-dark",
|
|
||||||
type=Path,
|
|
||||||
default=root / "export" / "thgtoa-dark.pdf",
|
|
||||||
help="Output path for dark PDF (default: ./export/thgtoa-dark.pdf)",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--skip-mkdocs",
|
|
||||||
action="store_true",
|
|
||||||
help="Reuse existing site dir; skip MkDocs build.",
|
|
||||||
)
|
|
||||||
|
|
||||||
mode = ap.add_mutually_exclusive_group()
|
|
||||||
mode.add_argument("--dark", action="store_true", help="Dark PDF only (light PDF must already exist)")
|
|
||||||
mode.add_argument("--both", action="store_true", help="Build light PDF, then dark PDF")
|
|
||||||
# default (no flag) = light only
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
build_light = not args.dark
|
guide_html = args.site_dir / "guide" / "index.html"
|
||||||
build_dark = args.dark or args.both
|
if not args.skip_mkdocs:
|
||||||
|
run_mkdocs(args.site_dir)
|
||||||
|
if not guide_html.is_file():
|
||||||
|
print(f"Missing {guide_html}; run without --skip-mkdocs first.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
# --- Light PDF (Chromium) ---
|
browser = find_chromium_executable()
|
||||||
if build_light:
|
if not browser:
|
||||||
guide_html = args.site_dir / "guide" / "index.html"
|
print(
|
||||||
|
"No Chromium-based browser found (Chrome, Edge, or Chromium). "
|
||||||
if not args.skip_mkdocs:
|
"Install Google Chrome or Microsoft Edge, or add Chromium to PATH.",
|
||||||
run_mkdocs(args.site_dir)
|
file=sys.stderr,
|
||||||
|
)
|
||||||
if not guide_html.is_file():
|
return 1
|
||||||
print(f"Missing {guide_html}; run without --skip-mkdocs first.", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
browser = find_chromium_executable()
|
|
||||||
if not browser:
|
|
||||||
print(
|
|
||||||
"No Chromium-based browser found (Chrome, Edge, or Chromium). "
|
|
||||||
"Install Google Chrome or Microsoft Edge, or add Chromium to PATH.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
out_light = print_to_pdf(browser, guide_html, args.pdf_light)
|
|
||||||
size_kb = out_light.stat().st_size // 1024
|
|
||||||
print(f"Wrote {out_light.resolve()} ({size_kb} KiB) [Light Mode]")
|
|
||||||
if out_light.resolve() != args.pdf_light.resolve():
|
|
||||||
print(
|
|
||||||
f"Note: {args.pdf_light.name} was in use; "
|
|
||||||
"close it and rename or replace with the file above.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Dark PDF (pixel converter) ---
|
|
||||||
if build_dark:
|
|
||||||
if not args.pdf_light.exists():
|
|
||||||
print(
|
|
||||||
f"Light PDF not found at {args.pdf_light}. "
|
|
||||||
"Run without --dark first, or use --both.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
out_dark = build_dark_pdf(args.pdf_light, args.pdf_dark)
|
|
||||||
size_kb = out_dark.stat().st_size // 1024
|
|
||||||
print(f"Wrote {out_dark.resolve()} ({size_kb} KiB) [Dark Mode]")
|
|
||||||
|
|
||||||
|
out = print_to_pdf(browser, guide_html, args.pdf)
|
||||||
|
size_kb = out.stat().st_size // 1024
|
||||||
|
print(f"Wrote {out.resolve()} ({size_kb} KiB)")
|
||||||
|
if out.resolve() != args.pdf.resolve():
|
||||||
|
print(
|
||||||
|
f"Note: {args.pdf.name} was in use; close it and rename or replace with the file above.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Dark-mode PDF converter (pixel-based, batch-safe).
|
|
||||||
|
|
||||||
Rasterizes each page with pdftoppm, applies the hacker theme palette
|
|
||||||
pixel-by-pixel, then reassembles into a PDF. Processes in batches of 50
|
|
||||||
pages to stay within memory limits on large documents, then merges with qpdf.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/convert.py INPUT.pdf [OUTPUT.pdf]
|
|
||||||
python scripts/convert.py INPUT.pdf [OUTPUT.pdf] [--dpi 200]
|
|
||||||
[--bg 1f1f31] [--text e0e0e0] [--link 5e8bde]
|
|
||||||
[--batch-size 50]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
python scripts/convert.py export/thgtoa.pdf export/thgtoa-dark.pdf
|
|
||||||
python scripts/convert.py export/thgtoa.pdf --dpi 150 --bg 0d1117
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import glob
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Defaults (Hacker theme)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
DEFAULT_BG = (0x1f, 0x1f, 0x31)
|
|
||||||
DEFAULT_TEXT = (0xe0, 0xe0, 0xe0)
|
|
||||||
DEFAULT_LINK = (0x5e, 0x8b, 0xde)
|
|
||||||
DEFAULT_DPI = 200
|
|
||||||
DEFAULT_BATCH = 50
|
|
||||||
|
|
||||||
|
|
||||||
def hex_to_rgb(h: str) -> tuple:
|
|
||||||
h = h.lstrip('#')
|
|
||||||
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
|
||||||
|
|
||||||
|
|
||||||
def apply_dark_theme(
|
|
||||||
img: Image.Image,
|
|
||||||
bg=DEFAULT_BG,
|
|
||||||
text=DEFAULT_TEXT,
|
|
||||||
link=DEFAULT_LINK,
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Remap a white-background page image to a dark theme.
|
|
||||||
- Near-white pixels → bg color
|
|
||||||
- Dark pixels (ink/text) → text color
|
|
||||||
- Blue-ish pixels → link color
|
|
||||||
"""
|
|
||||||
arr = np.array(img.convert('RGB'), dtype=np.float32)
|
|
||||||
orig = arr.copy()
|
|
||||||
norm = arr / 255.0
|
|
||||||
|
|
||||||
lightness = (
|
|
||||||
0.299 * norm[:, :, 0]
|
|
||||||
+ 0.587 * norm[:, :, 1]
|
|
||||||
+ 0.114 * norm[:, :, 2]
|
|
||||||
)
|
|
||||||
|
|
||||||
r, g, b = orig[:, :, 0], orig[:, :, 1], orig[:, :, 2]
|
|
||||||
link_mask = (
|
|
||||||
(b > 100)
|
|
||||||
& (b > r * 1.3)
|
|
||||||
& (b > g * 0.9)
|
|
||||||
& (lightness < 0.85)
|
|
||||||
)
|
|
||||||
content_mask = (lightness < 0.85) & ~link_mask
|
|
||||||
blend = ((1.0 - lightness) / 0.85).clip(0, 1)
|
|
||||||
|
|
||||||
bg_f = [c / 255.0 for c in bg]
|
|
||||||
text_f = [c / 255.0 for c in text]
|
|
||||||
link_f = [c / 255.0 for c in link]
|
|
||||||
|
|
||||||
out = np.zeros_like(norm)
|
|
||||||
for i, (b_c, t, lc) in enumerate(zip(bg_f, text_f, link_f)):
|
|
||||||
channel = np.full(lightness.shape, b_c)
|
|
||||||
channel = np.where(content_mask, b_c + blend * (t - b_c), channel)
|
|
||||||
channel = np.where(link_mask, b_c + blend * (lc - b_c), channel)
|
|
||||||
out[:, :, i] = channel
|
|
||||||
|
|
||||||
return Image.fromarray((out * 255).clip(0, 255).astype('uint8'))
|
|
||||||
|
|
||||||
|
|
||||||
def _save_images_as_pdf(images: list, output_path: str) -> None:
|
|
||||||
"""Save a list of RGB PIL images as a PDF without requiring libjpeg.
|
|
||||||
|
|
||||||
Pillow's PDF writer defaults to JPEG encoding for RGB images, which
|
|
||||||
fails when libjpeg is absent in the environment. Fix: quantize each
|
|
||||||
image to palette mode (256 colours, FASTOCTREE) so Pillow uses
|
|
||||||
zlib/deflate instead of JPEG, save each as an individual single-page
|
|
||||||
PDF, then merge the page PDFs with qpdf.
|
|
||||||
|
|
||||||
Colour fidelity is preserved — the hacker theme uses only a handful
|
|
||||||
of distinct colours so 256-colour quantization is visually lossless.
|
|
||||||
"""
|
|
||||||
import tempfile as _tempfile
|
|
||||||
with _tempfile.TemporaryDirectory() as staging:
|
|
||||||
page_pdfs = []
|
|
||||||
for i, img in enumerate(images):
|
|
||||||
page_path = os.path.join(staging, f'p{i:05d}.pdf')
|
|
||||||
img.quantize(colors=256, method=Image.Quantize.FASTOCTREE).save(
|
|
||||||
page_path, format='PDF'
|
|
||||||
)
|
|
||||||
page_pdfs.append(page_path)
|
|
||||||
subprocess.run(
|
|
||||||
['qpdf', '--empty', '--pages'] + page_pdfs + ['--', output_path],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_qpdf() -> bool:
|
|
||||||
return subprocess.run(
|
|
||||||
['qpdf', '--version'], capture_output=True
|
|
||||||
).returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def _check_dependencies() -> None:
|
|
||||||
"""Verify required system tools are available before doing any work."""
|
|
||||||
missing = []
|
|
||||||
for tool in ('pdftoppm', 'qpdf'):
|
|
||||||
if subprocess.run(['which', tool], capture_output=True).returncode != 0:
|
|
||||||
missing.append(tool)
|
|
||||||
if missing:
|
|
||||||
tools = ', '.join(missing)
|
|
||||||
instructions = (
|
|
||||||
f"Install with:\n"
|
|
||||||
f" Linux/WSL: sudo apt install poppler-utils qpdf\n"
|
|
||||||
f" macOS: brew install poppler qpdf\n"
|
|
||||||
f" Windows: see docs/code/develop.md"
|
|
||||||
)
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Missing required system tool(s): {tools}\n{instructions}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def convert_pdf_to_dark(
|
|
||||||
input_path: str | Path,
|
|
||||||
output_path: str | Path,
|
|
||||||
dpi: int = DEFAULT_DPI,
|
|
||||||
bg=DEFAULT_BG,
|
|
||||||
text=DEFAULT_TEXT,
|
|
||||||
link=DEFAULT_LINK,
|
|
||||||
batch_size: int = DEFAULT_BATCH,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Full pipeline: rasterize → apply dark theme → reassemble as PDF.
|
|
||||||
|
|
||||||
For large documents, pages are processed in batches of `batch_size` to
|
|
||||||
avoid OOM, then merged with qpdf. Falls back to single-pass Pillow save
|
|
||||||
if qpdf is not available (fine for small documents).
|
|
||||||
"""
|
|
||||||
input_path = str(input_path)
|
|
||||||
output_path = str(output_path)
|
|
||||||
|
|
||||||
_check_dependencies()
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
# 1. Rasterize all pages
|
|
||||||
prefix = os.path.join(tmp, 'page')
|
|
||||||
result = subprocess.run(
|
|
||||||
['pdftoppm', '-r', str(dpi), '-png', input_path, prefix],
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"pdftoppm failed:\n{result.stderr.decode()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
pages = sorted(glob.glob(prefix + '-*.png'))
|
|
||||||
if not pages:
|
|
||||||
raise RuntimeError(
|
|
||||||
"pdftoppm produced no output pages — "
|
|
||||||
"is the PDF valid and not password-protected?"
|
|
||||||
)
|
|
||||||
|
|
||||||
total = len(pages)
|
|
||||||
print(f" Converting {total} page(s) at {dpi} DPI…", flush=True)
|
|
||||||
|
|
||||||
out_dir = os.path.dirname(output_path)
|
|
||||||
if out_dir:
|
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# 2. Process in batches
|
|
||||||
use_batches = total > batch_size and _check_qpdf()
|
|
||||||
|
|
||||||
if use_batches:
|
|
||||||
batch_dir = os.path.join(tmp, 'batches')
|
|
||||||
os.makedirs(batch_dir)
|
|
||||||
batch_files = []
|
|
||||||
|
|
||||||
for start in range(0, total, batch_size):
|
|
||||||
batch = pages[start:start + batch_size]
|
|
||||||
batch_num = start // batch_size + 1
|
|
||||||
batch_path = os.path.join(batch_dir, f'batch_{batch_num:04d}.pdf')
|
|
||||||
|
|
||||||
print(
|
|
||||||
f" Batch {batch_num}/{(total + batch_size - 1) // batch_size}: "
|
|
||||||
f"pages {start + 1}–{start + len(batch)}",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
dark = [apply_dark_theme(Image.open(p), bg, text, link) for p in batch]
|
|
||||||
_save_images_as_pdf(dark, batch_path)
|
|
||||||
batch_files.append(batch_path)
|
|
||||||
del dark
|
|
||||||
|
|
||||||
# 3. Merge batches with qpdf
|
|
||||||
print(" Merging batches…", flush=True)
|
|
||||||
subprocess.run(
|
|
||||||
['qpdf', '--empty', '--pages'] + batch_files + ['--', output_path],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Single-pass for small documents or when qpdf is unavailable
|
|
||||||
dark_pages = []
|
|
||||||
for i, p in enumerate(pages, 1):
|
|
||||||
if i % 50 == 0 or i == 1:
|
|
||||||
print(f" Page {i}/{total}", flush=True)
|
|
||||||
dark_pages.append(apply_dark_theme(Image.open(p), bg, text, link))
|
|
||||||
|
|
||||||
_save_images_as_pdf(dark_pages, output_path)
|
|
||||||
|
|
||||||
size_mb = os.path.getsize(output_path) / 1024 / 1024
|
|
||||||
print(f" Saved → {output_path} ({size_mb:.1f} MB)")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# CLI
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description='Convert a PDF to dark mode.')
|
|
||||||
parser.add_argument('input', help='Input PDF path')
|
|
||||||
parser.add_argument('output', nargs='?', help='Output PDF path (optional)')
|
|
||||||
parser.add_argument('--dpi', type=int, default=DEFAULT_DPI, help='Rasterization DPI (default: 200)')
|
|
||||||
parser.add_argument('--batch-size', type=int, default=DEFAULT_BATCH, help='Pages per batch (default: 50)')
|
|
||||||
parser.add_argument('--bg', default='1f1f31', help='Background hex color (default: 1f1f31)')
|
|
||||||
parser.add_argument('--text', default='e0e0e0', help='Body text hex color (default: e0e0e0)')
|
|
||||||
parser.add_argument('--link', default='5e8bde', help='Link/blue hex color (default: 5e8bde)')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if not args.output:
|
|
||||||
base = Path(args.input).stem
|
|
||||||
args.output = str(Path(args.input).parent / f"{base}-dark.pdf")
|
|
||||||
|
|
||||||
convert_pdf_to_dark(
|
|
||||||
args.input,
|
|
||||||
args.output,
|
|
||||||
dpi=args.dpi,
|
|
||||||
bg=hex_to_rgb(args.bg),
|
|
||||||
text=hex_to_rgb(args.text),
|
|
||||||
link=hex_to_rgb(args.link),
|
|
||||||
batch_size=args.batch_size,
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
COMMIT_MSG_FILE="$1"
|
|
||||||
|
|
||||||
if [ -z "$COMMIT_MSG_FILE" ]; then
|
|
||||||
echo "require-commit-body: no commit message file supplied" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
stripped=$(grep -v '^\s*#' "$COMMIT_MSG_FILE" | sed 's/[[:space:]]*$//')
|
|
||||||
|
|
||||||
subject=$(echo "$stripped" | sed -n '1p')
|
|
||||||
separator=$(echo "$stripped" | sed -n '2p')
|
|
||||||
body=$(echo "$stripped" | tail -n +3 | grep -v '^\s*$')
|
|
||||||
|
|
||||||
if [ -z "$subject" ]; then
|
|
||||||
echo ""
|
|
||||||
echo " COMMIT REJECTED: subject line is empty." >&2
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$separator" ] && [ -z "$body" ]; then
|
|
||||||
echo ""
|
|
||||||
echo " COMMIT REJECTED: commit has no body." >&2
|
|
||||||
echo ""
|
|
||||||
echo " Every commit must explain *why*, not just *what*." >&2
|
|
||||||
echo " Format:" >&2
|
|
||||||
echo ""
|
|
||||||
echo " type(scope): short subject" >&2
|
|
||||||
echo ""
|
|
||||||
echo " Explain the motivation for this change. What problem does" >&2
|
|
||||||
echo " it solve? Why is this the right approach? Reference any" >&2
|
|
||||||
echo " relevant issues, prior art, or context." >&2
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$separator" ]; then
|
|
||||||
echo ""
|
|
||||||
echo " COMMIT REJECTED: no blank line between subject and body." >&2
|
|
||||||
echo ""
|
|
||||||
echo " The second line of a commit message must be blank." >&2
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$body" ]; then
|
|
||||||
echo ""
|
|
||||||
echo " COMMIT REJECTED: commit body is empty." >&2
|
|
||||||
echo ""
|
|
||||||
echo " Add at least one line after the blank separator explaining" >&2
|
|
||||||
echo " why this change was made." >&2
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Setup helper for PDF workflow configuration.
|
|
||||||
|
|
||||||
This script helps you configure the necessary GitHub Secrets for the automated
|
|
||||||
PDF build, signing, and VirusTotal scanning workflows.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/setup_workflow.py
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- Python 3.8+
|
|
||||||
- GPG installed (for key export)
|
|
||||||
- Access to GitHub repository settings
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
1. Validates your GPG key setup
|
|
||||||
2. Exports the public key for verification
|
|
||||||
3. Provides instructions for adding secrets to GitHub
|
|
||||||
"""
|
|
||||||
|
|
||||||
# The Hitchhiker's Guide to Online Anonymity © 2026 by Anonymous Planet is licensed under Creative
|
|
||||||
# Commons Attribution-NonCommercial 4.0 International
|
|
||||||
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Computes the root directory of the repository based on the current file’s location
|
|
||||||
def repo_root() -> Path:
|
|
||||||
return Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
# Determines whether the GPG command-line tool is available on the current system
|
|
||||||
def check_gpg_installed() -> bool:
|
|
||||||
"""Check if GPG is installed and accessible."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["gpg", "--version"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Checks whether the GPG tool is installed and accessible on the system
|
|
||||||
# Export public keys, parse, and read selection of keys from the GPG keyring
|
|
||||||
def list_gpg_keys() -> list[dict]:
|
|
||||||
"""List all GPG keys in the keyring."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["gpg", "--list-keys", "--with-colons"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
keys = []
|
|
||||||
current_key = {}
|
|
||||||
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if line.startswith('pub:'):
|
|
||||||
if current_key:
|
|
||||||
keys.append(current_key)
|
|
||||||
parts = line.split(':')
|
|
||||||
current_key = {
|
|
||||||
'type': parts[1],
|
|
||||||
'key_id': parts[4],
|
|
||||||
'fingerprint': parts[9] if len(parts) > 9 else None,
|
|
||||||
'created': parts[5],
|
|
||||||
'expires': parts[6],
|
|
||||||
'uid': None,
|
|
||||||
}
|
|
||||||
elif line.startswith('uid:'):
|
|
||||||
parts = line.split(':')
|
|
||||||
current_key['uid'] = parts[9] if len(parts) > 9 else None
|
|
||||||
|
|
||||||
if current_key:
|
|
||||||
keys.append(current_key)
|
|
||||||
|
|
||||||
return keys
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error listing GPG keys: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def export_public_key(key_id: str, output_file: Path | None = None) -> str | None:
|
|
||||||
"""Export a public key in ASCII armor format."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["gpg", "--armor", "--export", key_id],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if output_file:
|
|
||||||
output_file.write_text(result.stdout)
|
|
||||||
print(f"✓ Public key exported to {output_file}")
|
|
||||||
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error exporting public key: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Exporting the given private key in ASCII armor format (requires passphrase)
|
|
||||||
def export_private_key(key_id: str, output_file: Path | None = None) -> str | None:
|
|
||||||
"""Export a private key in ASCII armor format (requires passphrase)."""
|
|
||||||
try:
|
|
||||||
# This will prompt for passphrase interactively
|
|
||||||
result = subprocess.run(
|
|
||||||
["gpg", "--armor", "--export-secret-keys", key_id],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if output_file:
|
|
||||||
output_file.write_text(result.stdout)
|
|
||||||
print(f"✓ Private key exported to {output_file}")
|
|
||||||
|
|
||||||
return result.stdout
|
|
||||||
# return None if export fails (e.g. wrong passphrase, key not found)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error exporting private key: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Validate that the selected GPG key has signing capability
|
|
||||||
def validate_gpg_key(key_id: str) -> bool:
|
|
||||||
"""Validate that a GPG key has signing capability."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["gpg", "--list-keys", "--with-colons", key_id],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for 's' (signing) in the pub line
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if line.startswith('pub:'):
|
|
||||||
flags = line.split(':')[1]
|
|
||||||
return 's' in flags
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Print instructions for configuring GitHub Secrets
|
|
||||||
def print_setup_instructions():
|
|
||||||
"""Print instructions for configuring GitHub Secrets."""
|
|
||||||
print("\n" + "="*70)
|
|
||||||
print("GITHUB SECRETS SETUP INSTRUCTIONS")
|
|
||||||
print("="*70)
|
|
||||||
|
|
||||||
print("""
|
|
||||||
To enable the automated PDF workflow, you need to add three secrets to your
|
|
||||||
GitHub repository:
|
|
||||||
|
|
||||||
1. GPG_PRIVATE_KEY
|
|
||||||
- Your GPG private key in ASCII armor format
|
|
||||||
- Used to sign PDFs and hash files
|
|
||||||
- IMPORTANT: Keep this secret! Never commit it publicly
|
|
||||||
|
|
||||||
2. GPG_PASSPHRASE
|
|
||||||
- The passphrase for your GPG private key
|
|
||||||
- Required to unlock the private key for signing
|
|
||||||
|
|
||||||
3. VT_API_KEY (optional but recommended)
|
|
||||||
- VirusTotal API key for malware scanning
|
|
||||||
|
|
||||||
TROUBLESHOOTING:
|
|
||||||
|
|
||||||
- If GPG signing fails: Check that your key has signing capability ('s' flag)
|
|
||||||
- If passphrase is wrong: Verify you're using the correct passphrase
|
|
||||||
- If VT scan fails: Ensure API key is valid and within rate limits
|
|
||||||
""")
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
print("\n" + "="*70)
|
|
||||||
print("PDF WORKFLOW SETUP HELPER")
|
|
||||||
print("="*70)
|
|
||||||
|
|
||||||
# Check GPG installation
|
|
||||||
if not check_gpg_installed():
|
|
||||||
print("⚠ WARNING: GPG is not installed or not in PATH")
|
|
||||||
print("Please install GPG before continuing:")
|
|
||||||
print(" - Linux: sudo apt install gnupg")
|
|
||||||
print("\nContinuing anyway...")
|
|
||||||
|
|
||||||
# List available keys
|
|
||||||
print("\n🔑 Available GPG Keys:")
|
|
||||||
print("-" * 70)
|
|
||||||
|
|
||||||
keys = list_gpg_keys()
|
|
||||||
|
|
||||||
if not keys:
|
|
||||||
print("No GPG keys found in your keyring.")
|
|
||||||
print("Generate a key with: gpg --full-generate-key")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
for i, key in enumerate(keys, 1):
|
|
||||||
status = "✓" if validate_gpg_key(key['key_id']) else "✗"
|
|
||||||
print(f"\n{i}. {status} Key ID: {key['key_id']}")
|
|
||||||
print(f" Fingerprint: {key.get('fingerprint', 'N/A')}")
|
|
||||||
print(f" UID: {key.get('uid', 'Unknown')}")
|
|
||||||
print(f" Created: {key.get('created', 'Unknown')}")
|
|
||||||
|
|
||||||
if key.get('expires'):
|
|
||||||
print(f" Expires: {key['expires']}")
|
|
||||||
|
|
||||||
# Ask user to select key
|
|
||||||
print("\n" + "-" * 70)
|
|
||||||
try:
|
|
||||||
choice = input("\nEnter the number of the key you want to use (1-{}): ".format(len(keys)))
|
|
||||||
selected_index = int(choice) - 1
|
|
||||||
|
|
||||||
if not (0 <= selected_index < len(keys)):
|
|
||||||
print("Invalid selection!")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
print("Invalid input! Please enter a number.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
selected_key = keys[selected_index]
|
|
||||||
|
|
||||||
# Validate key has signing capability
|
|
||||||
if not validate_gpg_key(selected_key['key_id']):
|
|
||||||
print(f"\n⚠ WARNING: Selected key does not have signing capability!")
|
|
||||||
print("You need a key with 's' (signing) flag for PDF signatures.")
|
|
||||||
confirm = input("Continue anyway? (y/N): ")
|
|
||||||
if confirm.lower() != 'y':
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Export public key
|
|
||||||
print(f"\n📤 Exporting public key for {selected_key['uid']}...")
|
|
||||||
public_key_file = repo_root() / "pgp" / "workflow-public.asc"
|
|
||||||
|
|
||||||
public_key = export_public_key(selected_key['key_id'], public_key_file)
|
|
||||||
|
|
||||||
if not public_key:
|
|
||||||
print("Failed to export public key!")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Show public key info
|
|
||||||
print("\n✓ Public Key Information:")
|
|
||||||
print("-" * 70)
|
|
||||||
for line in public_key.split('\n')[:5]:
|
|
||||||
print(line)
|
|
||||||
print("...")
|
|
||||||
|
|
||||||
# Instructions for private key export
|
|
||||||
print("\n🔐 Private Key Export:")
|
|
||||||
print("-" * 70)
|
|
||||||
print("""
|
|
||||||
To get your private key for the GPG_PRIVATE_KEY secret:
|
|
||||||
|
|
||||||
1. Run this command (you'll be prompted for passphrase):
|
|
||||||
gpg --armor --export-secret-keys {} > workflow-private.asc
|
|
||||||
|
|
||||||
2. Copy the ENTIRE output including BEGIN and END lines
|
|
||||||
|
|
||||||
3. Add it to GitHub Secrets as 'GPG_PRIVATE_KEY'
|
|
||||||
|
|
||||||
⚠ IMPORTANT: Keep your private key secure! Never commit it publicly.
|
|
||||||
""".format(selected_key['key_id']))
|
|
||||||
|
|
||||||
# Print setup instructions
|
|
||||||
print_setup_instructions()
|
|
||||||
|
|
||||||
print("\n" + "="*70)
|
|
||||||
print("SETUP COMPLETE!")
|
|
||||||
print("="*70)
|
|
||||||
print(f"\nPublic key saved to: {public_key_file}")
|
|
||||||
print("Next steps:")
|
|
||||||
print("1. Export your private key (see instructions above)")
|
|
||||||
print("2. Add all three secrets to GitHub repository settings")
|
|
||||||
print("3. Test the workflow by triggering a manual build")
|
|
||||||
print("\nFor more information, see: docs/guide/dev-workflow.md\n")
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Auto-generate and prepend a changelog entry to docs/changelog/index.md.
|
|
||||||
|
|
||||||
Called by .github/workflows/changelog.yml. Reads git log since the last
|
|
||||||
changelog version, categorises commits by conventional-commit prefix,
|
|
||||||
and prepends a new ## [vX.Y.Z] section in the MkDocs admonition format used
|
|
||||||
by the rest of the file.
|
|
||||||
|
|
||||||
Environment variables:
|
|
||||||
MANUAL_VERSION Version string to record (required when run from CI).
|
|
||||||
Falls back to auto-increment from the changelog for local runs.
|
|
||||||
TRIGGERING_SHA The commit SHA that triggered this run (used as range end).
|
|
||||||
DRY_RUN If "true", print the entry and exit without writing.
|
|
||||||
|
|
||||||
Note: version is sourced from the changelog file, not from git tags. Git tags
|
|
||||||
are no longer used as the version authority. The changelog is the source of truth.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
CHANGELOG = Path(__file__).resolve().parent.parent / "docs" / "changelog" / "index.md"
|
|
||||||
|
|
||||||
# Conventional-commit prefixes → changelog bucket
|
|
||||||
BUCKET_MAP: dict[str, str] = {
|
|
||||||
"feat": "Added",
|
|
||||||
"feature": "Added",
|
|
||||||
"add": "Added",
|
|
||||||
"fix": "Fixed",
|
|
||||||
"bugfix": "Fixed",
|
|
||||||
"perf": "Changed",
|
|
||||||
"refactor": "Changed",
|
|
||||||
"change": "Changed",
|
|
||||||
"chore": "Changed",
|
|
||||||
"ci": "Changed",
|
|
||||||
"docs": "Changed",
|
|
||||||
"style": "Changed",
|
|
||||||
"test": "Changed",
|
|
||||||
"revert": "Fixed",
|
|
||||||
"security": "Fixed",
|
|
||||||
"build": "Changed",
|
|
||||||
}
|
|
||||||
|
|
||||||
BUCKET_ORDER = ["Added", "Changed", "Fixed"]
|
|
||||||
|
|
||||||
|
|
||||||
def run(cmd: list[str]) -> str:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def auto_increment_version(current: str | None) -> str:
|
|
||||||
"""Bump the patch number of the current version, or default to v1.0.0."""
|
|
||||||
if not current:
|
|
||||||
return "v1.0.0"
|
|
||||||
m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", current)
|
|
||||||
if not m:
|
|
||||||
return "v1.0.0"
|
|
||||||
major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
||||||
return f"v{major}.{minor}.{patch + 1}"
|
|
||||||
|
|
||||||
|
|
||||||
def version_from_changelog() -> str | None:
|
|
||||||
"""Parse the most recent ## [vX.Y.Z] heading from the changelog file.
|
|
||||||
|
|
||||||
This is the primary version source — the changelog is the authority,
|
|
||||||
not git tags.
|
|
||||||
"""
|
|
||||||
if not CHANGELOG.exists():
|
|
||||||
return None
|
|
||||||
for line in CHANGELOG.read_text(encoding="utf-8").splitlines():
|
|
||||||
m = re.match(r"^## \[(v\d+\.\d+\.\d+)\]", line)
|
|
||||||
if m:
|
|
||||||
return m.group(1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def commits_since(ref: str | None, until: str) -> list[str]:
|
|
||||||
"""Return one-line commit messages between ref and until (exclusive/inclusive).
|
|
||||||
|
|
||||||
When no ref is given we fall back to the merge-base between HEAD and
|
|
||||||
origin/main to avoid dumping the entire history into the changelog.
|
|
||||||
"""
|
|
||||||
if ref:
|
|
||||||
log_range = f"{ref}..{until}"
|
|
||||||
else:
|
|
||||||
merge_base = run(["git", "merge-base", "HEAD", "origin/main"])
|
|
||||||
if merge_base:
|
|
||||||
log_range = f"{merge_base}..{until}"
|
|
||||||
else:
|
|
||||||
# Truly brand new repo with no remote — limit to last 50 commits
|
|
||||||
log_range = f"-50 {until}"
|
|
||||||
out = run(["git", "log", "--pretty=format:%s", log_range])
|
|
||||||
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def categorise(messages: list[str]) -> dict[str, list[str]]:
|
|
||||||
"""Sort commit messages into Added / Changed / Fixed buckets."""
|
|
||||||
buckets: dict[str, list[str]] = {b: [] for b in BUCKET_ORDER}
|
|
||||||
|
|
||||||
NOISE = re.compile(
|
|
||||||
r"""
|
|
||||||
\[skip\ ci\] # CI skip marker
|
|
||||||
| ^Merge\ (pull\ request|branch) # merge commits
|
|
||||||
| ^chore:\ bump # version bump chores
|
|
||||||
| update\ changelog # self-referential
|
|
||||||
| ^\d+/\d+ # numbered commit series (e.g. 3/8)
|
|
||||||
| ^Tweaking # vague WIP messages
|
|
||||||
| ^Moving\ some # vague WIP messages
|
|
||||||
| \ pt\d+$ # "...pt2", "...pt3" suffixes
|
|
||||||
| ^Fix\ (workflow|path|README)$ # one-word infrastructure fixes
|
|
||||||
| ^Still\ broken # embarrassing mid-fix notes
|
|
||||||
| ^WIP\b # work in progress
|
|
||||||
| ^Forgot\ to # oops commits
|
|
||||||
| ^Revert\ " # reverts (surface the original instead)
|
|
||||||
| ^One\ job\ to\ rule # joke commit messages
|
|
||||||
""",
|
|
||||||
re.VERBOSE | re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
for msg in messages:
|
|
||||||
if NOISE.search(msg):
|
|
||||||
continue
|
|
||||||
|
|
||||||
m = re.match(r"^(\w+)(?:\([^)]+\))?!?:\s*(.+)$", msg)
|
|
||||||
if m:
|
|
||||||
prefix = m.group(1).lower()
|
|
||||||
description = m.group(2).strip()
|
|
||||||
bucket = BUCKET_MAP.get(prefix, "Changed")
|
|
||||||
else:
|
|
||||||
description = msg
|
|
||||||
bucket = "Changed"
|
|
||||||
|
|
||||||
description = description[0].upper() + description[1:] if description else description
|
|
||||||
buckets[bucket].append(description)
|
|
||||||
|
|
||||||
return buckets
|
|
||||||
|
|
||||||
|
|
||||||
def format_admonition(bucket: str, items: list[str]) -> str:
|
|
||||||
lines = [f'!!! Note "{bucket}"', ""]
|
|
||||||
for item in items:
|
|
||||||
lines.append(f" - {item}")
|
|
||||||
lines.append("")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def build_entry(version: str, buckets: dict[str, list[str]], sha: str) -> str:
|
|
||||||
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
lines = [f"## [{version}]", ""]
|
|
||||||
lines.append('!!! Note "Meta"')
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f" - Released {date} from [`{sha[:7]}`](https://github.com/Anon-Planet/thgtoa/commit/{sha})")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
for bucket in BUCKET_ORDER:
|
|
||||||
if buckets.get(bucket):
|
|
||||||
lines.append(format_admonition(bucket, buckets[bucket]))
|
|
||||||
|
|
||||||
if not any(buckets[b] for b in BUCKET_ORDER):
|
|
||||||
lines.append('!!! Note "Changed"')
|
|
||||||
lines.append("")
|
|
||||||
lines.append(" - Minor updates and maintenance")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def prepend_entry(entry: str) -> None:
|
|
||||||
"""Insert the new entry after the # Release Notes heading."""
|
|
||||||
content = CHANGELOG.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
insert_at = content.find("\n## ")
|
|
||||||
if insert_at == -1:
|
|
||||||
content = content.rstrip() + "\n\n" + entry + "\n"
|
|
||||||
else:
|
|
||||||
content = content[: insert_at + 1] + entry + "\n" + content[insert_at + 1 :]
|
|
||||||
|
|
||||||
CHANGELOG.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def already_has_version(version: str) -> bool:
|
|
||||||
if not CHANGELOG.exists():
|
|
||||||
return False
|
|
||||||
return f"## [{version}]" in CHANGELOG.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
dry_run = os.environ.get("DRY_RUN", "false").lower() == "true"
|
|
||||||
manual_version = os.environ.get("MANUAL_VERSION", "").strip()
|
|
||||||
triggering_sha = os.environ.get("TRIGGERING_SHA", "HEAD").strip() or "HEAD"
|
|
||||||
|
|
||||||
# Version authority: MANUAL_VERSION (required in CI) → changelog → auto-increment.
|
|
||||||
# Git tags are intentionally not consulted.
|
|
||||||
last_cl_ver = version_from_changelog()
|
|
||||||
new_version = manual_version or auto_increment_version(last_cl_ver)
|
|
||||||
|
|
||||||
print(f"Last CL version: {last_cl_ver or '(none)'}")
|
|
||||||
print(f"New version: {new_version}")
|
|
||||||
print(f"Triggering SHA: {triggering_sha}")
|
|
||||||
|
|
||||||
if already_has_version(new_version) and not manual_version:
|
|
||||||
print(f"Changelog already contains {new_version} — nothing to do.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if already_has_version(new_version) and manual_version:
|
|
||||||
print(f"::error::Changelog already contains {new_version}. Choose a different version.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Collect commits since the last changelog version (using it as a git ref
|
|
||||||
# is a best-effort — if the tag doesn't exist, commits_since handles it gracefully).
|
|
||||||
messages = commits_since(last_cl_ver, triggering_sha)
|
|
||||||
print(f"Commits found: {len(messages)}")
|
|
||||||
for m in messages:
|
|
||||||
print(f" {m}")
|
|
||||||
|
|
||||||
buckets = categorise(messages)
|
|
||||||
entry = build_entry(new_version, buckets, triggering_sha)
|
|
||||||
|
|
||||||
print("\n--- Generated entry ---")
|
|
||||||
print(entry)
|
|
||||||
print("-----------------------\n")
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print("DRY RUN — not writing to file.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
prepend_entry(entry)
|
|
||||||
print(f"Prepended {new_version} to {CHANGELOG}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Verification script for PDF files.
|
|
||||||
|
|
||||||
This script verifies:
|
|
||||||
1. SHA256 hash integrity of PDF files
|
|
||||||
2. GPG signature authenticity
|
|
||||||
3. VirusTotal scan status (optional)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/verify_pdf.py --all # Verify everything
|
|
||||||
python scripts/verify_pdf.py --hashes # Only verify hashes
|
|
||||||
python scripts/verify_pdf.py --signatures # Only verify signatures
|
|
||||||
python scripts/verify_pdf.py --vt # Check VT status (requires API key)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
python scripts/verify_pdf.py --all
|
|
||||||
python scripts/verify_pdf.py --hashes --file export/thgtoa.pdf
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def repo_root() -> Path:
|
|
||||||
return Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
def calculate_sha256(file_path: Path) -> str:
|
|
||||||
"""Calculate SHA256 hash of a file."""
|
|
||||||
sha256_hash = hashlib.sha256()
|
|
||||||
with open(file_path, "rb") as f:
|
|
||||||
for byte_block in iter(lambda: f.read(4096), b""):
|
|
||||||
sha256_hash.update(byte_block)
|
|
||||||
return sha256_hash.hexdigest()
|
|
||||||
|
|
||||||
def verify_hash(file_path: Path, expected_hash: str) -> bool:
|
|
||||||
"""Verify file hash against expected value."""
|
|
||||||
actual_hash = calculate_sha256(file_path)
|
|
||||||
is_valid = actual_hash == expected_hash
|
|
||||||
status = "✓ PASS" if is_valid else "✗ FAIL"
|
|
||||||
print(f"{status}: {file_path.name}")
|
|
||||||
print(f" Expected: {expected_hash}")
|
|
||||||
print(f" Actual: {actual_hash}")
|
|
||||||
return is_valid
|
|
||||||
|
|
||||||
def verify_signature(file_path: Path, sig_file: Path) -> bool:
|
|
||||||
"""Verify GPG signature of a file."""
|
|
||||||
if not sig_file.exists():
|
|
||||||
print(f"✗ FAIL: Signature file not found: {sig_file}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["gpg", "--verify", str(sig_file), str(file_path)],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(f"✓ PASS: {file_path.name} signature verified")
|
|
||||||
# Extract key info from GPG output
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if 'Good signature' in line or 'key ID' in line.lower():
|
|
||||||
print(f" {line.strip()}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"✗ FAIL: {file_path.name} signature verification failed")
|
|
||||||
print(f" Error: {result.stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
|
||||||
print("⚠ WARNING: GPG not installed. Skipping signature verification.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def verify_from_hash_file(file_path: Path, hash_file: Path) -> bool:
|
|
||||||
"""Verify file hash from a hash file."""
|
|
||||||
if not hash_file.exists():
|
|
||||||
print(f"✗ FAIL: Hash file not found: {hash_file}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
expected_hash = None
|
|
||||||
with open(hash_file, 'r') as f:
|
|
||||||
for line in f:
|
|
||||||
parts = line.strip().split()
|
|
||||||
if len(parts) >= 2 and parts[1] == str(file_path):
|
|
||||||
expected_hash = parts[0]
|
|
||||||
break
|
|
||||||
|
|
||||||
if not expected_hash:
|
|
||||||
print(f"✗ FAIL: Hash not found in {hash_file.name} for {file_path.name}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return verify_hash(file_path, expected_hash)
|
|
||||||
|
|
||||||
def check_virustotal(file_hash: str, api_key: str | None = None) -> dict | None:
|
|
||||||
"""Check VirusTotal scan status for a file hash."""
|
|
||||||
if not api_key:
|
|
||||||
print("⚠ WARNING: VT_API_KEY not set. Skipping VirusTotal check.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
import urllib.request
|
|
||||||
import json
|
|
||||||
|
|
||||||
url = f"https://www.virustotal.com/api/v3/files/{file_hash}"
|
|
||||||
request = urllib.request.Request(url, headers={"x-apikey": api_key})
|
|
||||||
|
|
||||||
with urllib.request.urlopen(request, timeout=30) as response:
|
|
||||||
data = json.loads(response.read().decode())
|
|
||||||
|
|
||||||
stats = data.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
|
|
||||||
total = sum(stats.values()) if stats else 0
|
|
||||||
|
|
||||||
print(f"\n🦠 VirusTotal Results for {file_hash[:16]}...")
|
|
||||||
print(f" Total scans: {total}")
|
|
||||||
|
|
||||||
if stats:
|
|
||||||
print(f" Malicious: {stats.get('malicious', 0)}")
|
|
||||||
print(f" Suspicious: {stats.get('suspicious', 0)}")
|
|
||||||
print(f" Undetected: {stats.get('undetected', 0)}")
|
|
||||||
print(f" Clean: {stats.get('harmless', 0)}")
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠ ERROR checking VirusTotal: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
root = repo_root()
|
|
||||||
ap = argparse.ArgumentParser(description="Verify PDF files (hashes, signatures, VT).")
|
|
||||||
|
|
||||||
# File paths
|
|
||||||
ap.add_argument(
|
|
||||||
"--light-pdf",
|
|
||||||
type=Path,
|
|
||||||
default=root / "export" / "thgtoa.pdf",
|
|
||||||
help="Light mode PDF file",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--dark-pdf",
|
|
||||||
type=Path,
|
|
||||||
default=root / "export" / "thgtoa-dark.pdf",
|
|
||||||
help="Dark mode PDF file",
|
|
||||||
)
|
|
||||||
ap.add_argument(
|
|
||||||
"--hash-file",
|
|
||||||
type=Path,
|
|
||||||
default=root / "export" / "thgtoa.pdf.sha256",
|
|
||||||
help="Hash file to verify against",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verification modes
|
|
||||||
group = ap.add_mutually_exclusive_group()
|
|
||||||
group.add_argument("--all", action="store_true", help="Verify everything")
|
|
||||||
group.add_argument("--hashes", action="store_true", help="Only verify hashes")
|
|
||||||
group.add_argument("--signatures", action="store_true", help="Only verify signatures")
|
|
||||||
ap.add_argument("--vt", action="store_true", help="Check VirusTotal status")
|
|
||||||
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
# Determine what to verify
|
|
||||||
if not any([args.all, args.hashes, args.signatures, args.vt]):
|
|
||||||
args.all = True
|
|
||||||
|
|
||||||
all_passed = True
|
|
||||||
|
|
||||||
pdf_files = [
|
|
||||||
("Light", args.light_pdf),
|
|
||||||
("Dark", args.dark_pdf),
|
|
||||||
]
|
|
||||||
|
|
||||||
for mode_name, pdf_file in pdf_files:
|
|
||||||
if not pdf_file.exists():
|
|
||||||
print(f"⚠ WARNING: {pdf_file.name} not found. Skipping.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"Verifying {mode_name} PDF: {pdf_file.name}")
|
|
||||||
print('='*60)
|
|
||||||
|
|
||||||
# Verify hash if requested
|
|
||||||
if args.all or args.hashes:
|
|
||||||
if not verify_from_hash_file(pdf_file, args.hash_file):
|
|
||||||
all_passed = False
|
|
||||||
|
|
||||||
# Verify signature if requested
|
|
||||||
if args.all or args.signatures:
|
|
||||||
sig_file = pdf_file.with_suffix(pdf_file.suffix + ".sig")
|
|
||||||
result = verify_signature(pdf_file, sig_file)
|
|
||||||
if result is False: # None means skipped (GPG not installed)
|
|
||||||
all_passed = False
|
|
||||||
|
|
||||||
# Check VirusTotal if requested
|
|
||||||
if args.all or args.vt:
|
|
||||||
file_hash = calculate_sha256(pdf_file)
|
|
||||||
api_key = os.environ.get("VT_API_KEY")
|
|
||||||
check_virustotal(file_hash, api_key)
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
if all_passed:
|
|
||||||
print("✓ All verifications PASSED")
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
print("✗ Some verifications FAILED")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
Reference in New Issue
Block a user