20 Commits

Author SHA1 Message Date
nopeitsnothing d1817e9049 ci(pipeline): more meta changes to the pipeline
pre-commit install --install-hooks
2026-05-27 23:49:19 -04:00
nopeitsnothing ede2a53437 ci(pipeline): replace semver tagging with timestamp tags, drop tag_release.py
- release.yml now generates release-YYYYMMDD-<sha> tags automatically
- changelog.yml requires explicit version input, no auto-increment from tags
- sign.yml normalises extensions to .asc and .b2sum
- build-sign-release.yml neutered to a no-op with descriptive error
- tag_release.py archived to scripts/archived/
- update_changelog.py: version_from_changelog() is now primary version source
- .gitignore: fix export/ tracking to match actual file extensions
- docs/code/develop.md: fully rewritten to reflect new manual four-step flow
2026-05-27 23:49:19 -04:00
github-actions[bot] 91a77ed552 chore(export): update PDFs, hashes and signatures [skip ci] 2026-05-26 00:14:20 +00:00
nopeitsnothing 1c3cf75cf0 ci(github): Manual only
automatic triggering is disabled to prevent version mismatches
2026-05-25 19:34:04 -04:00
github-actions[bot] 121be79cd8 docs: update changelog [skip ci] 2026-05-24 12:03:20 +00:00
nopeitsnothing 3b550119a8 chore(lint): commitizen passes
Passed a couple times through the automatic linter to fix some markdown

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-24 07:58:11 -04:00
nopeitsnothing c19389ce49 change(changelog): v1.2.3
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-24 07:57:59 -04:00
nopeitsnothing aabcbac3d9 fix(develop): we use the Anonymous Planet RSK for releases
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-24 01:16:29 -04:00
nopeitsnothing e11a1eb1ce fix(release): sign using RSK instead
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-24 01:05:31 -04:00
nopeitsnothing df6cfbc94b ci(release): auto-increment using [vX.X.X]
Keep it clean, simple, only include the semver tag:

LATEST=$(git tag --list 'v*' --sort=-version:refname \
  | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-24 00:35:59 -04:00
github-actions[bot] 095bb0d8be docs: update changelog [skip ci] 2026-05-24 04:12:03 +00:00
nopeitsnothing 8b81081089 change(changelog): only use "vX.X.X" in version tags
Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-24 00:10:06 -04:00
nopeitsnothing ccc97461c9 add(changelog): explain missing v1.2.2 tag
v1.2.2 contained broken Python and other additions that were not meant for release

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-24 00:07:25 -04:00
github-actions[bot] 8d74635d49 docs: update changelog [skip ci] 2026-05-24 03:59:47 +00:00
nopeitsnothing f71e5e2a28 fix(changelog): prevent history dump and filter noise commits
commits_since(): when no prior tag exists, scope to commits not yet on
origin/main via merge-base instead of walking the entire history. This
is what caused the v2.0.1 entry to contain every commit back to project
inception.

categorise(): replace the minimal skip pattern with a compiled NOISE
regex that also drops:
  - numbered series commits (3/8, 7/8, etc.)
  - vague WIP messages (Tweaking, Moving some, Still broken, pt2...)
  - one-word infrastructure fixes (Fix workflow, Fix path, Fix README)
  - oops commits (Forgot to, Revert "...")
  - joke messages (One job to rule them all)

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-23 23:55:34 -04:00
nopeitsnothing 3e28ec19ad fix(convert): actually save per-page PDFs for qpdf, not PNGs
We ignore this for the guide

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-23 23:21:14 -04:00
github-actions[bot] 192da89138 docs: update changelog [skip ci] 2026-05-24 03:05:09 +00:00
nopeitsnothing c658c354ee fix(convert): actually save per-page PDFs for qpdf, not PNGs
Previous filesystem edits to _save_images_as_pdf did not persist to
disk. Rewrote the function: quantize each dark-themed RGB image to
palette mode (256 colours, FASTOCTREE) so Pillow uses zlib/deflate
instead of JPEG (no libjpeg needed), save each as a single-page PDF,
then merge with qpdf. qpdf only accepts PDF inputs to --pages.

Also restores the orphaned footnote citations [^536] and [^537] in
docs/guide/index.md at the key disclosure law paragraph (line 8586).
Previous edit also did not persist to disk.

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-23 22:57:26 -04:00
nopeitsnothing 343ad7f037 fix(convert): fail fast with helpful message if pdftoppm or qpdf missing
Previously the script crashed with a FileNotFoundError traceback when
system tools were absent. Now _check_dependencies() runs before any
work begins and prints install instructions for Linux/WSL, macOS, and
a pointer to develop.md for Windows.

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-23 22:53:28 -04:00
nopeitsnothing 85ea1fee66 docs(develop): rewrite developer guide for current pipeline
Replaces the thin stub describing the old monolithic workflow with a
full developer reference covering:

- Prerequisites (Linux/macOS/Windows tabs)
- Repository layout
- Local build instructions for both PDFs and the MkDocs site
- Pipeline flow diagram (build → sign → release → changelog)
- What to check before pushing
- Every GitHub Secret: what it is, how to generate it, what breaks
  without it, and a summary table
- Step-by-step release process using tag_release.py
- Release verification instructions (GPG + hash checks)
- Troubleshooting section for every known CI failure mode

Signed-off-by: nopeitsnothing <no@anonymousplanet.org>
2026-05-23 22:48:13 -04:00
22 changed files with 739 additions and 465 deletions
+9
View File
@@ -0,0 +1,9 @@
[tool.commitizen]
name = "cz_conventional_commits"
# enforce sign-off below as well
extra_arguments = ["-S", "--signoff"]
# harmless redundancy
[tool.commitizen.customize]
schema_pattern = '^(feat|add|fix|bugfix|revert|security|perf|refactor|change|chore|ci|docs|style|test|build)(\(.+\))?(!)?: .{1,72}(\n.*)*\nSigned-off-by: .+ <.+@.+>'
+17 -210
View File
@@ -1,218 +1,25 @@
name: 🗑️ DEPRECATED — Build & Sign & Release (combined)
# DEPRECATED — replaced by build.yml, sign.yml, and release.yml # DEPRECATED — replaced by build.yml, sign.yml, and release.yml
# This file is kept temporarily so in-flight runs are not broken. # This workflow is disabled. It is kept only as a reference until the
# # split workflows have been confirmed stable in production.
# name: 📖 Build & Sign PDFs # Do not trigger this workflow.
on: on:
workflow_dispatch: # manual only — no automatic triggers (deprecated) workflow_dispatch:
inputs:
permissions: _disabled:
contents: write description: 'This workflow is deprecated. Use build.yml → sign.yml → release.yml instead.'
id-token: write required: false
type: string
jobs: jobs:
build-sign-release: noop:
name: Build, Sign & Release PDFs name: Deprecated — no-op
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 🛠️ Checkout - name: ❌ Workflow is deprecated
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 pillow numpy
- name: 🖼️ Install poppler (pdftoppm) and qpdf
run: | run: |
sudo apt-get update echo "This workflow is deprecated."
sudo apt-get install -y poppler-utils qpdf echo "Use build.yml → sign.yml → release.yml instead."
exit 1
- name: Setup Chrome
uses: browser-actions/setup-chrome@v2
with:
chrome-version: 120
install-dependencies: true
install-chromedriver: true
- name: 🔑 Install GPG tools
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' }}
# ------------------------------------------------------------------ #
# Hash (SHA-256 + BLAKE2b)
# ------------------------------------------------------------------ #
- name: #️⃣ Hash PDFs
id: hashes
run: |
mkdir -p export
sha256sum export/thgtoa.pdf | awk '{print $1}' > export/thgtoa.pdf.sha256
sha256sum export/thgtoa-dark.pdf | awk '{print $1}' > export/thgtoa-dark.pdf.sha256
b2sum export/thgtoa.pdf | awk '{print $1}' > export/thgtoa.pdf.b2
b2sum export/thgtoa-dark.pdf | awk '{print $1}' > export/thgtoa-dark.pdf.b2
# Also write combined human-readable files
sha256sum export/thgtoa.pdf export/thgtoa-dark.pdf > export/sha256sums.txt
b2sum export/thgtoa.pdf export/thgtoa-dark.pdf > export/b2sums.txt
# Expose hashes as step outputs for the release body
echo "light_sha256=$(cat export/thgtoa.pdf.sha256)" >> $GITHUB_OUTPUT
echo "dark_sha256=$(cat export/thgtoa-dark.pdf.sha256)" >> $GITHUB_OUTPUT
echo "light_b2=$(cat export/thgtoa.pdf.b2)" >> $GITHUB_OUTPUT
echo "dark_b2=$(cat export/thgtoa-dark.pdf.b2)" >> $GITHUB_OUTPUT
# ------------------------------------------------------------------ #
# GPG sign (detached .sig for each PDF + each hash file)
# ------------------------------------------------------------------ #
- name: 🔏 Import GPG key
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
# Pre-cache the passphrase so signing doesn't prompt
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--pinentry-mode loopback --list-secret-keys
- name: 🔏 GPG sign PDFs and hash files
env:
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
sign() {
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--pinentry-mode loopback \
--detach-sign --armor --output "${1}.sig" "$1"
}
sign export/thgtoa.pdf
sign export/thgtoa-dark.pdf
sign export/sha256sums.txt
sign export/b2sums.txt
# ------------------------------------------------------------------ #
# VirusTotal
# ------------------------------------------------------------------ #
- name: 🦠 Upload PDFs to VirusTotal
id: vt
uses: crazy-max/ghaction-virustotal@v5
with:
vt_api_key: ${{ secrets.VT_API_KEY }}
files: |
export/thgtoa.pdf
export/thgtoa-dark.pdf
- name: 🔗 Build VT report URLs
id: vt_urls
run: |
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
# ------------------------------------------------------------------ #
# Create GitHub Release
# ------------------------------------------------------------------ #
- name: 🏷️ Generate release tag
id: tag
run: |
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
- 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
Built from commit ${{ github.sha }} on `${{ github.ref_name }}`.
---
### 📄 Files
| 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-release-${{ steps.tag.outputs.tag }}
path: export/*
if-no-files-found: error
retention-days: 90
compression-level: 0
+5
View File
@@ -1,3 +1,8 @@
# 1. Push to main → build.yml runs automatically → note the run ID
# 2. Manually trigger sign.yml with that build run ID → note the sign run ID
# 3. Manually trigger release.yml with: version=v1.2.5, sign_run_id=<id>
# 4. Manually trigger changelog.yml with: version=v1.2.5
name: 📖 Build PDFs name: 📖 Build PDFs
on: on:
+6 -12
View File
@@ -1,17 +1,14 @@
name: 📝 Update Changelog name: 📝 Update Changelog
# Runs after build.yml completes on main — at that point we know what changed. # Manual only — run after a release is published. Provide the exact version
# Can also be triggered manually to backfill a missing entry. # string (e.g. v1.2.4) to prepend to the changelog. Version is required to
# prevent silent auto-increment drift from release tags.
on: on:
workflow_run:
workflows: ["📖 Build PDFs"]
types: [completed]
branches: [main]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: 'Version string (e.g. v1.2.4) — leave blank to auto-increment' description: 'Version string to record (e.g. v1.2.4) — required'
required: false required: true
type: string type: string
dry_run: dry_run:
description: 'Dry run — print entry without committing' description: 'Dry run — print entry without committing'
@@ -25,9 +22,6 @@ permissions:
jobs: jobs:
changelog: changelog:
name: Prepend changelog entry name: Prepend changelog entry
if: >
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -46,7 +40,7 @@ jobs:
- name: 📝 Generate and prepend changelog entry - name: 📝 Generate and prepend changelog entry
env: env:
DRY_RUN: ${{ inputs.dry_run || 'false' }} DRY_RUN: ${{ inputs.dry_run || 'false' }}
MANUAL_VERSION: ${{ inputs.version || '' }} MANUAL_VERSION: ${{ inputs.version }}
GH_SHA: ${{ github.sha }} GH_SHA: ${{ github.sha }}
GH_REF: ${{ github.ref_name }} GH_REF: ${{ github.ref_name }}
TRIGGERING_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} TRIGGERING_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
+48 -65
View File
@@ -1,23 +1,16 @@
name: 🚀 Release name: 🚀 Release
# Can be triggered: # Manual only — run this deliberately after build and sign are confirmed good.
# 1. Automatically after sign.yml completes on main # Provide the sign.yml run ID to pull artifacts from. The release tag is
# 2. Manually, pointing at specific build/sign runs to pull artifacts from # generated automatically as release-YYYYMMDD-<short-sha> — no version input
# needed, no semver drift possible.
on: on:
workflow_run:
workflows: ["🔏 Sign PDFs"]
types: [completed]
branches: [main]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
sign_run_id: sign_run_id:
description: 'sign.yml run ID to pull signatures from' description: 'sign.yml run ID to pull signatures and PDFs from'
required: true required: true
type: string 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: prerelease:
description: 'Mark as pre-release?' description: 'Mark as pre-release?'
required: false required: false
@@ -31,52 +24,32 @@ permissions:
jobs: jobs:
release: release:
name: Publish GitHub Release name: Publish GitHub Release
if: >
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 🛠️ Checkout (for commit metadata only) - name: 🛠️ Checkout (for pgp/)
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0
sparse-checkout: pgp sparse-checkout: pgp
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Resolve which run IDs to pull artifacts from # Download artifacts from the specified sign run
# ------------------------------------------------------------------ #
- 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 - name: 📥 Download signatures artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: signatures name: signatures
path: release/ path: release/
run-id: ${{ steps.runs.outputs.sign_run }} run-id: ${{ inputs.sign_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: 📥 Download PDFs (from sign run) - name: 📥 Download signed PDFs artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: pdfs-signed name: pdfs-signed
path: release/ path: release/
run-id: ${{ steps.runs.outputs.sign_run }} run-id: ${{ inputs.sign_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: 📋 List release assets - name: 📋 List release assets
@@ -85,18 +58,17 @@ jobs:
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Read hashes for the release body # Read hashes for the release body
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
- name: #️⃣ Read hashes - name: "#️⃣ Read hashes"
id: hashes id: hashes
run: | run: |
read_hash() { cat "release/$1" 2>/dev/null || echo "(not built)"; } read_hash() { cat "release/$1" 2>/dev/null || echo "(not built)"; }
echo "light_sha256=$(read_hash thgtoa.pdf.sha256)" >> $GITHUB_OUTPUT echo "light_sha256=$(read_hash thgtoa.pdf.sha256)" >> $GITHUB_OUTPUT
echo "dark_sha256=$(read_hash thgtoa-dark.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 "light_b2=$(read_hash thgtoa.pdf.b2sum)" >> $GITHUB_OUTPUT
echo "dark_b2=$(read_hash thgtoa-dark.pdf.b2)" >> $GITHUB_OUTPUT echo "dark_b2=$(read_hash thgtoa-dark.pdf.b2sum)" >> $GITHUB_OUTPUT
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# VirusTotal — upload whichever PDFs are present # VirusTotal
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
- name: 🦠 Upload PDFs to VirusTotal - name: 🦠 Upload PDFs to VirusTotal
id: vt id: vt
@@ -112,23 +84,34 @@ jobs:
run: | run: |
light_hash=$(cat release/thgtoa.pdf.sha256 2>/dev/null || echo "") light_hash=$(cat release/thgtoa.pdf.sha256 2>/dev/null || echo "")
dark_hash=$(cat release/thgtoa-dark.pdf.sha256 2>/dev/null || echo "") dark_hash=$(cat release/thgtoa-dark.pdf.sha256 2>/dev/null || echo "")
[ -n "$light_hash" ] && \ if [ -n "$light_hash" ]; then
echo "light_vt=https://www.virustotal.com/gui/file/${light_hash}" >> $GITHUB_OUTPUT || \ echo "light_vt=https://www.virustotal.com/gui/file/${light_hash}" >> $GITHUB_OUTPUT
else
echo "light_vt=(not built)" >> $GITHUB_OUTPUT echo "light_vt=(not built)" >> $GITHUB_OUTPUT
[ -n "$dark_hash" ] && \ fi
echo "dark_vt=https://www.virustotal.com/gui/file/${dark_hash}" >> $GITHUB_OUTPUT || \ if [ -n "$dark_hash" ]; then
echo "dark_vt=https://www.virustotal.com/gui/file/${dark_hash}" >> $GITHUB_OUTPUT
else
echo "dark_vt=(not built)" >> $GITHUB_OUTPUT echo "dark_vt=(not built)" >> $GITHUB_OUTPUT
fi
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Tag + Release # Generate release tag — timestamp + short SHA, always unique
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
- name: 🏷️ Generate release tag - name: 🏷️ Generate release tag
id: tag id: tag
run: | run: |
TAG="v$(date -u +'%Y.%m.%d')-$(echo ${{ github.sha }} | cut -c1-7)" SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
DATE=$(date -u +'%Y%m%d')
TAG="release-${DATE}-${SHORT_SHA}"
NAME="Release ${DATE} (${SHORT_SHA})"
echo "tag=$TAG" >> $GITHUB_OUTPUT echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "name=Release $(date -u +'%Y-%m-%d') (${TAG})" >> $GITHUB_OUTPUT echo "name=$NAME" >> $GITHUB_OUTPUT
echo "Tag: $TAG"
# ------------------------------------------------------------------ #
# Create GitHub Release
# ------------------------------------------------------------------ #
- name: 🚀 Create GitHub Release - name: 🚀 Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@@ -154,22 +137,22 @@ jobs:
| `b2sums.txt` | BLAKE2b checksums (both files) | | `b2sums.txt` | BLAKE2b checksums (both files) |
| `thgtoa.pdf.sha256` | SHA-256 — light PDF | | `thgtoa.pdf.sha256` | SHA-256 — light PDF |
| `thgtoa-dark.pdf.sha256` | SHA-256 — dark PDF | | `thgtoa-dark.pdf.sha256` | SHA-256 — dark PDF |
| `thgtoa.pdf.b2` | BLAKE2b — light PDF | | `thgtoa.pdf.b2sum` | BLAKE2b — light PDF |
| `thgtoa-dark.pdf.b2` | BLAKE2b — dark PDF | | `thgtoa-dark.pdf.b2sum` | BLAKE2b — dark PDF |
| `*.sig` | GPG detached signatures (ASCII armor) | | `*.asc` | GPG detached signatures (ASCII armor) |
--- ---
### #️⃣ Hashes ### #️⃣ Hashes
**thgtoa.pdf** (light) **thgtoa.pdf** (light)
``` ```text
SHA-256 ${{ steps.hashes.outputs.light_sha256 }} SHA-256 ${{ steps.hashes.outputs.light_sha256 }}
BLAKE2b ${{ steps.hashes.outputs.light_b2 }} BLAKE2b ${{ steps.hashes.outputs.light_b2 }}
``` ```
**thgtoa-dark.pdf** (dark) **thgtoa-dark.pdf** (dark)
``` ```text
SHA-256 ${{ steps.hashes.outputs.dark_sha256 }} SHA-256 ${{ steps.hashes.outputs.dark_sha256 }}
BLAKE2b ${{ steps.hashes.outputs.dark_b2 }} BLAKE2b ${{ steps.hashes.outputs.dark_b2 }}
``` ```
@@ -183,12 +166,12 @@ jobs:
gpg --import pgp/anonymousplanet-release.asc gpg --import pgp/anonymousplanet-release.asc
# Verify PDFs # Verify PDFs
gpg --verify thgtoa.pdf.sig thgtoa.pdf gpg --verify thgtoa.pdf.asc thgtoa.pdf
gpg --verify thgtoa-dark.pdf.sig thgtoa-dark.pdf gpg --verify thgtoa-dark.pdf.asc thgtoa-dark.pdf
# Verify hash files # Verify hash files
gpg --verify sha256sums.txt.sig sha256sums.txt gpg --verify sha256sums.txt.asc sha256sums.txt
gpg --verify b2sums.txt.sig b2sums.txt gpg --verify b2sums.txt.asc b2sums.txt
``` ```
--- ---
@@ -207,9 +190,9 @@ jobs:
release/b2sums.txt release/b2sums.txt
release/thgtoa.pdf.sha256 release/thgtoa.pdf.sha256
release/thgtoa-dark.pdf.sha256 release/thgtoa-dark.pdf.sha256
release/thgtoa.pdf.b2 release/thgtoa.pdf.b2sum
release/thgtoa-dark.pdf.b2 release/thgtoa-dark.pdf.b2sum
release/thgtoa.pdf.sig release/thgtoa.pdf.asc
release/thgtoa-dark.pdf.sig release/thgtoa-dark.pdf.asc
release/sha256sums.txt.sig release/sha256sums.txt.asc
release/b2sums.txt.sig release/b2sums.txt.asc
+67 -44
View File
@@ -4,28 +4,28 @@ name: 🔏 Sign PDFs
# 1. Automatically after build.yml completes on main # 1. Automatically after build.yml completes on main
# 2. Manually, pointing at a specific build run to pull PDFs from # 2. Manually, pointing at a specific build run to pull PDFs from
on: on:
workflow_run: # workflow_run:
workflows: ["📖 Build PDFs"] # workflows: ["📖 Build PDFs"]
types: [completed] # types: [completed]
branches: [main] # branches: [main]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
build_run_id: build_run_id:
description: 'build.yml run ID to download PDFs from (leave blank for latest)' description: 'build.yml run ID to download PDFs from'
required: false required: true
type: string type: string
permissions: permissions:
actions: read # download artifacts from other runs actions: read # download artifacts from other runs
contents: read contents: write # needed to commit export/ files back to the repo
jobs: jobs:
sign: sign:
name: Hash & Sign PDFs name: Hash & Sign PDFs
# On workflow_run, only proceed if the build actually succeeded # On workflow_run, only proceed if the build actually succeeded
if: > # if: >
github.event_name == 'workflow_dispatch' || # github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success' # github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
light_sha256: ${{ steps.hashes.outputs.light_sha256 }} light_sha256: ${{ steps.hashes.outputs.light_sha256 }}
@@ -39,36 +39,21 @@ jobs:
with: with:
sparse-checkout: pgp 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 - name: 📥 Download PDF artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: pdfs name: pdfs
path: export/ path: export/
run-id: ${{ steps.src.outputs.run_id }} run-id: ${{ inputs.build_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: 📋 List downloaded files - name: 📋 List downloaded files
run: ls -lh export/ run: ls -lh export/
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Hash # Hash — extensions match export/ conventions: .sha256, .b2sum
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
- name: #️⃣ Hash PDFs - name: "#️⃣ Hash PDFs"
id: hashes id: hashes
run: | run: |
cd export cd export
@@ -76,20 +61,19 @@ jobs:
for f in thgtoa.pdf thgtoa-dark.pdf; do for f in thgtoa.pdf thgtoa-dark.pdf; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
sha256sum "$f" | awk '{print $1}' > "${f}.sha256" sha256sum "$f" | awk '{print $1}' > "${f}.sha256"
b2sum "$f" | awk '{print $1}' > "${f}.b2" b2sum "$f" | awk '{print $1}' > "${f}.b2sum"
done done
# Combined files (only include files that exist) # Combined summary files
sha256sum thgtoa.pdf thgtoa-dark.pdf 2>/dev/null > sha256sums.txt || \ sha256sum thgtoa.pdf thgtoa-dark.pdf 2>/dev/null > sha256sums.txt || \
sha256sum thgtoa.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 thgtoa-dark.pdf 2>/dev/null > b2sums.txt || \
b2sum thgtoa.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 "") light_sha256=$(cat thgtoa.pdf.sha256 2>/dev/null || echo "")
dark_sha256=$(cat thgtoa-dark.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 "") light_b2=$(cat thgtoa.pdf.b2sum 2>/dev/null || echo "")
dark_b2=$(cat thgtoa-dark.pdf.b2 2>/dev/null || echo "") dark_b2=$(cat thgtoa-dark.pdf.b2sum 2>/dev/null || echo "")
echo "light_sha256=$light_sha256" >> $GITHUB_OUTPUT echo "light_sha256=$light_sha256" >> $GITHUB_OUTPUT
echo "dark_sha256=$dark_sha256" >> $GITHUB_OUTPUT echo "dark_sha256=$dark_sha256" >> $GITHUB_OUTPUT
@@ -102,15 +86,19 @@ jobs:
cat b2sums.txt cat b2sums.txt
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# GPG sign # GPG sign — detached ASCII-armor signatures use .asc extension
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
- name: 🔑 Install GPG
run: |
sudo apt-get update -qq
sudo apt-get install -y gnupg
- name: 🔏 Import GPG signing key - name: 🔏 Import GPG signing key
env: env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: | run: |
echo "$GPG_PRIVATE_KEY" | gpg --batch --import echo "$GPG_PRIVATE_KEY" | gpg --batch --import
# Pre-cache passphrase to prevent interactive prompt
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \ echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--pinentry-mode loopback --list-secret-keys --pinentry-mode loopback --list-secret-keys
@@ -123,8 +111,8 @@ jobs:
[ -f "$file" ] || return 0 [ -f "$file" ] || return 0
echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \ echo "$GPG_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--pinentry-mode loopback \ --pinentry-mode loopback \
--detach-sign --armor --output "${file}.sig" "$file" --detach-sign --armor --output "${file}.asc" "$file"
echo "Signed: $file" echo "Signed: $file → ${file}.asc"
} }
sign export/thgtoa.pdf sign export/thgtoa.pdf
sign export/thgtoa-dark.pdf sign export/thgtoa-dark.pdf
@@ -132,7 +120,42 @@ jobs:
sign export/b2sums.txt sign export/b2sums.txt
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Upload — PDFs + all signatures and hashes together # Commit export/ back to main
# ------------------------------------------------------------------ #
- name: 📦 Checkout full repo for commit
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
path: repo
- name: 📂 Copy export files into repo
run: cp -v export/* repo/export/
- name: 🔏 Configure SSH commit signing
run: |
mkdir -p ~/.ssh
echo "${{ secrets.ACTIONS_SSH_SIGNING_KEY }}" > ~/.ssh/signing_key
chmod 600 ~/.ssh/signing_key
git config --global gpg.format ssh
git config --global user.signingKey ~/.ssh/signing_key
git config --global commit.gpgSign true
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: 📤 Commit and push export/ to main
working-directory: repo
run: |
git add export/
if git diff --cached --quiet; then
echo "Nothing to commit — export/ is already up to date."
else
git commit -S -m "chore(export): update PDFs, hashes and signatures [skip ci]"
git push origin main
fi
# ------------------------------------------------------------------ #
# Upload artifacts for release.yml to consume
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
- name: 📤 Upload signatures artifact - name: 📤 Upload signatures artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -143,12 +166,12 @@ jobs:
export/b2sums.txt export/b2sums.txt
export/thgtoa.pdf.sha256 export/thgtoa.pdf.sha256
export/thgtoa-dark.pdf.sha256 export/thgtoa-dark.pdf.sha256
export/thgtoa.pdf.b2 export/thgtoa.pdf.b2sum
export/thgtoa-dark.pdf.b2 export/thgtoa-dark.pdf.b2sum
export/thgtoa.pdf.sig export/thgtoa.pdf.asc
export/thgtoa-dark.pdf.sig export/thgtoa-dark.pdf.asc
export/sha256sums.txt.sig export/sha256sums.txt.asc
export/b2sums.txt.sig export/b2sums.txt.asc
if-no-files-found: error if-no-files-found: error
retention-days: 90 retention-days: 90
compression-level: 0 compression-level: 0
+4 -4
View File
@@ -21,9 +21,9 @@ _site/
_site_test/ _site_test/
build/ build/
# Export directory - but track hash files and signatures # Export directory track only hashes and signatures, not the PDFs themselves
export/thgtoa.pdf.sha256 export/thgtoa.pdf.sha256
export/thgtoa-dark.pdf.sha256 export/thgtoa-dark.pdf.sha256
export/thgtoa.pdf.b2 export/thgtoa.pdf.b2sum
export/thgtoa-dark.pdf.b2 export/thgtoa-dark.pdf.b2sum
*.sig export/*.asc
+1 -18
View File
@@ -62,24 +62,7 @@ MD012:
# Consecutive blank lines # Consecutive blank lines
maximum: 1 maximum: 1
# MD013/line-length - Line length # MD013/line-length - Line length
# MD013: false
MD013:
# Number of characters
line_length: 80
# Number of characters for headings
heading_line_length: 80
# Number of characters for code blocks
code_block_line_length: 160
# Include code blocks
code_blocks: false
# Include tables
tables: false
# Include headings
headings: true
# Strict length checking (e.g. allow for longer URLs)
strict: false
# Stern length checking
stern: false
# MD014/commands-show-output - Dollar signs used before commands without showing output # MD014/commands-show-output - Dollar signs used before commands without showing output
# TODO: set false for now but we should consider enabling it # TODO: set false for now but we should consider enabling it
+12 -5
View File
@@ -10,14 +10,21 @@ repos:
- id: check-added-large-files - id: check-added-large-files
- id: check-merge-conflict - id: check-merge-conflict
- id: check-symlinks - id: check-symlinks
- id: detect-private-key
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
- id: mixed-line-ending - id: mixed-line-ending
args: [--fix=lf] args: [--fix=lf]
- repo: https://github.com/igorshubovych/markdownlint-cli - repo: https://github.com/commitizen-tools/commitizen
rev: v0.41.0 rev: v4.8.3
hooks: hooks:
- id: markdownlint - id: commitizen
- id: markdownlint-fix stages: [commit-msg]
- repo: local
hooks:
- id: require-commit-body
name: Avoid bad commits
language: script
entry: scripts/hooks/require-commit-body.sh
stages: [commit-msg]
+20 -12
View File
@@ -20,35 +20,43 @@ Notable changes to the guide and its tooling. Follows [Keep a Changelog](https:/
--- ---
## [v1.2.3] — 2026-05-22 ## [v1.2.3]
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. CI/CD pipeline split into independent stages, dark PDF quality improved, release signing automated, and the changelog now updates itself on every build. Skipping v1.2.2 which was a placeholder and contained broken Python unsuitable for a tag/release.
!!! success "Added" !!! 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. - **Dark mode PDF** (`scripts/convert.py`): pixel-level converter replaces the broken `--prefers-color-scheme=dark` Chromium flag. Produces a 200 DPI hacker-themed PDF (`#1f1f31` background, `#e0e0e0` text, `#5e8bde` links) with batched page processing to avoid OOM on large documents.
- **Three independent CI workflows** replacing the old monolithic `build-sign-release.yml`: - **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. - `build.yml` — builds PDFs and uploads them as an artifact; no secrets required, can be re-run freely.
- `sign.yml` — downloads the PDF artifact, computes SHA-256 and BLAKE2b hashes, GPG-signs all outputs, and uploads a `signatures` artifact. Can be re-run against any historical build. - `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**. - `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. - **`scripts/update_changelog.py`**: reads `git log` since the last version tag, categorises commits by conventional-commit prefix, and prepends a new entry to this file automatically after each successful build.
- **`changelog.yml`** workflow: commits the auto-generated changelog entry back to `main` after every build, with `dry_run` and `manual_version` dispatch inputs for testing. - **`changelog.yml`** workflow: commits the auto-generated changelog entry back to `main` after every build, with `dry_run` and `manual_version` dispatch inputs for safe local testing.
- **`scripts/tag_release.py`**: interactive guided helper for maintainers to create GPG-signed annotated tags. Checks clean tree and branch, auto-increments the version, pulls the message from the changelog, resolves the release signing key, creates and verifies the tag, then prints the push command.
- **`docs/code/develop.md`**: full developer reference covering prerequisites, local build instructions, the pipeline flow, all required GitHub Secrets, the release process, verification steps, and a troubleshooting section for every known CI failure mode.
!!! warning "Changed" !!! warning "Changed"
- `build-sign-release.yml` is now deprecated — push triggers removed, manual dispatch only. Will be deleted once in-flight runs complete. - `build-sign-release.yml` deprecated — push triggers removed, manual dispatch only. Will be deleted once in-flight runs complete.
- The full pipeline (build → sign → release) now chains automatically via `workflow_run` on every push to `main`. - The full pipeline (build → sign → release → changelog) now chains automatically via `workflow_run` on every push to `main`.
- GPG signing uses `--pinentry-mode loopback` and `--passphrase-fd 0` to avoid interactive prompts on headless runners. - 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. - VirusTotal scans moved to the release stage so they run once per release, not once per build.
- `.gitignore` updated to track `.b2` per-file hash files alongside existing `.sha256` and `.sig` entries.
- Stale information removed from the guide; deprecated ODT section in Appendix A6 commented out.
- Footer copyright information corrected.
!!! bug "Fixed" !!! bug "Fixed"
- Broken internal links and a mismatched cross-reference in `docs/about/index.md`. - `_save_images_as_pdf` in `convert.py` was passing raw PNG files to `qpdf --pages`, which only accepts PDF inputs. Fixed by quantizing each page to palette mode (256 colours, FASTOCTREE) and saving as a single-page PDF before merging.
- Deprecated ODT section commented out in Appendix A6 of the guide. - `convert.py` now fails immediately with install instructions if `pdftoppm` or `qpdf` are missing, instead of crashing with an unhelpful `FileNotFoundError`.
- Pillow `KeyError: 'JPEG'` on CI resolved by installing `mkdocs-material[imaging]` and using palette-mode PDF encoding instead of RGB+JPEG.
- Orphaned footnote citations `[^536]` and `[^537]` (Australian privacy law and the Identify and Disrupt Act) restored at the key disclosure law paragraph in the guide.
- Broken internal links and mismatched cross-references throughout the guide corrected.
--- ---
## [v1.2.1] — 2025 ## [v1.2.1]
First automated PDF build and the start of the CI pipeline. First automated PDF build and the start of the CI pipeline.
+369 -32
View File
@@ -1,50 +1,387 @@
# Development # Developer Guide
??? Note "How the pipeline works" This page covers everything you need to contribute to the project, run the build pipeline locally, configure GitHub Secrets, and publish a release.
**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 ## Prerequisites
### Build PDF Workflow (`build-sign-release.yml`) Install these before anything else.
!!! Note "Steps" === "Linux / macOS"
- Checkout repository ```bash
- Set up Python and MkDocs Material # Python 3.11+
- Install Chromium browser python3 --version
- 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 # poppler (pdftoppm) and qpdf
sudo apt install poppler-utils qpdf # Debian / Ubuntu
brew install poppler qpdf # macOS
!!! Note "**How it works**" # GPG
sudo apt install gnupg # Debian / Ubuntu
brew install gnupg # macOS
- Each PDF gets a unique SHA256 hash calculated at build time # Python dependencies
- Hash stored in `.sha256` files alongside the PDFs pip install "mkdocs-material[imaging]" pillow numpy
- Combined `sha256sum.txt` for batch verification ```
### GPG Signature Verification === "Windows"
**Purpose:** Verify authenticity and prevent tampering ```powershell
# Python 3.11+ from https://python.org
!!! Note "How it works" # poppler — download from https://github.com/oschwartz10612/poppler-windows/releases
# Extract and add the bin\ folder to PATH
- Detached signatures created for each PDF and hash file # qpdf — download from https://github.com/qpdf/qpdf/releases
- Public keys available in `/pgp/` directory # Extract and add the bin\ folder to PATH
**Verification command:** # GPG — download Gpg4win from https://gpg4win.org
```bash
gpg --import pgp/anonymousplanet-master.asc # Python dependencies
gpg --verify export/thgtoa.pdf.sig export/thgtoa.pdf pip install "mkdocs-material[imaging]" pillow numpy
```
You also need **Google Chrome** or **Microsoft Edge** installed for the light-mode PDF build (headless Chromium).
---
## Repository layout
```
.github/
workflows/
build.yml ← builds PDFs, uploads artifact
sign.yml ← hashes + GPG signs, uploads signatures artifact
release.yml ← publishes GitHub Release with all assets
changelog.yml ← prepends a new entry to docs/changelog/index.md
publish.yml ← deploys MkDocs site to GitHub Pages
build-sign-release.yml← DEPRECATED — fails on trigger, kept for reference
docs/
guide/index.md ← the guide (single Markdown file)
changelog/ ← release notes
code/ ← this page
export/ ← PDF output (PDFs gitignored; .sha256, .b2sum, .asc tracked)
pgp/ ← public signing keys
scripts/
build_guide_pdf.py ← MkDocs + Chromium PDF builder
convert.py ← pixel-based dark mode PDF converter
update_changelog.py ← auto-generates changelog entries from git log
setup_workflow.py ← GitHub Secrets setup assistant
verify_pdf.py ← signature verification helper
archived/
tag_release.py ← ARCHIVED — GPG tag helper (not used in current flow)
``` ```
--- ---
*This workflow is designed for security-conscious users who need to verify the authenticity and integrity of downloaded documents.* ## Building locally
### Build both PDFs
```bash
python scripts/build_guide_pdf.py --both
```
This builds the MkDocs site, renders it to `export/thgtoa.pdf` via headless Chromium, then calls `scripts/convert.py` to produce `export/thgtoa-dark.pdf`.
| Flag | Effect |
|------|--------|
| `--both` | Light PDF then dark PDF |
| (no flag) | Light PDF only |
| `--dark` | Dark PDF only (light PDF must already exist) |
### Build only the dark PDF from an existing light PDF
```bash
python scripts/convert.py export/thgtoa.pdf export/thgtoa-dark.pdf
```
Options:
| Flag | Default | Description |
|------|---------|-------------|
| `--dpi` | `200` | Rasterization DPI. 150 = smaller file, 300 = sharper but slow |
| `--batch-size` | `50` | Pages per batch. Reduce if you hit OOM |
| `--bg` | `1f1f31` | Background colour (hex) |
| `--text` | `e0e0e0` | Body text colour (hex) |
| `--link` | `5e8bde` | Link / blue element colour (hex) |
### Preview the MkDocs site
```bash
mkdocs serve
```
Opens at `http://127.0.0.1:8000`.
---
## CI/CD pipeline overview
The pipeline is fully manual after the initial build — no step automatically triggers the next. This prevents version mismatches between what was built, what was signed, and what gets released.
```
push to main (or manual trigger)
build.yml
Builds thgtoa.pdf + thgtoa-dark.pdf.
Uploads artifact: pdfs
Note the run ID.
│ ← manually trigger sign.yml with the build run ID
sign.yml
Downloads pdfs artifact. Hashes (SHA-256 + BLAKE2b) and GPG-signs
all files. Commits export/ back to main. Uploads artifacts:
signatures, pdfs-signed
Note the run ID.
│ ← manually trigger release.yml with the sign run ID
release.yml
Downloads signatures + pdfs-signed artifacts. Runs VirusTotal.
Creates GitHub Release tagged release-YYYYMMDD-<short-sha>.
│ ← manually trigger changelog.yml with the version string
changelog.yml
Runs update_changelog.py, prepends a new ## [vX.Y.Z] entry,
commits back to main.
```
Each stage is independent. If signing fails (e.g. an expired key), re-run only `sign.yml` pointing at the existing build artifact — no need to rebuild the PDFs.
!!! warning "Before you push"
- Make sure the working tree is clean (`git status`)
- Run `mkdocs build` locally if you changed `docs/` to catch broken links before CI does
- If you added new footnotes, verify they have both a definition `[^N]:` and at least one inline citation `[^N]`
---
## Release process (step by step)
### 1. Trigger a build
Push to `main` — `build.yml` runs automatically when `docs/`, `mkdocs.yml`, or `scripts/` change. You can also trigger it manually from **Actions → Build PDFs → Run workflow**.
Once it completes successfully, **note the run ID** from the URL or the Actions list.
---
### 2. Sign the PDFs
Go to **Actions → Sign PDFs → Run workflow**.
| Input | Value |
|-------|-------|
| `build_run_id` | The run ID from step 1 |
`sign.yml` will:
- Download the PDFs artifact from the build run
- Compute SHA-256 and BLAKE2b hashes, writing `thgtoa.pdf.sha256`, `thgtoa.pdf.b2sum`, `sha256sums.txt`, `b2sums.txt`, and the dark equivalents
- GPG-sign all PDFs and hash files, writing `.asc` detached signature files
- Commit the updated `export/` directory back to `main`
- Upload two artifacts: `signatures` and `pdfs-signed`
Once it completes successfully, **note the run ID**.
---
### 3. Publish the release
Go to **Actions → Release → Run workflow**.
| Input | Value |
|-------|-------|
| `sign_run_id` | The run ID from step 2 |
| `prerelease` | `false` for a normal release |
`release.yml` will:
- Download `signatures` and `pdfs-signed` artifacts from the sign run
- Upload both PDFs to VirusTotal
- Auto-generate a release tag in the format `release-YYYYMMDD-<short-sha>` (e.g. `release-20260527-abc1234`)
- Create a GitHub Release with all PDFs, hash files, and signatures attached, and the VirusTotal report URLs in the body
No version number needs to be chosen at this step — the tag is derived from the date and commit SHA, so it is always unique and always traceable.
---
### 4. Update the changelog
Go to **Actions → Update Changelog → Run workflow**.
| Input | Value |
|-------|-------|
| `version` | The human-readable version string, e.g. `v1.2.4` |
| `dry_run` | `true` to preview without committing |
`changelog.yml` runs `scripts/update_changelog.py`, which:
- Reads git log since the last `## [vX.Y.Z]` heading in the changelog
- Categorises commits into Added / Changed / Fixed using conventional-commit prefixes
- Prepends a new `## [version]` admonition block to `docs/changelog/index.md`
- Commits the result back to `main`
The version string is the only human decision in the release process. It goes into the changelog only — it does not affect the release tag.
!!! tip "Previewing the changelog entry"
Run with `dry_run: true` first to review the generated entry before it is committed.
---
## Release tag format
Release tags use the format `release-YYYYMMDD-<short-sha>`, for example:
```
release-20260527-abc1234
```
This format is always unique, requires no version decision at release time, and is directly traceable to the commit that was built. The version string (e.g. `v1.2.4`) is a separate, human-assigned label that lives only in the changelog.
---
## Commit message format
All commits must follow the [Conventional Commits](https://www.conventionalcommits.org) format. This is enforced by the `commitizen` pre-commit hook.
```
<type>(<scope>): <description>
```
Accepted types and their changelog bucket:
| Type | Bucket |
|------|--------|
| `feat`, `feature`, `add` | Added |
| `fix`, `bugfix`, `revert`, `security` | Fixed |
| `perf`, `refactor`, `change`, `chore`, `ci`, `docs`, `style`, `test`, `build` | Changed |
Examples:
```bash
feat: add dark-mode PDF export
fix(scripts): handle locked PDF on Windows
docs: update developer workflow guide
chore(ci): pin Chrome version to 120
```
---
## GitHub Secrets
Configure these in **Settings → Secrets and variables → Actions** before the pipeline will fully work. The build step requires no secrets; signing and releasing require all of them.
### `GPG_PRIVATE_KEY`
The ASCII-armored private key used to sign PDFs and hash files.
```bash
gpg --armor --export-secret-keys C3023DBEA3FB38C438BA1EEDCEC60AEDE8B992A2
```
Copy the entire output (including `-----BEGIN PGP PRIVATE KEY BLOCK-----` and the closing line) and paste it as the secret value.
!!! danger "Key security"
This is the release signing key. Only repository admins should have access to it. Never commit it to the repository or share it outside of GitHub Secrets.
### `GPG_PASSPHRASE`
The passphrase protecting the private key above. Must match exactly — no trailing newline.
### `ACTIONS_SSH_SIGNING_KEY`
An SSH private key used by `sign.yml` to sign the commit that pushes `export/` back to `main`. Generate a dedicated key for this:
```bash
ssh-keygen -t ed25519 -C "github-actions signing key" -f actions_signing_key
```
Add the **private key** as the `ACTIONS_SSH_SIGNING_KEY` secret, and the **public key** to the repository's Deploy Keys (Settings → Deploy Keys) with write access.
### `VT_API_KEY`
A [VirusTotal](https://www.virustotal.com) API key with file upload permissions. Used by `release.yml` to scan both PDFs before publishing. Get one by creating a free account at `virustotal.com` → API key under your profile. The free tier (4 lookups/minute, 500/day) is sufficient.
### `CHANGELOG_PAT`
A GitHub Personal Access Token with `contents: write` scope on this repository. Needed because `changelog.yml` commits back to `main` — commits made with the default `GITHUB_TOKEN` do not trigger further workflow runs (GitHub loop-prevention). A PAT bypasses this. If absent, falls back to `GITHUB_TOKEN` — the commit still happens, it just won't trigger downstream workflows.
**Creating one:** GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens → set Contents to Read and write for this repo only.
### Secrets summary
| Secret | Required by | What happens if missing |
|--------|------------|------------------------|
| `GPG_PRIVATE_KEY` | `sign.yml` | Signing step fails — no `.asc` files produced |
| `GPG_PASSPHRASE` | `sign.yml` | GPG import succeeds but signing fails |
| `ACTIONS_SSH_SIGNING_KEY` | `sign.yml` | Export commit is unsigned (may fail if branch protection requires signed commits) |
| `VT_API_KEY` | `release.yml` | VirusTotal step fails — release is not published |
| `CHANGELOG_PAT` | `changelog.yml` | Falls back to `GITHUB_TOKEN` — changelog updates but commit won't trigger downstream workflows |
---
## Verifying a release
Anyone can verify the authenticity of a release download.
```bash
# Import the release signing key
gpg --import pgp/anonymousplanet-release.asc
# Verify the PDFs
gpg --verify thgtoa.pdf.asc thgtoa.pdf
gpg --verify thgtoa-dark.pdf.asc thgtoa-dark.pdf
# Verify the hash files
gpg --verify sha256sums.txt.asc sha256sums.txt
gpg --verify b2sums.txt.asc b2sums.txt
# Check the PDF hashes match
sha256sum -c sha256sums.txt
b2sum -c b2sums.txt
```
A successful verify looks like:
```
gpg: Signature made ...
gpg: Good signature from "Anonymous Planet (Release) ..."
```
---
## Troubleshooting
**`cairosvg` missing during MkDocs build**
Install the imaging extras: `pip install "mkdocs-material[imaging]"`. Required by the `social` plugin.
**`KeyError: 'JPEG'` in convert.py**
Pillow needs libjpeg. Reinstall after installing the system lib: `sudo apt install libjpeg-dev && pip install --force-reinstall pillow`.
**`qpdf: can't find PDF header`**
Ensure you are on the current version of `convert.py` — qpdf only accepts PDF inputs, not PNG.
**GPG signing fails on CI with `No secret key`**
The `GPG_PRIVATE_KEY` secret is missing or malformed. Re-export with `gpg --armor --export-secret-keys <fingerprint>` and paste the full block including header and footer lines.
**GPG signing fails with `Bad passphrase`**
The `GPG_PASSPHRASE` secret has a trailing space or newline. Paste it again with no surrounding whitespace.
**`release.yml` fails on VirusTotal**
The `VT_API_KEY` is missing, invalid, or over the rate limit (500 requests/day on the free tier). Check the secret and re-run after a few minutes.
**`sign.yml` fails downloading PDF artifact**
The `build_run_id` is wrong, or the artifact has expired (90-day retention). Trigger a new build and use the fresh run ID.
**Changelog already contains version X**
`update_changelog.py` will error if `MANUAL_VERSION` is set to a version already in the changelog. Choose the next version string.
**Footnote warnings from MkDocs (`link '#fnref:N' has no anchor`)**
A footnote definition `[^N]:` exists without a matching inline citation. Add the citation or remove the orphaned definition.
+2 -13
View File
@@ -8583,7 +8583,7 @@ It is recommended that you learn about the common ways people mess up OPSEC <htt
- Contact a lawyer if possible and hope for the best and if you cannot contact one (yet), **try to remain silent (if your country allows it) until you have a lawyer to help you and if your law allows you to remain silent.** - Contact a lawyer if possible and hope for the best and if you cannot contact one (yet), **try to remain silent (if your country allows it) until you have a lawyer to help you and if your law allows you to remain silent.**
Keep in mind that many countries have specific laws to compel you to reveal your passwords that could override your "right to remain silent". See this Wikipedia article: <https://en.wikipedia.org/wiki/Key_disclosure_law> <sup>[[Wikiless]](https://wikiless.com/wiki/Key_disclosure_law)</sup> <sup>[[Archive.org]](https://web.archive.org/web/https://en.wikipedia.org/wiki/Key_disclosure_law)</sup> and this other visual resource with law references <https://www.gp-digital.org/world-map-of-encryption/> <sup>[[Archive.org]](https://web.archive.org/web/https://www.gp-digital.org/world-map-of-encryption/)</sup>. Keep in mind that many countries have specific laws to compel you to reveal your passwords that could override your "right to remain silent". See this Wikipedia article: <https://en.wikipedia.org/wiki/Key_disclosure_law> <sup>[[Wikiless]](https://wikiless.com/wiki/Key_disclosure_law)</sup> <sup>[[Archive.org]](https://web.archive.org/web/https://en.wikipedia.org/wiki/Key_disclosure_law)</sup> and this other visual resource with law references <https://www.gp-digital.org/world-map-of-encryption/> <sup>[[Archive.org]](https://web.archive.org/web/https://www.gp-digital.org/world-map-of-encryption/)</sup>. Australia in particular has broad privacy laws[^536] and passed the Surveillance Legislation Amendment (Identify and Disrupt) Act 2021[^537], which grants authorities powers to modify, add, copy, and delete data on a suspect's devices and accounts.
## A small final editorial note ## A small final editorial note
@@ -10043,28 +10043,17 @@ Again, regarding the PDFs of this guide and as explained in the README of my rep
- Run "python pdfid.py file-to-check.pdf" and you should see these at 0 in the case of the PDF files in this repository: - Run "python pdfid.py file-to-check.pdf" and you should see these at 0 in the case of the PDF files in this repository:
``` ```text
/JS 0 #This indicates the presence of Javascript /JS 0 #This indicates the presence of Javascript
/JavaScript 0 #This indicates the presence of Javascript /JavaScript 0 #This indicates the presence of Javascript
/AA 0 #This indicates the presence of automatic action on opening /AA 0 #This indicates the presence of automatic action on opening
/OpenAction 0 #This indicates the presence of automatic action on opening /OpenAction 0 #This indicates the presence of automatic action on opening
/AcroForm 0 #This indicates the presence of AcroForm which could contain JavaScript /AcroForm 0 #This indicates the presence of AcroForm which could contain JavaScript
/JBIG2Decode 0 #This indicates the use of JBIG2 compression which could be used for obfuscating content /JBIG2Decode 0 #This indicates the use of JBIG2 compression which could be used for obfuscating content
/RichMedia 0 #This indicates the presence of rich media within the PDF such as Flash /RichMedia 0 #This indicates the presence of rich media within the PDF such as Flash
/Launch 0 #This counts the launch actions /Launch 0 #This counts the launch actions
/EmbeddedFile 0 #This indicates there are embedded files within the PDF /EmbeddedFile 0 #This indicates there are embedded files within the PDF
/XFA 0 #This indicates the presence of XML Forms within the PDF /XFA 0 #This indicates the presence of XML Forms within the PDF
``` ```
Now, what if you think the PDF is still suspicious? Fear not ... there are more things you can do to ensure it is not malicious: Now, what if you think the PDF is still suspicious? Fear not ... there are more things you can do to ensure it is not malicious:
+11 -2
View File
@@ -36,12 +36,14 @@ python scripts/verify_pdf.py --vt
#### 1. Verify SHA256 Hash #### 1. Verify SHA256 Hash
**Linux/macOS:** **Linux/macOS:**
```bash ```bash
cd /path/to/repo cd /path/to/repo
sha256sum -c sha256sum-light.txt sha256sum -c sha256sum-light.txt
``` ```
**Windows (PowerShell):** **Windows (PowerShell):**
```powershell ```powershell
Get-FileHash -Algorithm SHA256 export\thgtoa.pdf | Select-Object Hash Get-FileHash -Algorithm SHA256 export\thgtoa.pdf | Select-Object Hash
# Compare with the hash in thgtoa.pdf.sha256 # Compare with the hash in thgtoa.pdf.sha256
@@ -50,18 +52,21 @@ Get-FileHash -Algorithm SHA256 export\thgtoa.pdf | Select-Object Hash
#### 2. Verify GPG Signature #### 2. Verify GPG Signature
First, import the public key: First, import the public key:
```bash ```bash
gpg --import pgp/anonymousplanet-master.asc gpg --import pgp/anonymousplanet-master.asc
``` ```
Then verify the signature: Then verify the signature:
```bash ```bash
gpg --verify export/thgtoa.pdf.sig export/thgtoa.pdf gpg --verify export/thgtoa.pdf.sig export/thgtoa.pdf
gpg --verify export/thgtoa-dark.pdf.sig export/thgtoa-dark.pdf gpg --verify export/thgtoa-dark.pdf.sig export/thgtoa-dark.pdf
``` ```
Expected output for successful verification: Expected output for successful verification:
```
```text
gpg: Signature made Mon 20 Apr 2026 01:46:40 AM EDT gpg: Signature made Mon 20 Apr 2026 01:46:40 AM EDT
gpg: using EDDSA key 9FA5436D0EE360985157382517ECA05F768DEDF6 gpg: using EDDSA key 9FA5436D0EE360985157382517ECA05F768DEDF6
gpg: Good signature from "Anonymous Planet Master Signing Key" [unknown] gpg: Good signature from "Anonymous Planet Master Signing Key" [unknown]
@@ -77,6 +82,7 @@ Visit the VirusTotal report links (automatically generated in release notes):
- Dark mode: `https://www.virustotal.com/gui/file/[hash]` - Dark mode: `https://www.virustotal.com/gui/file/[hash]`
Or use the Python script with API key: Or use the Python script with API key:
```bash ```bash
export VT_API_KEY=your_vt_api_key export VT_API_KEY=your_vt_api_key
python scripts/verify_pdf.py --vt python scripts/verify_pdf.py --vt
@@ -103,15 +109,18 @@ The GitHub Actions workflows automatically:
## Troubleshooting ## Troubleshooting
### "Good signature" but wrong owner? ### "Good signature" but wrong owner?
- Ensure you imported the correct public key - Ensure you imported the correct public key
- Check the key fingerprint matches the official one from the repository - Check the key fingerprint matches the official one from the repository
### Hash mismatch? ### Hash mismatch?
- Re-download the file (corruption during transfer) - Re-download the file (corruption during transfer)
- Verify you're checking against the correct hash file - Verify you're checking against the correct hash file
- Check for disk errors on your system - Check for disk errors on your system
### GPG not found? ### GPG not found?
- Install GPG: `sudo apt install gnupg` (Debian/Ubuntu) or `brew install gnupg` (macOS) - Install GPG: `sudo apt install gnupg` (Debian/Ubuntu) or `brew install gnupg` (macOS)
- On Windows, use [Gpg4win](https://www.gpg4win.org/) - On Windows, use [Gpg4win](https://www.gpg4win.org/)
@@ -123,4 +132,4 @@ The GitHub Actions workflows automatically:
--- ---
*For questions or issues with verification, please open an issue on GitHub.* _For questions or issues with verification, please open an issue on GitHub._
+2
View File
@@ -0,0 +1,2 @@
7ed217a5e21353a79e0cea393f20d1c3d12549a9a3ed8b968cbbb7ca487f0db5d381af06fdbf09bed8f15066074a841fd79106bdb810e447cd123016e129da76 thgtoa.pdf
c84086635084be469074df60b5083e4e72cbabaa2687ef55d5bdd9876efc202a56b70d07e8ac177a6456cdb8bb248cd52550f9f32ba0a263be4aefdca841df95 thgtoa-dark.pdf
+2
View File
@@ -0,0 +1,2 @@
f1fdb5ae16f197747f648ecf539906361eb7110d118cb9dae69b3ca0c1927313 thgtoa.pdf
17e06922819a8805e42ad0a1cdec9e15ab7fcac0ee49aac6ce77aa2ca5102f59 thgtoa-dark.pdf
Binary file not shown.
BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
# scripts/archived
Scripts kept for reference but no longer part of the active pipeline.
| Script | Why archived |
|--------|-------------|
| `tag_release.py` | Created GPG-signed `vX.Y.Z` annotated tags. Superseded by the `release-YYYYMMDD-<sha>` timestamp tagging built into `release.yml`. Re-enable if semver release tagging is reintroduced. |
@@ -38,7 +38,7 @@ from pathlib import Path
# Default release signing key fingerprint. # Default release signing key fingerprint.
# Maintainers with a different key can pass --key on the CLI. # Maintainers with a different key can pass --key on the CLI.
DEFAULT_SIGNING_KEY = "9FA5436D0EE360985157382517ECA05F768DEDF6" DEFAULT_SIGNING_KEY = "C3023DBEA3FB38C438BA1EEDCEC60AEDE8B992A2"
CHANGELOG = Path(__file__).resolve().parent.parent / "docs" / "changelog" / "index.md" CHANGELOG = Path(__file__).resolve().parent.parent / "docs" / "changelog" / "index.md"
+37 -10
View File
@@ -93,22 +93,28 @@ def apply_dark_theme(
def _save_images_as_pdf(images: list, output_path: str) -> None: 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. """Save a list of RGB PIL images as a PDF without requiring libjpeg.
Pillow's built-in PDF writer defaults to JPEG encoding for RGB images, Pillow's PDF writer defaults to JPEG encoding for RGB images, which
which fails when libjpeg is not available in the environment. Instead we fails when libjpeg is absent in the environment. Fix: quantize each
write each page as a lossless PNG to a temp directory and assemble them image to palette mode (256 colours, FASTOCTREE) so Pillow uses
with qpdf, which embeds the PNGs directly without re-encoding. zlib/deflate instead of JPEG, save each as an individual single-page
PDF, then merge the page PDFs with qpdf.
Colour fidelity is preserved — the hacker theme uses only a handful
of distinct colours so 256-colour quantization is visually lossless.
""" """
import tempfile as _tempfile import tempfile as _tempfile
with _tempfile.TemporaryDirectory() as staging: with _tempfile.TemporaryDirectory() as staging:
png_paths = [] page_pdfs = []
for i, img in enumerate(images): for i, img in enumerate(images):
p = os.path.join(staging, f'p{i:05d}.png') page_path = os.path.join(staging, f'p{i:05d}.pdf')
img.save(p, format='PNG') img.quantize(colors=256, method=Image.Quantize.FASTOCTREE).save(
png_paths.append(p) page_path, format='PDF'
)
page_pdfs.append(page_path)
subprocess.run( subprocess.run(
['qpdf', '--empty', '--pages'] + png_paths + ['--', output_path], ['qpdf', '--empty', '--pages'] + page_pdfs + ['--', output_path],
check=True, check=True,
) )
@@ -119,6 +125,25 @@ def _check_qpdf() -> bool:
).returncode == 0 ).returncode == 0
def _check_dependencies() -> None:
"""Verify required system tools are available before doing any work."""
missing = []
for tool in ('pdftoppm', 'qpdf'):
if subprocess.run(['which', tool], capture_output=True).returncode != 0:
missing.append(tool)
if missing:
tools = ', '.join(missing)
instructions = (
f"Install with:\n"
f" Linux/WSL: sudo apt install poppler-utils qpdf\n"
f" macOS: brew install poppler qpdf\n"
f" Windows: see docs/code/develop.md"
)
raise RuntimeError(
f"Missing required system tool(s): {tools}\n{instructions}"
)
def convert_pdf_to_dark( def convert_pdf_to_dark(
input_path: str | Path, input_path: str | Path,
output_path: str | Path, output_path: str | Path,
@@ -138,6 +163,8 @@ def convert_pdf_to_dark(
input_path = str(input_path) input_path = str(input_path)
output_path = str(output_path) output_path = str(output_path)
_check_dependencies()
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
# 1. Rasterize all pages # 1. Rasterize all pages
prefix = os.path.join(tmp, 'page') prefix = os.path.join(tmp, 'page')
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
COMMIT_MSG_FILE="$1"
if [ -z "$COMMIT_MSG_FILE" ]; then
echo "require-commit-body: no commit message file supplied" >&2
exit 1
fi
stripped=$(grep -v '^\s*#' "$COMMIT_MSG_FILE" | sed 's/[[:space:]]*$//')
subject=$(echo "$stripped" | sed -n '1p')
separator=$(echo "$stripped" | sed -n '2p')
body=$(echo "$stripped" | tail -n +3 | grep -v '^\s*$')
if [ -z "$subject" ]; then
echo ""
echo " COMMIT REJECTED: subject line is empty." >&2
echo ""
exit 1
fi
if [ -z "$separator" ] && [ -z "$body" ]; then
echo ""
echo " COMMIT REJECTED: commit has no body." >&2
echo ""
echo " Every commit must explain *why*, not just *what*." >&2
echo " Format:" >&2
echo ""
echo " type(scope): short subject" >&2
echo ""
echo " Explain the motivation for this change. What problem does" >&2
echo " it solve? Why is this the right approach? Reference any" >&2
echo " relevant issues, prior art, or context." >&2
echo ""
exit 1
fi
if [ -n "$separator" ]; then
echo ""
echo " COMMIT REJECTED: no blank line between subject and body." >&2
echo ""
echo " The second line of a commit message must be blank." >&2
echo ""
exit 1
fi
if [ -z "$body" ]; then
echo ""
echo " COMMIT REJECTED: commit body is empty." >&2
echo ""
echo " Add at least one line after the blank separator explaining" >&2
echo " why this change was made." >&2
echo ""
exit 1
fi
exit 0
+55 -31
View File
@@ -2,14 +2,18 @@
"""Auto-generate and prepend a changelog entry to docs/changelog/index.md. """Auto-generate and prepend a changelog entry to docs/changelog/index.md.
Called by .github/workflows/changelog.yml. Reads git log since the last Called by .github/workflows/changelog.yml. Reads git log since the last
changelog version tag, categorises commits by conventional-commit prefix, changelog version, categorises commits by conventional-commit prefix,
and prepends a new ## [vX.Y.Z] section in the MkDocs admonition format used and prepends a new ## [vX.Y.Z] section in the MkDocs admonition format used
by the rest of the file. by the rest of the file.
Environment variables (all optional — reasonable defaults apply): Environment variables:
MANUAL_VERSION Override the auto-incremented version string. MANUAL_VERSION Version string to record (required when run from CI).
Falls back to auto-increment from the changelog for local runs.
TRIGGERING_SHA The commit SHA that triggered this run (used as range end). TRIGGERING_SHA The commit SHA that triggered this run (used as range end).
DRY_RUN If "true", print the entry and exit without writing. DRY_RUN If "true", print the entry and exit without writing.
Note: version is sourced from the changelog file, not from git tags. Git tags
are no longer used as the version authority. The changelog is the source of truth.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,7 +21,6 @@ from __future__ import annotations
import os import os
import re import re
import subprocess import subprocess
import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -52,15 +55,6 @@ def run(cmd: list[str]) -> str:
return result.stdout.strip() 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: def auto_increment_version(current: str | None) -> str:
"""Bump the patch number of the current version, or default to v1.0.0.""" """Bump the patch number of the current version, or default to v1.0.0."""
if not current: if not current:
@@ -73,7 +67,11 @@ def auto_increment_version(current: str | None) -> str:
def version_from_changelog() -> str | None: def version_from_changelog() -> str | None:
"""Parse the most recent ## [vX.Y.Z] heading from the changelog file.""" """Parse the most recent ## [vX.Y.Z] heading from the changelog file.
This is the primary version source — the changelog is the authority,
not git tags.
"""
if not CHANGELOG.exists(): if not CHANGELOG.exists():
return None return None
for line in CHANGELOG.read_text(encoding="utf-8").splitlines(): for line in CHANGELOG.read_text(encoding="utf-8").splitlines():
@@ -84,11 +82,20 @@ def version_from_changelog() -> str | None:
def commits_since(ref: str | None, until: str) -> list[str]: def commits_since(ref: str | None, until: str) -> list[str]:
"""Return one-line commit messages between ref and until (exclusive/inclusive).""" """Return one-line commit messages between ref and until (exclusive/inclusive).
When no ref is given we fall back to the merge-base between HEAD and
origin/main to avoid dumping the entire history into the changelog.
"""
if ref: if ref:
log_range = f"{ref}..{until}" log_range = f"{ref}..{until}"
else: else:
log_range = until merge_base = run(["git", "merge-base", "HEAD", "origin/main"])
if merge_base:
log_range = f"{merge_base}..{until}"
else:
# Truly brand new repo with no remote — limit to last 50 commits
log_range = f"-50 {until}"
out = run(["git", "log", "--pretty=format:%s", log_range]) out = run(["git", "log", "--pretty=format:%s", log_range])
return [line.strip() for line in out.splitlines() if line.strip()] return [line.strip() for line in out.splitlines() if line.strip()]
@@ -97,12 +104,30 @@ def categorise(messages: list[str]) -> dict[str, list[str]]:
"""Sort commit messages into Added / Changed / Fixed buckets.""" """Sort commit messages into Added / Changed / Fixed buckets."""
buckets: dict[str, list[str]] = {b: [] for b in BUCKET_ORDER} buckets: dict[str, list[str]] = {b: [] for b in BUCKET_ORDER}
NOISE = re.compile(
r"""
\[skip\ ci\] # CI skip marker
| ^Merge\ (pull\ request|branch) # merge commits
| ^chore:\ bump # version bump chores
| update\ changelog # self-referential
| ^\d+/\d+ # numbered commit series (e.g. 3/8)
| ^Tweaking # vague WIP messages
| ^Moving\ some # vague WIP messages
| \ pt\d+$ # "...pt2", "...pt3" suffixes
| ^Fix\ (workflow|path|README)$ # one-word infrastructure fixes
| ^Still\ broken # embarrassing mid-fix notes
| ^WIP\b # work in progress
| ^Forgot\ to # oops commits
| ^Revert\ " # reverts (surface the original instead)
| ^One\ job\ to\ rule # joke commit messages
""",
re.VERBOSE | re.IGNORECASE,
)
for msg in messages: for msg in messages:
# Skip automated / noise commits if NOISE.search(msg):
if re.search(r"\[skip ci\]|^Merge |^chore: bump|update changelog", msg, re.I):
continue continue
# Strip conventional-commit prefix to get the plain description
m = re.match(r"^(\w+)(?:\([^)]+\))?!?:\s*(.+)$", msg) m = re.match(r"^(\w+)(?:\([^)]+\))?!?:\s*(.+)$", msg)
if m: if m:
prefix = m.group(1).lower() prefix = m.group(1).lower()
@@ -112,7 +137,6 @@ def categorise(messages: list[str]) -> dict[str, list[str]]:
description = msg description = msg
bucket = "Changed" bucket = "Changed"
# Capitalise first letter
description = description[0].upper() + description[1:] if description else description description = description[0].upper() + description[1:] if description else description
buckets[bucket].append(description) buckets[bucket].append(description)
@@ -130,7 +154,7 @@ def format_admonition(bucket: str, items: list[str]) -> str:
def build_entry(version: str, buckets: dict[str, list[str]], sha: str) -> str: def build_entry(version: str, buckets: dict[str, list[str]], sha: str) -> str:
date = datetime.now(timezone.utc).strftime("%Y-%m-%d") date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
lines = [f"## [{version}]", ""] lines = [f"## [{version}]", ""]
lines.append(f'!!! Note "Meta"') lines.append('!!! Note "Meta"')
lines.append("") lines.append("")
lines.append(f" - Released {date} from [`{sha[:7]}`](https://github.com/Anon-Planet/thgtoa/commit/{sha})") lines.append(f" - Released {date} from [`{sha[:7]}`](https://github.com/Anon-Planet/thgtoa/commit/{sha})")
lines.append("") lines.append("")
@@ -139,7 +163,6 @@ def build_entry(version: str, buckets: dict[str, list[str]], sha: str) -> str:
if buckets.get(bucket): if buckets.get(bucket):
lines.append(format_admonition(bucket, buckets[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): if not any(buckets[b] for b in BUCKET_ORDER):
lines.append('!!! Note "Changed"') lines.append('!!! Note "Changed"')
lines.append("") lines.append("")
@@ -153,10 +176,8 @@ def prepend_entry(entry: str) -> None:
"""Insert the new entry after the # Release Notes heading.""" """Insert the new entry after the # Release Notes heading."""
content = CHANGELOG.read_text(encoding="utf-8") content = CHANGELOG.read_text(encoding="utf-8")
# Find the first ## heading and insert before it
insert_at = content.find("\n## ") insert_at = content.find("\n## ")
if insert_at == -1: if insert_at == -1:
# No existing version section — append after the header block
content = content.rstrip() + "\n\n" + entry + "\n" content = content.rstrip() + "\n\n" + entry + "\n"
else: else:
content = content[: insert_at + 1] + entry + "\n" + content[insert_at + 1 :] content = content[: insert_at + 1] + entry + "\n" + content[insert_at + 1 :]
@@ -175,13 +196,11 @@ def main() -> int:
manual_version = os.environ.get("MANUAL_VERSION", "").strip() manual_version = os.environ.get("MANUAL_VERSION", "").strip()
triggering_sha = os.environ.get("TRIGGERING_SHA", "HEAD").strip() or "HEAD" triggering_sha = os.environ.get("TRIGGERING_SHA", "HEAD").strip() or "HEAD"
# Determine version # Version authority: MANUAL_VERSION (required in CI) → changelog → auto-increment.
last_tag = latest_version_tag() # Git tags are intentionally not consulted.
last_cl_ver = version_from_changelog() last_cl_ver = version_from_changelog()
base_version = last_tag or last_cl_ver new_version = manual_version or auto_increment_version(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"Last CL version: {last_cl_ver or '(none)'}")
print(f"New version: {new_version}") print(f"New version: {new_version}")
print(f"Triggering SHA: {triggering_sha}") print(f"Triggering SHA: {triggering_sha}")
@@ -190,8 +209,13 @@ def main() -> int:
print(f"Changelog already contains {new_version} — nothing to do.") print(f"Changelog already contains {new_version} — nothing to do.")
return 0 return 0
# Collect commits since the last tag (or all commits if no tag) if already_has_version(new_version) and manual_version:
messages = commits_since(last_tag, triggering_sha) print(f"::error::Changelog already contains {new_version}. Choose a different version.")
return 1
# Collect commits since the last changelog version (using it as a git ref
# is a best-effort — if the tag doesn't exist, commits_since handles it gracefully).
messages = commits_since(last_cl_ver, triggering_sha)
print(f"Commits found: {len(messages)}") print(f"Commits found: {len(messages)}")
for m in messages: for m in messages:
print(f" {m}") print(f" {m}")