16 Commits

Author SHA1 Message Date
nopeitsnothing cdc54d8b3b feat(scripts): add tag_release.py — guided signed release tagger
Interactive script for maintainers to create GPG-signed annotated tags.
Checks clean tree and branch, auto-increments version from latest tag,
pulls the message from the matching changelog entry, resolves the release
signing key (default: 9FA5436D0EE360985157382517ECA05F768DEDF6),
creates the tag, verifies the signature, then prints the push command.

Usage:
  python scripts/tag_release.py                   # auto version
  python scripts/tag_release.py --version v1.2.4
  python scripts/tag_release.py --dry-run         # preview only

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 17:46:37 -04:00
nopeitsnothing 823edbf4af fix(ci): resolve Pillow JPEG KeyError and cairosvg missing dep
convert.py: Pillow's PDF writer requires libjpeg for RGB images, which
is not available in the CI Python environment. Replace all Pillow PDF
saves with _save_images_as_pdf(), which writes pages as lossless PNGs
and assembles them with qpdf — no JPEG dependency needed.

build.yml: install mkdocs-material[imaging] instead of mkdocs-material
to satisfy the cairosvg dependency required by the social plugin.

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 17:23:15 -04:00
nopeitsnothing f9d4c17ac6 8/8 chore(bump): v1.2.3
Some whitespace failed checks

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 17:23:15 -04:00
nopeitsnothing 4fd0413f4b 8/8 chore(scripts): minor cleanup to setup_workflow.py
Some whitespace failed checks

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 16:28:43 -04:00
nopeitsnothing bb005772af 7/8 docs(guide): bump version string to v1.2.3
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 16:27:07 -04:00
nopeitsnothing 77840d7d5c 6/8 chore: track .b2 hash files in .gitignore
Adds export/thgtoa.pdf.b2 and export/thgtoa-dark.pdf.b2 alongside
the existing .sha256 and .sig entries.

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 16:27:07 -04:00
nopeitsnothing f02a15a07c 5/8 docs(changelog): rewrite for v1.2.3 — consolidate and clean up
Merges the v1.2.2 and v1.2.3 draft entries into a single clean
release. Removes duplicate bullets, internal implementation noise,
and half-finished notes. Switches admonitions to success/warning/bug
types for better visual scanning. Adds a plain-English summary line
per version.

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 16:27:06 -04:00
nopeitsnothing c0aa6b8814 4/8 ci: add automated changelog update workflow
update_changelog.py reads git log since the last version tag,
categorises commits by conventional-commit prefix, and prepends a
new ## [vX.Y.Z] entry to docs/changelog/index.md. changelog.yml
runs after build.yml succeeds and commits the result back to main
with [skip ci]. Supports dry_run and manual_version dispatch inputs.

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 16:27:06 -04:00
nopeitsnothing c6fd2891e0 3/8 ci: split monolithic workflow into build, sign, release stages
build.yml   — builds PDFs, uploads artifact, no secrets required
sign.yml    — hashes (SHA-256 + BLAKE2b) and GPG-signs, triggered via
              workflow_run after build or manually with a build_run_id
release.yml — downloads artifacts, uploads to VirusTotal, publishes
              tagged GitHub Release with all 12 assets attached

All three chain automatically on push to main. Each can be re-run or
triggered independently against any historical run.

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 16:27:05 -04:00
nopeitsnothing 11f88859bf 2/8 refactor(pdf): wire dark mode through convert.py
Removes the dead Chromium dark mode path and BeautifulSoup CSS
injection code. Dark PDF is now produced by calling convert.py on the
finished light PDF. --both builds light then dark; --dark alone works
if the light PDF already exists.

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 16:27:05 -04:00
nopeitsnothing 68b2687b6b 1/8 feat(pdf): add pixel-based dark mode PDF converter
Replaces the broken --prefers-color-scheme=dark Chromium flag with a
pixel-level converter. Rasterizes pages via pdftoppm, remaps colors to
the hacker theme (#1f1f31 bg, #e0e0e0 text, #5e8bde links), and
reassembles with qpdf. Processes in batches of 50 pages to avoid OOM
on large documents like the 414-page guide.

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 16:27:04 -04:00
nopeitsnothing 3184181fa8 ci: refactor pipeline into independent build/sign/release/changelog workflows
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-22 16:20:57 -04:00
nopeitsnothing f3cb57230f Submit actual develop page
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-25 17:40:12 -04:00
nopeitsnothing 5e8057bb1f Fix copy information in website footer
And also move the develop workflow information to docs/code to cleanup
the guide documents, preventing accidental addition to the PDF.

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-25 17:37:09 -04:00
nopeitsnothing ac3d2ceb37 Fix some broken YAML references
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-24 15:54:30 -04:00
nopeitsnothing 5eded0af38 Delete stale information
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-24 00:50:53 -04:00
20 changed files with 1757 additions and 402 deletions
+164 -51
View File
@@ -1,25 +1,10 @@
name: 📖 Build & Sign PDFs
# DEPRECATED — replaced by build.yml, sign.yml, and release.yml
# This file is kept temporarily so in-flight runs are not broken.
#
# name: 📖 Build & Sign 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/**"
workflow_dispatch: # manual only — no automatic triggers (deprecated)
permissions:
contents: write
@@ -38,8 +23,13 @@ jobs:
with:
python-version: "3.13"
- name: 📦 Install MkDocs Material
run: pip install mkdocs-material
- name: 📦 Install Python dependencies
run: pip install mkdocs-material pillow numpy
- name: 🖼️ Install poppler (pdftoppm) and qpdf
run: |
sudo apt-get update
sudo apt-get install -y poppler-utils qpdf
- name: Setup Chrome
uses: browser-actions/setup-chrome@v2
@@ -49,21 +39,71 @@ jobs:
install-chromedriver: true
- name: 🔑 Install GPG tools
run: |
sudo apt-get update
sudo apt-get install gnupg
run: sudo apt-get install -y gnupg
- name: 🖨️ Build & Hash PDFs
# ------------------------------------------------------------------ #
# Build PDFs
# ------------------------------------------------------------------ #
- name: 🖨️ Build PDFs
env:
CI: true
run: |
python scripts/build_guide_pdf.py --${{ inputs.build_mode || 'both' }}
for f in ./export/*.pdf; do
echo "sha256sums: $f"; sha256sum "$f" >> export/sha256sums.txt; done
for f in ./export/*.pdf; do
echo "b2sums: $f"; b2sum "$f" >> export/b2sums.txt; done
run: python scripts/build_guide_pdf.py --${{ inputs.build_mode || 'both' }}
# ------------------------------------------------------------------ #
# Hash (SHA-256 + BLAKE2b)
# ------------------------------------------------------------------ #
- name: #️⃣ Hash PDFs
id: hashes
run: |
mkdir -p export
sha256sum export/thgtoa.pdf | awk '{print $1}' > export/thgtoa.pdf.sha256
sha256sum export/thgtoa-dark.pdf | awk '{print $1}' > export/thgtoa-dark.pdf.sha256
b2sum export/thgtoa.pdf | awk '{print $1}' > export/thgtoa.pdf.b2
b2sum export/thgtoa-dark.pdf | awk '{print $1}' > export/thgtoa-dark.pdf.b2
# Also write combined human-readable files
sha256sum export/thgtoa.pdf export/thgtoa-dark.pdf > export/sha256sums.txt
b2sum export/thgtoa.pdf export/thgtoa-dark.pdf > export/b2sums.txt
# Expose hashes as step outputs for the release body
echo "light_sha256=$(cat export/thgtoa.pdf.sha256)" >> $GITHUB_OUTPUT
echo "dark_sha256=$(cat export/thgtoa-dark.pdf.sha256)" >> $GITHUB_OUTPUT
echo "light_b2=$(cat export/thgtoa.pdf.b2)" >> $GITHUB_OUTPUT
echo "dark_b2=$(cat export/thgtoa-dark.pdf.b2)" >> $GITHUB_OUTPUT
# ------------------------------------------------------------------ #
# GPG sign (detached .sig for each PDF + each hash file)
# ------------------------------------------------------------------ #
- name: 🔏 Import GPG key
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
# Pre-cache the passphrase so signing doesn't prompt
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--pinentry-mode loopback --list-secret-keys
- name: 🔏 GPG sign PDFs and hash files
env:
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
sign() {
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--pinentry-mode loopback \
--detach-sign --armor --output "${1}.sig" "$1"
}
sign export/thgtoa.pdf
sign export/thgtoa-dark.pdf
sign export/sha256sums.txt
sign export/b2sums.txt
# ------------------------------------------------------------------ #
# VirusTotal
# ------------------------------------------------------------------ #
- name: 🦠 Upload PDFs to VirusTotal
id: vt
uses: crazy-max/ghaction-virustotal@v5
with:
vt_api_key: ${{ secrets.VT_API_KEY }}
@@ -71,35 +111,108 @@ jobs:
export/thgtoa.pdf
export/thgtoa-dark.pdf
- name: 📊 Extract VT scan results
id: vt-scan
- name: 🔗 Build VT report URLs
id: vt_urls
run: |
echo "status=completed" >> $GITHUB_OUTPUT
light_hash=$(cat export/thgtoa.pdf.sha256)
dark_hash=$(cat export/thgtoa-dark.pdf.sha256)
echo "light_vt=https://www.virustotal.com/gui/file/${light_hash}" >> $GITHUB_OUTPUT
echo "dark_vt=https://www.virustotal.com/gui/file/${dark_hash}" >> $GITHUB_OUTPUT
- name: 🔗 Generate VT report links
# ------------------------------------------------------------------ #
# Create GitHub Release
# ------------------------------------------------------------------ #
- name: 🏷️ Generate release tag
id: tag
run: |
# Create a markdown file with VT scan results and links
cat > export/virus-total-results.md << EOF
## VirusTotal Scan Results
TAG="release-$(date -u +'%Y%m%d-%H%M%S')"
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "name=Release $(date -u +'%Y-%m-%d %H:%M UTC')" >> $GITHUB_OUTPUT
**Scan Date:** \$(date -u +"%Y-%m-%d %H:%M UTC")
- name: 🚀 Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: ${{ steps.tag.outputs.name }}
body: |
## 📖 The Hitchhiker's Guide to Online Anonymity
### thgtoa.pdf (Light Mode)
- **VT Report:** https://www.virustotal.com/gui/file/\$(sha256sum export/thgtoa.pdf | cut -d' ' -f1)
Built from commit ${{ github.sha }} on `${{ github.ref_name }}`.
### thgtoa-dark.pdf (Dark Mode) (currently broken)
- **VT Report:** https://www.virustotal.com/gui/file/\$(sha256sum export/thgtoa-dark.pdf | cut -d' ' -f1)
---
---
*Scan performed automatically by GitHub Actions*
EOF
### 📄 Files
- name: 📤 Upload export directory as artifact
| File | Description |
|------|-------------|
| `thgtoa.pdf` | Light mode PDF |
| `thgtoa-dark.pdf` | Dark mode PDF (hacker theme) |
| `sha256sums.txt` | SHA-256 checksums |
| `b2sums.txt` | BLAKE2b checksums |
| `*.sig` | GPG detached signatures (ASCII armor) |
---
### #️⃣ Hashes
#### thgtoa.pdf (Light)
```
SHA-256: ${{ steps.hashes.outputs.light_sha256 }}
BLAKE2b: ${{ steps.hashes.outputs.light_b2 }}
```
#### thgtoa-dark.pdf (Dark)
```
SHA-256: ${{ steps.hashes.outputs.dark_sha256 }}
BLAKE2b: ${{ steps.hashes.outputs.dark_b2 }}
```
---
### 🔏 GPG Signatures
Detached signatures (`.sig`) are included in the release assets.
Verify with:
```bash
gpg --verify thgtoa.pdf.sig thgtoa.pdf
gpg --verify thgtoa-dark.pdf.sig thgtoa-dark.pdf
```
The signing key is published at `pgp/anonymousplanet-release.asc`.
---
### 🦠 VirusTotal Scans
| File | Report |
|------|--------|
| `thgtoa.pdf` | ${{ steps.vt_urls.outputs.light_vt }} |
| `thgtoa-dark.pdf` | ${{ steps.vt_urls.outputs.dark_vt }} |
files: |
export/thgtoa.pdf
export/thgtoa-dark.pdf
export/sha256sums.txt
export/b2sums.txt
export/thgtoa.pdf.sha256
export/thgtoa-dark.pdf.sha256
export/thgtoa.pdf.b2
export/thgtoa-dark.pdf.b2
export/thgtoa.pdf.sig
export/thgtoa-dark.pdf.sig
export/sha256sums.txt.sig
export/b2sums.txt.sig
draft: false
prerelease: false
fail_on_unmatched_files: true
# ------------------------------------------------------------------ #
# Upload everything as a workflow artifact (90-day archive)
# ------------------------------------------------------------------ #
- name: 📤 Upload export as workflow artifact
uses: actions/upload-artifact@v4
with:
name: export
path: |
export/*
name: pdf-release-${{ steps.tag.outputs.tag }}
path: export/*
if-no-files-found: error
retention-days: 90
compression-level: 0
+80
View File
@@ -0,0 +1,80 @@
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
+64
View File
@@ -0,0 +1,64 @@
name: 📝 Update Changelog
# Runs after build.yml completes on main — at that point we know what changed.
# Can also be triggered manually to backfill a missing entry.
on:
workflow_run:
workflows: ["📖 Build PDFs"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Version string (e.g. v1.2.4) — leave blank to auto-increment'
required: false
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
if: >
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
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
+215
View File
@@ -0,0 +1,215 @@
name: 🚀 Release
# Can be triggered:
# 1. Automatically after sign.yml completes on main
# 2. Manually, pointing at specific build/sign runs to pull artifacts from
on:
workflow_run:
workflows: ["🔏 Sign PDFs"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
sign_run_id:
description: 'sign.yml run ID to pull signatures from'
required: true
type: string
build_run_id:
description: 'build.yml run ID to pull PDFs from (leave blank to use pdfs-signed from sign run)'
required: false
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
if: >
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: 🛠️ Checkout (for commit metadata only)
uses: actions/checkout@v4
with:
sparse-checkout: pgp
# ------------------------------------------------------------------ #
# Resolve which run IDs to pull artifacts from
# ------------------------------------------------------------------ #
- name: 🔍 Resolve run IDs
id: runs
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
SIGN_RUN="${{ inputs.sign_run_id }}"
BUILD_RUN="${{ inputs.build_run_id }}"
else
SIGN_RUN="${{ github.event.workflow_run.id }}"
BUILD_RUN=""
fi
echo "sign_run=$SIGN_RUN" >> $GITHUB_OUTPUT
echo "build_run=$BUILD_RUN" >> $GITHUB_OUTPUT
echo "Sign run: $SIGN_RUN"
echo "Build run: ${BUILD_RUN:-'(using pdfs-signed from sign run)'}"
# ------------------------------------------------------------------ #
# Download artifacts
# ------------------------------------------------------------------ #
- name: 📥 Download signatures artifact
uses: actions/download-artifact@v4
with:
name: signatures
path: release/
run-id: ${{ steps.runs.outputs.sign_run }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: 📥 Download PDFs (from sign run)
uses: actions/download-artifact@v4
with:
name: pdfs-signed
path: release/
run-id: ${{ steps.runs.outputs.sign_run }}
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.b2)" >> $GITHUB_OUTPUT
echo "dark_b2=$(read_hash thgtoa-dark.pdf.b2)" >> $GITHUB_OUTPUT
# ------------------------------------------------------------------ #
# VirusTotal — upload whichever PDFs are present
# ------------------------------------------------------------------ #
- 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 "")
[ -n "$light_hash" ] && \
echo "light_vt=https://www.virustotal.com/gui/file/${light_hash}" >> $GITHUB_OUTPUT || \
echo "light_vt=(not built)" >> $GITHUB_OUTPUT
[ -n "$dark_hash" ] && \
echo "dark_vt=https://www.virustotal.com/gui/file/${dark_hash}" >> $GITHUB_OUTPUT || \
echo "dark_vt=(not built)" >> $GITHUB_OUTPUT
# ------------------------------------------------------------------ #
# Tag + Release
# ------------------------------------------------------------------ #
- name: 🏷️ Generate release tag
id: tag
run: |
TAG="v$(date -u +'%Y.%m.%d')-$(echo ${{ github.sha }} | cut -c1-7)"
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "name=Release $(date -u +'%Y-%m-%d') (${TAG})" >> $GITHUB_OUTPUT
- 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.b2` | BLAKE2b — light PDF |
| `thgtoa-dark.pdf.b2` | BLAKE2b — dark PDF |
| `*.sig` | GPG detached signatures (ASCII armor) |
---
### #️⃣ Hashes
**thgtoa.pdf** (light)
```
SHA-256 ${{ steps.hashes.outputs.light_sha256 }}
BLAKE2b ${{ steps.hashes.outputs.light_b2 }}
```
**thgtoa-dark.pdf** (dark)
```
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.sig thgtoa.pdf
gpg --verify thgtoa-dark.pdf.sig thgtoa-dark.pdf
# Verify hash files
gpg --verify sha256sums.txt.sig sha256sums.txt
gpg --verify b2sums.txt.sig 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.b2
release/thgtoa-dark.pdf.b2
release/thgtoa.pdf.sig
release/thgtoa-dark.pdf.sig
release/sha256sums.txt.sig
release/b2sums.txt.sig
+165
View File
@@ -0,0 +1,165 @@
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 (leave blank for latest)'
required: false
type: string
permissions:
actions: read # download artifacts from other runs
contents: read
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: 🔑 Install GPG
run: |
sudo apt-get update -qq
sudo apt-get install -y gnupg
# Download PDFs from the triggering build run, or a manually specified one
- name: 📥 Resolve source run ID
id: src
run: |
if [ -n "${{ inputs.build_run_id }}" ]; then
echo "run_id=${{ inputs.build_run_id }}" >> $GITHUB_OUTPUT
else
echo "run_id=${{ github.event.workflow_run.id }}" >> $GITHUB_OUTPUT
fi
- name: 📥 Download PDF artifacts
uses: actions/download-artifact@v4
with:
name: pdfs
path: export/
run-id: ${{ steps.src.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: 📋 List downloaded files
run: ls -lh export/
# ------------------------------------------------------------------ #
# Hash
# ------------------------------------------------------------------ #
- 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}.b2"
done
# Combined files (only include files that exist)
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
# Expose individual hashes as outputs (empty string if file absent)
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.b2 2>/dev/null || echo "")
dark_b2=$(cat thgtoa-dark.pdf.b2 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
# ------------------------------------------------------------------ #
- 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
# Pre-cache passphrase to prevent interactive prompt
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}.sig" "$file"
echo "Signed: $file"
}
sign export/thgtoa.pdf
sign export/thgtoa-dark.pdf
sign export/sha256sums.txt
sign export/b2sums.txt
# ------------------------------------------------------------------ #
# Upload — PDFs + all signatures and hashes together
# ------------------------------------------------------------------ #
- 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.b2
export/thgtoa-dark.pdf.b2
export/thgtoa.pdf.sig
export/thgtoa-dark.pdf.sig
export/sha256sums.txt.sig
export/b2sums.txt.sig
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
+29 -27
View File
@@ -1,27 +1,29 @@
# Visual Studio (Windows) solution metadata
.vs/
.vscode/
# Python (MkDocs, scripts/build_guide_pdf.py)
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
env/
ENV/
.env
# Cache
.cache/
# MkDocs build output and local PDF export
site/
_site/
_site_test/
build/
# Export directory - but track hash files and signatures
export/thgtoa.pdf.sha256
export/thgtoa-dark.pdf.sha256
*.sig
# Visual Studio (Windows) solution metadata
.vs/
.vscode/
# Python (MkDocs, scripts/build_guide_pdf.py)
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
env/
ENV/
.env
# Cache
.cache/
# MkDocs build output and local PDF export
site/
_site/
_site_test/
build/
# Export directory - but track hash files and signatures
export/thgtoa.pdf.sha256
export/thgtoa-dark.pdf.sha256
export/thgtoa.pdf.b2
export/thgtoa-dark.pdf.b2
*.sig
+1 -3
View File
@@ -1,8 +1,6 @@
Welcome.
**[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 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.
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.
+2 -2
View File
@@ -1,5 +1,5 @@
---
title: "About Anonymous Planet"
title: "Anonymous Planet"
description: We are the maintainers of the Hitchhiker's Guide and the PSA Matrix space.
schema:
"@context": https://schema.org
@@ -7,7 +7,7 @@ schema:
"@id": https://www.anonymousplanet.org/
name: Anonymous Planet
url: https://www.anonymousplanet.org/about/
logo: ../media/favicon.png
logo: ../media/profile.png
sameAs:
- https://github.com/Anon-Planet
- https://opencollective.com/anonymousplanetorg
+75 -68
View File
@@ -1,68 +1,75 @@
---
title: "TBA"
description: ""
schema:
"@context": https://schema.org
"@type": Organization
"@id": https://www.anonymousplanet.org/
name: Anonymous Planet
url: https://www.anonymousplanet.org/authors/
logo: ../media/favicon.png
sameAs:
- https://github.com/Anon-Planet
- https://opencollective.com/anonymousplanetorg
- https://mastodon.social/@anonymousplanet
---
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
### Added
- This changelog page
- Add ways to verify the files
### Changed
- Refactored GitHub Actions workflow **Build PDF** (`scripts\build_guide_pdf.py`): now builds both light and dark mode PDFs (`export/thgtoa.pdf` and `export/thgtoa-dark.pdf` respectively).
- Restored previous VT scans
## Fixed
- `docs/about/index.md`: replace broken reference-style internal links
- `docs/guide/index.md`: Appendix A6: comment out deprecated ODT information because we don't and probably won't use it in the future
### Feature
- Updated `scripts/build_guide_pdf.py` to use `--print-to-pdf` instead of `--save-as` for PDF generation, and added a new `--dark-mode` flag to generate dark mode PDFs. The script now supports generating both light and dark mode PDFs with a single command invocation by using the `--both` flag. This change improves the PDF generation process and provides better support for dark mode users. Save your eyes - you only get one pair.
## [1.2.1] - 2026-04-11
### Added
- GitHub Actions workflow **Build 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 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
***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).***
---
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] — 2026-05-22
CI/CD pipeline split into independent stages, dark PDF quality improved, and the changelog is now updated automatically on every release. v1.2.2 was just a placeholder, this is a minor but CI breaking change.
!!! 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.
- **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.
- `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 here 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 testing.
!!! warning "Changed"
- `build-sign-release.yml` is now deprecated — push triggers removed, manual dispatch only. Will be deleted once in-flight runs complete.
- The full pipeline (build → sign → release) 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.
!!! bug "Fixed"
- Broken internal links and a mismatched cross-reference in `docs/about/index.md`.
- Deprecated ODT section commented out in Appendix A6 of the guide.
---
## [v1.2.1] — 2025
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
@@ -8,11 +8,11 @@
**VirusTotal Scanning:** - Automatically scans PDFs and updates release notes
**Release Automation:** - Packages everything into GitHub releases
## Workflow Architecture
## Architecture
### Build PDF Workflow (`build-sign-release.yml`)
??? Note "Steps"
!!! Note "Steps"
- Checkout repository
- Set up Python and MkDocs Material
@@ -34,7 +34,7 @@
**Purpose:** Verify authenticity and prevent tampering
??? Note "How it works"
!!! Note "How it works"
- Detached signatures created for each PDF and hash file
- Public keys available in `/pgp/` directory
+3 -3
View File
@@ -7,15 +7,15 @@ schema:
"@id": https://www.anonymousplanet.org/
name: Anonymous Planet
url: https://www.anonymousplanet.org/guide/
logo: ../media/favicon.ico
logo: ../media/profile.png
sameAs:
- https://github.com/Anon-Planet
- https://opencollective.com/anonymousplanetorg
---
<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__subtitle"><em>(Or "How I learned to start worrying and love privacy anonymity")</em></p>
<p class="pdf-title-page__meta">Version 1.2.1, April 2026 by Anonymous Planet</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__meta">v1.2.3, May 2026 by Anonymous Planet</p>
</div>
<div class="guide-intro-lead" markdown="1">
![Anonymous Planet logo](../media/profile.png)
+1 -1
View File
@@ -7,7 +7,7 @@ schema:
"@id": https://www.anonymousplanet.org/
name: Anonymous Planet
url: https://www.anonymousplanet.org/authors/
logo: ../media/favicon.png
logo: ../media/profile.png
sameAs:
- https://github.com/Anon-Planet
- https://opencollective.com/anonymousplanetorg
+2 -2
View File
@@ -7,7 +7,7 @@ schema:
"@id": https://www.anonymousplanet.org/
name: Anonymous Planet
url: https://www.anonymousplanet.org/mirrors/
logo: ../media/favicon.png
logo: ../media/profile.png
sameAs:
- https://github.com/Anon-Planet
- https://opencollective.com/anonymousplanetorg
@@ -27,7 +27,7 @@ schema:
!!! 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-pdf.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-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**).
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).
+9 -5
View File
@@ -9,6 +9,7 @@ repo_name: ""
#edit_uri: ""
theme:
name: material
locale: en
favicon: media/profile.png
icon:
logo: material/bird
@@ -17,6 +18,8 @@ theme:
text: Public Sans
code: Liberation Mono
features:
- navigation.instant
- navigation.instant.prefetch
- navigation.tabs
- navigation.sections
- toc.integrate
@@ -122,18 +125,19 @@ markdown_extensions:
toc_depth: 3
nav:
- Home: index.md
- Welcome: index.md
- About: about/index.md
- Verify: verify/index.md
- Guide:
- guide/index.md
- Workflow Documentation: guide/dev-workflow.md
- Code: code/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
- TBA: changelog/index.md
- Releases: changelog/index.md
copyright: |
&copy; 2023-2026 <a href="https://anonymousplanet.org/" target="_blank" rel="noopener">Anonymous Planet</a>
<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>
-132
View File
@@ -1,132 +0,0 @@
# PDF Build, Scan & Release Scripts
This directory contains scripts for building PDFs from MkDocs documentation, scanning them with VirusTotal, generating hashes, and uploading artifacts to GitHub releases.
## Scripts
### `pdf_release.sh` (v2 - Recommended)
The main script that handles:
- SHA256 hash generation for PDF files
- VirusTotal scanning of PDFs
- Release creation/update on GitHub
- GPG signature verification support
**Usage:**
```bash
./scripts/pdf_release.sh --build <light|dark|both> --release <tag|latest> [--vt-api-key VT_KEY] [--github-token TOKEN]
```
**Options:**
- `--build`: PDF build mode (`light`, `dark`, or `both`) - Required
- `--release`: Release update mode (`tag` for tagged releases, `latest` to always update) - Default: `tag`
- `--vt-api-key`: VirusTotal API key (optional)
- `--github-token`: GitHub token for release operations (optional)
### `build_guide_pdf.py`
Python script that builds MkDocs documentation and converts it to PDF using Chromium/Chrome.
**Usage:**
```bash
python scripts/build_guide_pdf.py --both # Build both light and dark mode
python scripts/build_guide_pdf.py --dark-mode # Dark mode only
python scripts/build_guide_pdf.py --skip-mkdocs # Skip MkDocs build, use existing site
```
## GitHub Actions Workflow
The workflow `.github/workflows/build-pdf-combined.yml` combines all operations:
1. **Build PDFs** - Generates light/dark mode PDFs with GPG signatures
2. **Scan & Release** - Scans with VirusTotal and updates/releases artifacts
### Required Secrets
Add these to your repository settings under **Settings > Secrets and variables > Actions**:
- `GPG_PRIVATE_KEY`: Your GPG private key for signing
- `GPG_PASSPHRASE`: Passphrase for the GPG key (if any)
- `VT_API_KEY`: VirusTotal API key for malware scanning
- `GITHUB_TOKEN`: Automatically available, but can be manually added
### Workflow Triggers
The workflow runs on:
- Manual dispatch (`workflow_dispatch`) with customizable options
- Push to main branch when docs, mkdocs.yml, or scripts change
## Output Files
After running the build and release process, you'll get:
```
export/
├── thgtoa.pdf # Light mode PDF
├── thgtoa-dark.pdf # Dark mode PDF
├── thgtoa.pdf.sig # GPG signature for light PDF
├── thgtoa-dark.pdf.sig # GPG signature for dark PDF
├── thgtoa.pdf.sha256 # SHA256 hash for light PDF
├── thgtoa-dark.pdf.sha256 # SHA256 hash for dark PDF
├── sha256sum-combined.txt # Combined hash file
├── sha256sum-combined.txt.sig # GPG signature for combined hashes
└── virus-total-results.md # VirusTotal scan results
```
## Hash Verification
To verify the integrity of downloaded PDFs:
```bash
# Verify against individual hash file
sha256sum -c thgtoa.pdf.sha256
# Or verify against combined hash file
sha256sum -c sha256sum-combined.txt
```
## VirusTotal Integration
When a `VT_API_KEY` is provided, the script will:
1. Upload each PDF to VirusTotal's API
2. Generate individual scan reports
3. Include VT report links in release notes and artifacts
The VT results file (`virus-total-results.md`) contains:
- Scan timestamp
- SHA256 hashes for each PDF
- Direct links to VirusTotal GUI reports
## Release Management
The script supports two release modes:
1. **Tag mode** (`--release tag`): Updates the release matching the current git tag
2. **Latest mode** (`--release latest`): Always updates the most recent release (useful for continuous deployment)
When running in a GitHub Actions workflow with a tag push, it will automatically create or update the corresponding release.
## Troubleshooting
### PDF Build Fails
- Ensure Chrome/Chromium is installed: `sudo apt install chromium-browser`
- Check MkDocs configuration is valid: `mkdocs build --strict`
- Verify all documentation files are present and properly formatted
### VirusTotal Scan Fails
- Check VT_API_KEY secret is correctly set in repository settings
- Verify the API key has sufficient quota (free tier allows 4 requests/minute)
- Check network connectivity to VirusTotal API
### Release Upload Fails
- Ensure GITHUB_TOKEN has appropriate permissions (repo scope)
- For existing releases, use `--release latest` instead of `tag`
- Check that the release tag format matches GitHub's requirements (e.g., `v1.0.0`)
## Security Notes
- **GPG Keys**: Never commit private keys to version control. Use GitHub Secrets.
- **VT API Key**: Keep your VirusTotal API key secret and rotate periodically.
- **Release Artifacts**: All uploaded artifacts are publicly visible on your releases page.
## License
These scripts are part of the "The How-To Guide To Anonymity" project and follow the same licensing as the main repository.
+66 -96
View File
@@ -1,16 +1,14 @@
#!/usr/bin/env python3
"""Experimental dark mode support.
This script builds both light and dark mode MkDocs site, then renders docs/guide/ to single PDFs via Chromium.
"""Build light-mode PDF with MkDocs + Chromium, then produce dark-mode PDF via convert.py.
Usage:
python scripts/build_guide_pdf.py # Generate light mode PDF only
python scripts/build_guide_pdf.py --dark-mode # Generate dark mode PDF only
python scripts/build_guide_pdf.py --both # Generate both light and dark mode PDFs
python scripts/build_guide_pdf.py # Light PDF only
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:
python scripts/build_guide_pdf.py --site-dir build/html --pdf-light export/thgtoa.pdf
python scripts/build_guide_pdf.py --dark-mode --pdf-dark export/thgtoa-dark.pdf
python scripts/build_guide_pdf.py --both --pdf-light export/thgtoa.pdf --pdf-dark export/thgtoa-dark.pdf
"""
from __future__ import annotations
@@ -68,15 +66,11 @@ def run_mkdocs(site_dir: Path) -> None:
)
def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path, dark_mode: bool = False) -> Path:
"""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.
def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path:
"""Render html_file to pdf_out via headless Chromium.
Args:
browser: Path to Chromium executable
html_file: Path to HTML file to convert
pdf_out: Output PDF path
dark_mode: If True, use dark mode color scheme via --prefers-color-scheme flag
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)
partial = pdf_out.parent / f".{pdf_out.name}.writing"
@@ -84,32 +78,23 @@ def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path, dark_mode: bool
uri = html_file.resolve().as_uri()
# Chromium headless print; allow time for fonts/images on very large pages.
cmd = [str(browser)]
if os.environ.get("CI"):
# GitHub Actions / other CI runners often need these for Chromium to start.
cmd += [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
]
cmd += [
"--headless=new",
"--disable-gpu",
"--no-pdf-header-footer",
]
# Add dark mode preference if requested
if dark_mode:
cmd.append("--prefers-color-scheme=dark")
cmd += [
f"--print-to-pdf={partial.resolve()}",
uri,
]
subprocess.run(cmd, check=True, timeout=600)
deadline = time.time() + 120
while time.time() < deadline:
if partial.exists() and partial.stat().st_size > 0:
@@ -131,42 +116,23 @@ def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path, dark_mode: bool
partial.replace(pdf_out)
return pdf_out
def generate_dark_mode_html(html_file: Path, output_file: Path, dark_css_path: Path) -> None:
"""Create a temporary HTML file with dark mode stylesheet applied.
This is used when we need to force dark mode rendering via CSS rather than browser flags.
"""
try:
from bs4 import BeautifulSoup
# Read the original HTML
html_content = html_file.read_text(encoding='utf-8')
soup = BeautifulSoup(html_content, 'html.parser')
# Add dark mode stylesheet link if not present
existing_links = [link.get('href', '') for link in soup.find_all('link', rel='stylesheet')]
if not any(dark_css_path.name in link for link in existing_links):
head = soup.head or soup.new_tag('head')
link_tag = soup.new_tag('link', rel='stylesheet', href=str(dark_css_path))
if soup.head:
soup.head.append(link_tag)
else:
# Create a new head section
new_head = soup.new_tag('head')
new_head.append(link_tag)
soup.insert(0, new_head)
# Write the modified HTML
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(str(soup), encoding='utf-8')
except ImportError:
print("BeautifulSoup not available. Skipping CSS injection.")
# 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:
root = repo_root()
ap = argparse.ArgumentParser(description="Build MkDocs + single-page guide PDF (light and/or dark mode).")
ap = argparse.ArgumentParser(
description="Build MkDocs + single-page guide PDF (light and/or dark mode)."
)
ap.add_argument(
"--site-dir",
type=Path,
@@ -177,68 +143,72 @@ def main() -> int:
"--pdf-light",
type=Path,
default=root / "export" / "thgtoa.pdf",
help="Output PDF path for light mode (default: ./export/thgtoa.pdf)",
help="Output path for light PDF (default: ./export/thgtoa.pdf)",
)
ap.add_argument(
"--pdf-dark",
type=Path,
default=root / "export" / "thgtoa-dark.pdf",
help="Output PDF path for dark mode (default: ./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; only run print-to-pdf.")
ap.add_argument("--dark-mode", action="store_true", help="Generate dark mode PDF only")
ap.add_argument("--both", action="store_true", help="Generate both light and dark mode PDFs")
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()
# Determine which modes to generate
if args.dark_mode:
modes = ["dark"]
elif args.both:
modes = ["light", "dark"]
else:
modes = ["light"]
build_light = not args.dark
build_dark = args.dark or args.both
guide_html = args.site_dir / "guide" / "index.html"
# --- Light PDF (Chromium) ---
if build_light:
guide_html = args.site_dir / "guide" / "index.html"
if not args.skip_mkdocs or any(mode == "light" for mode in modes):
run_mkdocs(args.site_dir)
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
if not guide_html.is_file():
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
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
dark_css_path = root / "docs" / "stylesheets" / "dark-extra.css"
# Generate light mode PDF (default)
if "light" in modes:
out_light = print_to_pdf(browser, guide_html, args.pdf_light, dark_mode=False)
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.",
f"Note: {args.pdf_light.name} was in use; "
"close it and rename or replace with the file above.",
file=sys.stderr,
)
# Generate dark mode PDF
if "dark" in modes:
out_dark = print_to_pdf(browser, guide_html, args.pdf_dark, dark_mode=True)
size_kb = out_dark.stat().st_size // 1024
print(f"Wrote {out_dark.resolve()} ({size_kb} KiB) [Dark Mode]")
if out_dark.resolve() != args.pdf_dark.resolve():
# --- Dark PDF (pixel converter) ---
if build_dark:
if not args.pdf_light.exists():
print(
f"Note: {args.pdf_dark.name} was in use; close it and rename or replace with the file above.",
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]")
return 0
+243
View File
@@ -0,0 +1,243 @@
#!/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 using PNG compression via qpdf.
Pillow's built-in PDF writer defaults to JPEG encoding for RGB images,
which fails when libjpeg is not available in the environment. Instead we
write each page as a lossless PNG to a temp directory and assemble them
with qpdf, which embeds the PNGs directly without re-encoding.
"""
import tempfile as _tempfile
with _tempfile.TemporaryDirectory() as staging:
png_paths = []
for i, img in enumerate(images):
p = os.path.join(staging, f'p{i:05d}.png')
img.save(p, format='PNG')
png_paths.append(p)
subprocess.run(
['qpdf', '--empty', '--pages'] + png_paths + ['--', output_path],
check=True,
)
def _check_qpdf() -> bool:
return subprocess.run(
['qpdf', '--version'], capture_output=True
).returncode == 0
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)
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())
+12 -9
View File
@@ -18,17 +18,21 @@ What it does:
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 files 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:
@@ -42,7 +46,8 @@ def check_gpg_installed() -> bool:
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:
@@ -82,7 +87,6 @@ def list_gpg_keys() -> list[dict]:
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:
@@ -103,7 +107,7 @@ def export_public_key(key_id: str, output_file: Path | None = None) -> str | Non
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:
@@ -120,12 +124,12 @@ def export_private_key(key_id: str, output_file: Path | None = None) -> str | No
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:
@@ -147,7 +151,7 @@ def validate_gpg_key(key_id: str) -> bool:
except subprocess.CalledProcessError:
return False
# Print instructions for configuring GitHub Secrets
def print_setup_instructions():
"""Print instructions for configuring GitHub Secrets."""
print("\n" + "="*70)
@@ -177,7 +181,6 @@ TROUBLESHOOTING:
- If VT scan fails: Ensure API key is valid and within rate limits
""")
def main() -> int:
print("\n" + "="*70)
print("PDF WORKFLOW SETUP HELPER")
+407
View File
@@ -0,0 +1,407 @@
#!/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 = "9FA5436D0EE360985157382517ECA05F768DEDF6"
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())
+216
View File
@@ -0,0 +1,216 @@
#!/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 tag, 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 (all optional — reasonable defaults apply):
MANUAL_VERSION Override the auto-incremented version string.
TRIGGERING_SHA The commit SHA that triggered this run (used as range end).
DRY_RUN If "true", print the entry and exit without writing.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
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 latest_version_tag() -> str | None:
"""Return the most recent vX.Y.Z tag reachable from HEAD, or None."""
out = run(["git", "tag", "--sort=-version:refname", "--list", "v*"])
for line in out.splitlines():
if re.match(r"^v\d+\.\d+\.\d+", line):
return line
return None
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."""
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)."""
if ref:
log_range = f"{ref}..{until}"
else:
log_range = 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}
for msg in messages:
# Skip automated / noise commits
if re.search(r"\[skip ci\]|^Merge |^chore: bump|update changelog", msg, re.I):
continue
# Strip conventional-commit prefix to get the plain description
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"
# Capitalise first letter
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(f'!!! 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 no commits were categorised, add a placeholder
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")
# Find the first ## heading and insert before it
insert_at = content.find("\n## ")
if insert_at == -1:
# No existing version section — append after the header block
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"
# Determine version
last_tag = latest_version_tag()
last_cl_ver = version_from_changelog()
base_version = last_tag or last_cl_ver
new_version = manual_version or auto_increment_version(base_version)
print(f"Last tag: {last_tag or '(none)'}")
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
# Collect commits since the last tag (or all commits if no tag)
messages = commits_since(last_tag, 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())