30 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
nopeitsnothing 1bb0acc3e8 Sign local copy
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:50:31 -04:00
nopeitsnothing 25bc901ece Tweaking some of the build to function
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:43:52 -04:00
nopeitsnothing 78a0a37ee8 Tweaking some of the build to function
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:38:27 -04:00
nopeitsnothing aeb63cd7ba Tweaking some of the build to function
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:34:47 -04:00
nopeitsnothing 64ddd18535 Tweaking some of the build to function
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:29:54 -04:00
nopeitsnothing 7c9847e7d1 Tweaking some of the build to function
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:23:15 -04:00
nopeitsnothing 1e8c90513f Tweaking some of the build to function
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:21:00 -04:00
nopeitsnothing 2d09d7c01c Tweaking some of the build to function pt6
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:15:19 -04:00
nopeitsnothing 1938e031ee Tweaking some of the build to function pt5
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:13:28 -04:00
nopeitsnothing 8483d6336b Tweaking some of the build to function pt4
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:08:28 -04:00
nopeitsnothing 1c168691c5 Tweaking some of the build to function pt3
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 03:04:39 -04:00
nopeitsnothing ae50911375 Tweaking some of the build to function pt2
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 02:58:56 -04:00
nopeitsnothing df2dd61676 Tweaking some of the build to function
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 02:50:05 -04:00
nopeitsnothing 904fa24478 Moving some things around
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-04-20 02:45:06 -04:00
36 changed files with 1889 additions and 899 deletions
+160 -88
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,98 +39,180 @@ 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
# ------------------------------------------------------------------ #
# Build PDFs
# ------------------------------------------------------------------ #
- name: 🖨️ Build PDFs
env:
CI: true
run: python scripts/build_guide_pdf.py --${{ inputs.build_mode || 'both' }}
- name: 🔒 Sign SHA256 hash file with GPG
# ------------------------------------------------------------------ #
# 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_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
cd ${{ github.workspace }}
# Import GPG key
export GPG_TTY=$(tty)
echo "$GPG_KEY" | gpg --batch --import 2>/dev/null || true
- name: 🔒 Sign PDF files with GPG
env:
GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
cd ${{ github.workspace }}
# Import GPG key if not already imported
export GPG_TTY=$(tty)
echo "$GPG_KEY" | gpg --batch --import 2>/dev/null || true
# Create combined hash file with all PDFs
sha256sum export/thgtoa.pdf > export/checksums.sha256
sha256sum export/thgtoa-dark.pdf >> export/checksums.sha256
# Sign the checksum file
gpg --batch --yes --armor --detach-sign --output export/checksums.sha256.sig export/checksums.sha256 2>/dev/null || true
# Sign each PDF file individually with detached signature
for pdf_file in export/*.pdf; do
if [ -f "$pdf_file" ]; then
base_name=$(basename "$pdf_file")
echo "Signing $base_name..."
gpg --default-key 17ECA05F768DEDF6 --batch --yes --armor --detach-sign --output "export/${pdf_file}.sig" "$pdf_file" 2>/dev/null || true
fi
done
# Verify signatures were created
ls -la export/*.sig 2>/dev/null || echo "No signature files found in export/"
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 }}
files: |
./export/thgtoa.pdf
./export/thgtoa-dark.pdf
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)
- **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: pdf-export-${{ inputs.build_mode || 'both' }}
path: |
export/*.pdf
export/*.sig
export/*.sha256
export/virus-total-results.md
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
+2
View File
@@ -24,4 +24,6 @@ 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
-51
View File
@@ -1,51 +0,0 @@
# 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]
### Added
- 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 workflow **VirusTotal Scan** (`.github/workflows/vt-scan.yml`): submit files to VT for malware scanning. Links will be published on the site.
## 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
+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
View File
@@ -0,0 +1,75 @@
---
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
+50
View File
@@ -0,0 +1,50 @@
# Development
??? Note "How the pipeline works"
**Automatic PDF Generation:** - Builds both light and dark mode PDFs from MkDocs source
**SHA256 Hash Generation:** - Creates hash files for integrity verification
**GPG Signature Signing:** - Signs all PDFs and hash files with repository GPG key
**VirusTotal Scanning:** - Automatically scans PDFs and updates release notes
**Release Automation:** - Packages everything into GitHub releases
## Architecture
### Build PDF Workflow (`build-sign-release.yml`)
!!! Note "Steps"
- Checkout repository
- Set up Python and MkDocs Material
- Install Chromium browser
- Generate both light and dark mode PDFs with `scripts\build_guide_pdf.py`
- Create SHA256 and blake2 hash files in `export/`
- Sign all files with GPG in `export/`
- Upload artifacts to GitHub Actions **manually**
### SHA256 Hash Verification
!!! Note "**How it works**"
- Each PDF gets a unique SHA256 hash calculated at build time
- Hash stored in `.sha256` files alongside the PDFs
- Combined `sha256sum.txt` for batch verification
### GPG Signature Verification
**Purpose:** Verify authenticity and prevent tampering
!!! Note "How it works"
- Detached signatures created for each PDF and hash file
- Public keys available in `/pgp/` directory
**Verification command:**
```bash
gpg --import pgp/anonymousplanet-master.asc
gpg --verify export/thgtoa.pdf.sig export/thgtoa.pdf
```
---
*This workflow is designed for security-conscious users who need to verify the authenticity and integrity of downloaded documents.*
-193
View File
@@ -1,193 +0,0 @@
# Development
## Overview
This repository now includes an automated workflow that handles PDF generation, verification, and distribution with the following features:
??? Note "How the pipeline works"
1. **Automatic PDF Generation** - Builds both light and dark mode PDFs from MkDocs source
2. **SHA256 Hash Generation** - Creates hash files for integrity verification
3. **GPG Signature Signing** - Signs all PDFs and hash files with repository GPG key
4. **VirusTotal Scanning** - Automatically scans PDFs and updates release notes
5. **Release Automation** - Packages everything into GitHub releases
## Workflow Architecture
### 1. Build PDF Workflow (`build-pdf.yml`)
**Trigger:** Push to main, pull requests, or manual dispatch
??? Note "Steps"
- Checkout repository
- Set up Python 3.13 and MkDocs Material
- Install Chromium browser
- Generate both light and dark mode PDFs
- Create SHA256 hash files
- Sign all files with GPG
- Upload artifacts to GitHub Actions
- Publish release
### 2. VirusTotal Scan Workflow (`vt-scan.yml`)
**Trigger:** Push to main, tags, or manual dispatch (runs after build-pdf)
??? Note "Steps"
- Download PDF artifacts from build workflow
- Scan both PDFs with VirusTotal API
- Extract scan results and generate report links
- Update release notes with VT scan status and URLs
## File Structure
After a successful build, the repository will contain:
```
.../
├── export/
│ ├── thgtoa.pdf # Light mode PDF
│ ├── thgtoa-dark.pdf # Dark mode PDF
│ ├── thgtoa.pdf.sig # GPG signature (light)
│ └── thgtoa-dark.pdf.sig # GPG signature (dark)
├── thgtoa.pdf.sha256 # Hash file (light)
├── thgtoa-dark.pdf.sha256 # Hash file (dark)
├── sha256sum-light.txt # Combined hash file
└── scripts/
├── build_guide_pdf.py # PDF generation script
└── verify_pdf.py # Verification utility
```
## Security Features
### 1. SHA256 Hash Verification
**Purpose:** Ensure file integrity during download/transit
**How it works:**
- Each PDF gets a unique SHA256 hash calculated at build time
- Hash stored in `.sha256` files alongside the PDFs
- Combined `sha256sum-light.txt` for batch verification
**Verification command:**
```bash
sha256sum -c sha256sum-light.txt
```
### 2. GPG Signature Verification
**Purpose:** Verify authenticity and prevent tampering
??? Note "How it works"
- Detached signatures created for each PDF and hash file
- Public keys available in `/pgp/` directory
**Verification command:**
```bash
gpg --import pgp/anonymousplanet-master.asc
gpg --verify export/thgtoa.pdf.sig export/thgtoa.pdf
```
### 3. VirusTotal Integration
**Purpose:** Malware detection and security scanning
??? Note "How it works"
- Automatic scan of all generated PDFs
- Results published in release notes with direct links
- Provides third-party validation of file safety
## Usage Examples
### Local Development
```bash
# Build PDFs locally
python scripts/build_guide_pdf.py --both
# Verify hashes
python scripts/verify_pdf.py --hashes
# Verify signatures (requires GPG installed)
python scripts/verify_pdf.py --signatures
# Full verification with VirusTotal check
export VT_API_KEY=your_api_key
python scripts/verify_pdf.py --all
```
### CI/CD Verification
The workflows automatically verify everything during the build process. To manually trigger:
1. Go to Actions tab
2. Select "Build guide PDF" or "VirusTotal Scan"
3. Click "Run workflow"
4. Download artifacts from successful run
## Release Process
When you create a tag (e.g., `v1.0.0`):
1. Push the tag: `git push origin v1.0.0`
2. Build PDF workflow triggers automatically
3. VirusTotal scan workflow runs after build completes
4. Both workflows update/create GitHub release with:
- Light and dark mode PDFs
- GPG signatures for all files
- Hash files for verification
- Release notes with VT scan results
## Troubleshooting
### Common Issues
**GPG signing fails:**
- Check that `GPG_PRIVATE_KEY` is in ASCII armor format
- Verify passphrase is correct
- Ensure key has signing capability
**Hash mismatch after download:**
- Re-download the file (corruption during transfer)
- Verify you're using the correct hash file
- Check disk integrity
**VirusTotal scan fails:**
- Verify `VT_API_KEY` is set correctly
- Check API quota limits (free tier: 4 requests/minute)
- Ensure PDF files exist before scanning
### Debug Mode
Enable verbose output by adding to workflow:
```yaml
- name: Debug
run: |
echo "Current directory:" && pwd
echo "Files in export:" && ls -la export/
echo "Hash file contents:" && cat sha256sum-light.txt
```
## Best Practices
1. **Always verify signatures** before opening PDFs from untrusted sources
2. **Check VirusTotal results** for any suspicious detections
3. **Keep GPG keys secure** - never commit private keys to repository
4. **Monitor API usage** for VirusTotal to avoid rate limiting
5. **Test locally** before pushing tags to production
## Future Enhancements
Potential improvements:
- Multi-signature support (multiple maintainers)
- Automated changelog generation with hashes
- Cross-platform signature verification scripts
- Integration with additional malware scanners
- Automatic mirror updates with verified files
---
*This workflow is designed for security-conscious users who need to verify the authenticity and integrity of downloaded documents.*
+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)
+4 -5
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
@@ -18,13 +18,12 @@ schema:
**9FA5 436D 0EE3 6098 5157 3825 17EC A05F 768D EDF6**
This is the master signing key fingerprint for Anonymous Planet.
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.
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.
![Anonymous Planet logo](media/profile.png){ align=right }
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.
Anonymous Planet is a collective of volunteers.
??? person "Das Kolburn"
+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).
+7 -4
View File
@@ -62,9 +62,12 @@ gpg --verify export/thgtoa-dark.pdf.sig export/thgtoa-dark.pdf
Expected output for successful verification:
```
gpg: Signature made [date]
gpg: using RSA key [key-id]
gpg: Good signature from "[owner]"
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
@@ -114,7 +117,7 @@ The GitHub Actions workflows automatically:
## Key Information
**Signing Key:** Anonymous Planet Master Key
**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
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7fY6QAD/YCGJqs9HiRllFrF9EluE
Ga4XUEQ/R6Q2zc+X6lX856sBAJIpxeMxUmMUXyr3xBAHxUf5eV+nQYkQQMKI81L1
x8gL
=VX6l
-----END PGP SIGNATURE-----
+1
View File
@@ -0,0 +1 @@
f212d0425b38d5cd10da6dc804b60f143da23d4b07051aae31d0966082519b300af0e1c423683e0223738b33b138c687232b1c8bd68cf643777bbc5b588152bd ./export/thgtoa-dark.pdf
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7fbdDgEAoSslLR47ydW/3r1wJOPY
X/waLkVbkGZpHqwd4RjywwcA/3B7Ci+jUg+yP5TRsuChagEhwyO5vw2DxSlUGoB4
+ksH
=2ja9
-----END PGP SIGNATURE-----
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7faErgD/Svj1G+B7gmrZQ6AsLZ5J
HfeldxjmrXE99dig1iHtl5IBAMndZZb+95TO03IZ9eLGfYuyTz4GCUanmftsY9yv
LAIN
=MEd0
-----END PGP SIGNATURE-----
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7favvgEAvFFSB5NrsrKMYvGG5ZYB
iLIyt8Sn1rZmlVkibssMPq0BAImpZe8S7hWNkbukyEC4sLbKiOYvjbVipQHnrIUV
xPMH
=0hnj
-----END PGP SIGNATURE-----
+1
View File
@@ -0,0 +1 @@
436ed0df78c299f95b8d5ff94f43f26ec2e7825d92d843fc15419630d55ed5e0c98485e738c12715a2b6242633faae38e8a98935b361d44ddde97a1692cb01a1 ./export/thgtoa.pdf
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7fatsgEAixDzH+zTnKYMEx3sikWp
dsNTiHTU6wJY/brVJIU879UBAJntBIq72vqwKtMb/ZlVvomdDvKVllZw8ZsYBz1n
aTkM
=vkgy
-----END PGP SIGNATURE-----
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7faAGQEAyEhVKrRoXIsV3E5f1FZg
8fcsmbxCnKBqxichCkf0dWYBAIvbI146mQLHaNqLDaTIqCUQbkq1aE/YMFDGykUG
ngsJ
=/0RY
-----END PGP SIGNATURE-----
+16
View File
@@ -0,0 +1,16 @@
## 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*
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQSfpUNtDuNgmFFXOCUX7KBfdo3t9gUCaeXaqxsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMiwyLDIACgkQF+ygX3aN7fYpCgEA209U3QewChp7mdrrFjH1
CaBMIk2sCHwRMCcmbMDkNTAA/RIchAKex13ZjZWC9xsJpZEktvBENFsQLsNPReqR
UZ8C
=TYsa
-----END PGP SIGNATURE-----
+9 -4
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,17 +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
- 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>
+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/guide.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/guide-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())
-365
View File
@@ -1,365 +0,0 @@
!/bin/bash
set -e
# PDF Hashing, Scanning, Release and Management script (hSCRAM)
# Usage: ./pdf_release.sh --build <light|dark|both> --release <tag|latest> [--vt-api-key]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
EXPORT_DIR="$ROOT_DIR/export"
# Default values
MODE="both" # light, dark, or both
RELEASE_MODE="tag" # tag (e.g. "v2.1.2") or "latest"
VT_API_KEY=""
GITHUB_TOKEN=""
while [[ $# -gt 0 ]]; do
case $1 in
--build)
MODE="$2"
shift 2
;;
--release)
RELEASE_MODE="$2"
shift 2
;;
--vt-api-key)
VT_API_KEY="$2"
shift 2
;;
--github-token)
GITHUB_TOKEN="$2"
shift 2
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
if [[ ! "$MODE" =~ ^(light|dark|both)$ ]]; then
echo "Error: Invalid build mode '$MODE'. Must be 'light', 'dark', or 'both'."
exit 1
fi
echo "Hashing, Scanning, Release and Management script (hSCRAM)"
echo "Mode: $MODE"
echo "Release Mode: $RELEASE_MODE"
generate_hash() {
local file="$1"
sha256sum "$file" | cut -d' ' -f1
}
create_hash_file() {
local pdf_path="$1"
local base_name=$(basename "$pdf_path")
local hash_file="${EXPORT_DIR}/${base_name}.sha256"
(cd "$EXPORT_DIR" && sha256sum "$base_name") > "$hash_file"
echo "Created: $hash_file"
}
scan_with_virustotal() {
local pdf_path="$1"
local base_name=$(basename "$pdf_path")
if [[ -z "$VT_API_KEY" ]]; then
echo "Warning: VT_API_KEY not provided, skipping VirusTotal scan for $base_name"
return 1
fi
echo "Scanning $base_name with VirusTotal..."
local upload_response=$(curl -s -X POST \
-H "x-apikey: $VT_API_KEY" \
-F "file=@$pdf_path" \
https://www.virustotal.com/api/v3/files)
if [[ $? -ne 0 ]]; then
echo "Error uploading $base_name to VirusTotal"
return 1
fi
local file_id=$(echo "$upload_response" | python3 -c "import sys, json; print(json.load(sys.stdin)['data']['id'])" 2>/dev/null || echo "")
if [[ -z "$file_id" ]]; then
echo "Error: Could not extract file ID from VirusTotal response for $base_name"
echo "Response: $upload_response"
return 1
fi
local vt_url="https://www.virustotal.com/gui/file/$file_id"
echo "$vt_url"
}
scan_all_pdfs() {
local results_file="$EXPORT_DIR/virus-total-results.md"
cat > "$results_file" << 'HEADER'
## VirusTotal Scan Results
**Scan Date:** TIMESTAMP
---
HEADER
sed -i "s/TIMESTAMP/$(date -u +"%Y-%m-%d %H:%M UTC")/" "$results_file"
local pdf_files=()
if [[ "$MODE" == "light" || "$MODE" == "both" ]]; then
pdf_files+=("$EXPORT_DIR/thgtoa.pdf")
fi
if [[ "$MODE" == "dark" || "$MODE" == "both" ]]; then
pdf_files+=("$EXPORT_DIR/thgtoa-dark.pdf")
fi
for pdf in "${pdf_files[@]}"; do
if [[ -f "$pdf" ]]; then
local base_name=$(basename "$pdf")
local hash=$(generate_hash "$pdf")
echo "" >> "$results_file"
echo "### $base_name" >> "$results_file"
echo "- **SHA256 Hash:** \`$hash\`" >> "$results_file"
if [[ -n "$VT_API_KEY" ]]; then
local vt_url=$(scan_with_virustotal "$pdf")
if [[ $? -eq 0 && -n "$vt_url" ]]; then
echo "- **VirusTotal Report:** [$vt_url]($vt_url)" >> "$results_file"
else
echo "- **VirusTotal Report:** Scan failed or API key not provided" >> "$results_file"
fi
else
echo "- **VirusTotal Report:** VT_API_KEY not configured, scan skipped" >> "$results_file"
fi
create_hash_file "$pdf"
else
echo "Warning: $pdf does not exist, skipping..."
fi
done
cat >> "$results_file" << 'FOOTER'
---
*Scan performed automatically by GitHub Actions*
FOOTER
echo "VirusTotal results saved to: $results_file"
}
update_release() {
local tag="${1:-}"
local release_notes="$EXPORT_DIR/release-notes.md"
if [[ "$RELEASE_MODE" == "tag" && -z "$tag" ]]; then
tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
fi
if [[ -z "$tag" ]]; then
echo "Warning: No release tag found, skipping release update."
return 1
fi
echo "Updating release for tag: $tag"
cat > "$release_notes" << EOF
# Release Notes - $tag
**Release Date:** $(date -u +"%Y-%m-%d %H:%M UTC")
## PDF Files
EOF
if [[ "$MODE" == "light" || "$MODE" == "both" ]]; then
local hash=$(generate_hash "$EXPORT_DIR/thgtoa.pdf")
echo "- **thgtoa.pdf (Light Mode)**" >> "$release_notes"
echo " - SHA256: \`$hash\`" >> "$release_notes"
if [[ -f "$EXPORT_DIR/thgtoa.pdf.sig" ]]; then
echo " - Signature: \`thgtoa.pdf.sig\` (GPG signed)" >> "$release_notes"
fi
fi
if [[ "$MODE" == "dark" || "$MODE" == "both" ]]; then
local hash=$(generate_hash "$EXPORT_DIR/thgtoa-dark.pdf")
echo "- **thgtoa-dark.pdf (Dark Mode)**" >> "$release_notes"
echo " - SHA256: \`$hash\`" >> "$release_notes"
if [[ -f "$EXPORT_DIR/thgtoa-dark.pdf.sig" ]]; then
echo " - Signature: \`thgtoa-dark.pdf.sig\` (GPG signed)" >> "$release_notes"
fi
fi
echo "" >> "$release_notes"
echo "---" >> "$release_notes"
if [[ -f "$EXPORT_DIR/virus-total-results.md" ]]; then
echo "## VirusTotal Scan Results" >> "$release_notes"
echo "" >> "$release_notes"
cat "$EXPORT_DIR/virus-total-results.md" >> "$release_notes"
echo "" >> "$release_notes"
fi
local files_to_upload=""
if [[ -f "$EXPORT_DIR/thgtoa.pdf" ]]; then
files_to_upload+="$EXPORT_DIR/thgtoa.pdf "
fi
if [[ -f "$EXPORT_DIR/thgtoa-dark.pdf" ]]; then
files_to_upload+="$EXPORT_DIR/thgtoa-dark.pdf "
fi
if [[ -f "$EXPORT_DIR/thgtoa.pdf.sig" ]]; then
files_to_upload+="$EXPORT_DIR/thgtoa.pdf.sig "
fi
if [[ -f "$EXPORT_DIR/thgtoa-dark.pdf.sig" ]]; then
files_to_upload+="$EXPORT_DIR/thgtoa-dark.pdf.sig "
fi
local combined_hash_file="$EXPORT_DIR/sha256sum-combined.txt"
if [[ -f "$EXPORT_DIR/thgtoa.pdf.sha256" ]]; then
cat "$EXPORT_DIR/thgtoa.pdf.sha256" >> "$combined_hash_file" 2>/dev/null || true
fi
if [[ -f "$EXPORT_DIR/thgtoa-dark.pdf.sha256" ]]; then
echo "" >> "$combined_hash_file"
cat "$EXPORT_DIR/thgtoa-dark.pdf.sha256" >> "$combined_hash_file"
fi
files_to_upload+="$combined_hash_file "
if [[ -n "${GPG_PRIVATE_KEY:-}" && -n "${GPG_PASSPHRASE:-}" ]]; then
echo "$GPG_PRIVATE_KEY" | gpg --batch --import 2>/dev/null || true
gpg --batch --yes --armor --detach-sign --output "$combined_hash_file.sig" "$combined_hash_file" 2>/dev/null || true
if [[ -f "$combined_hash_file.sig" ]]; then
files_to_upload+="$combined_hash_file.sig "
fi
fi
if command -v gh &> /dev/null && [[ -n "$GITHUB_TOKEN" ]]; then
echo "Uploading release with GitHub CLI..."
local release_exists=$(gh release view "$tag" 2>/dev/null && echo "yes" || echo "no")
if [[ "$release_exists" == "yes" ]]; then
gh release edit "$tag" --notes-file "$release_notes" 2>/dev/null || {
echo "Warning: Failed to update release notes"
}
for file in $files_to_upload; do
if [[ -f "$file" ]]; then
local file_name=$(basename "$file")
gh release upload "$tag" "$file" 2>/dev/null || {
echo "Warning: Failed to upload $file_name"
}
fi
done
else
gh release create "$tag" \
--title "Release $tag" \
--notes-file "$release_notes" \
$files_to_upload 2>/dev/null || {
echo "Error: Failed to create release"
return 1
}
fi
else
if [[ -n "$GITHUB_TOKEN" && -n "$tag" ]]; then
echo "Using GitHub API to upload release..."
local repo="${GITHUB_REPOSITORY:-}"
local api_url="https://api.github.com/repos/$repo/releases"
local existing_release=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
"$api_url/tags/$tag")
if [[ $(echo "$existing_release" | grep -c '"id":') -gt 0 ]]; then
echo "Release already exists, updating..."
local release_id=$(echo "$existing_release" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])" 2>/dev/null || echo "")
curl -X PATCH \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"$api_url/$release_id" \
-d "{\"body\":\"$(cat "$release_notes")\"}" 2>/dev/null || true
for file in $files_to_upload; do
if [[ -f "$file" ]]; then
local file_name=$(basename "$file")
local mime_type=$(file --mime-type -b "$file")
curl -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: $mime_type" \
--data-binary @"$file" \
"https://uploads.github.com/repos/$repo/releases/$release_id/assets?name=$file_name" 2>/dev/null || {
echo "Warning: Failed to upload $file_name"
}
fi
done
else
echo "Creating new release..."
local create_response=$(curl -s -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"$api_url" \
-d "{\"tag_name\":\"$tag\",\"name\":\"Release $tag\",\"body\":\"$(cat "$release_notes")\"}")
local release_id=$(echo "$create_response" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])" 2>/dev/null || echo "")
for file in $files_to_upload; do
if [[ -f "$file" ]]; then
local file_name=$(basename "$file")
local mime_type=$(file --mime-type -b "$file")
curl -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: $mime_type" \
--data-binary @"$file" \
"https://uploads.github.com/repos/$repo/releases/$release_id/assets?name=$file_name" 2>/dev/null || {
echo "Warning: Failed to upload $file_name"
}
fi
done
fi
else
echo "Error: GITHUB_TOKEN required for release upload."
return 1
fi
fi
echo "Release update complete!"
}
echo ""
echo "Step 1: Generating hashes..."
if [[ "$MODE" == "light" || "$MODE" == "both" ]]; then
if [[ -f "$EXPORT_DIR/thgtoa.pdf" ]]; then
create_hash_file "$EXPORT_DIR/thgtoa.pdf"
else
echo "Warning: $EXPORT_DIR/thgtoa.pdf not found. Ensure PDF is built first."
fi
fi
if [[ "$MODE" == "dark" || "$MODE" == "both" ]]; then
if [[ -f "$EXPORT_DIR/thgtoa-dark.pdf" ]]; then
create_hash_file "$EXPORT_DIR/thgtoa-dark.pdf"
else
echo "Warning: $EXPORT_DIR/thgtoa-dark.pdf not found. Ensure PDF is built first."
fi
fi
echo ""
echo "Step 2: Scanning with VirusTotal..."
scan_all_pdfs
echo ""
echo "Step 3: Updating release..."
update_release "$GITHUB_REF_NAME"
echo ""
echo "PDF Release Script Complete!"
+13 -44
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)
@@ -169,47 +173,14 @@ GitHub repository:
3. VT_API_KEY (optional but recommended)
- VirusTotal API key for malware scanning
- Get a free key at: https://www.virustotal.com/gui/join-us
HOW TO ADD SECRETS:
1. Go to your repository on GitHub
2. Click 'Settings''Secrets and variables''Actions'
3. Click 'New repository secret' for each secret below:
Secret Name | Value Format
---------------------|--------------------------------------------------
GPG_PRIVATE_KEY | Paste the entire ASCII armored key (BEGIN PGP...)
GPG_PASSPHRASE | Your key's passphrase (no special characters issues)
VT_API_KEY | Your VirusTotal API key
VERIFYING YOUR SETUP:
After adding secrets, you can test by:
1. Going to 'Actions' tab
2. Selecting 'Build guide PDF' workflow
3. Clicking 'Run workflow'
4. Checking if the workflow completes successfully
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
SECURITY NOTES:
⚠ NEVER share your private key or passphrase publicly
⚠ Always use repository secrets, never hardcode in scripts
⚠ Rotate keys periodically if compromised
⚠ Use strong passphrases (12+ characters recommended)
""")
def main() -> int:
print("\n" + "="*70)
print("PDF WORKFLOW SETUP HELPER")
@@ -220,8 +191,6 @@ def main() -> int:
print("⚠ WARNING: GPG is not installed or not in PATH")
print("Please install GPG before continuing:")
print(" - Linux: sudo apt install gnupg")
print(" - macOS: brew install gnupg")
print(" - Windows: https://www.gpg4win.org/")
print("\nContinuing anyway...")
# List available keys
@@ -313,7 +282,7 @@ To get your private key for the GPG_PRIVATE_KEY secret:
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/pdf-workflow.md\n")
print("\nFor more information, see: docs/guide/dev-workflow.md\n")
return 0
+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())
+4 -12
View File
@@ -26,11 +26,9 @@ 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()
@@ -39,7 +37,6 @@ def calculate_sha256(file_path: Path) -> str:
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)
@@ -50,7 +47,6 @@ def verify_hash(file_path: Path, expected_hash: str) -> bool:
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():
@@ -81,7 +77,6 @@ def verify_signature(file_path: Path, sig_file: Path) -> bool:
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():
@@ -102,7 +97,6 @@ def verify_from_hash_file(file_path: Path, hash_file: Path) -> bool:
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:
@@ -127,9 +121,9 @@ def check_virustotal(file_hash: str, api_key: str | None = None) -> dict | None:
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)}")
print(f" Suspicious: {stats.get('suspicious', 0)}")
print(f" Undetected: {stats.get('undetected', 0)}")
print(f" Clean: {stats.get('harmless', 0)}")
return data
@@ -137,7 +131,6 @@ def check_virustotal(file_hash: str, api_key: str | None = None) -> dict | None:
print(f"⚠ ERROR checking VirusTotal: {e}")
return None
def main() -> int:
root = repo_root()
ap = argparse.ArgumentParser(description="Verify PDF files (hashes, signatures, VT).")
@@ -158,7 +151,7 @@ def main() -> int:
ap.add_argument(
"--hash-file",
type=Path,
default=root / "sha256sum-light.txt",
default=root / "export" / "thgtoa.pdf.sha256",
help="Hash file to verify against",
)
@@ -217,6 +210,5 @@ def main() -> int:
print("✗ Some verifications FAILED")
return 1
if __name__ == "__main__":
raise SystemExit(main())