mirror of
https://github.com/Anon-Planet/thgtoa.git
synced 2026-06-11 00:02:29 +02:00
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
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version_scheme = "semver"
|
||||
tag_format = "v$version"
|
||||
update_changelog_on_bump = false
|
||||
major_version_zero = false
|
||||
|
||||
# Version management is handled manually via the changelog.
|
||||
# commitizen is used here only to enforce conventional commit message format.
|
||||
# -S ensures SSH signing is applied to every commit regardless of global git config state.
|
||||
extra_arguments = ["-S"]
|
||||
|
||||
[tool.commitizen.customize]
|
||||
schema_pattern = '^(feat|feature|add|fix|bugfix|revert|security|perf|refactor|change|chore|ci|docs|style|test|build)(\(.+\))?(!)?: .{1,72}(\n.*)*$'
|
||||
|
||||
@@ -1,218 +1,25 @@
|
||||
name: 🗑️ DEPRECATED — Build & Sign & Release (combined)
|
||||
|
||||
# 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
|
||||
# This workflow is disabled. It is kept only as a reference until the
|
||||
# split workflows have been confirmed stable in production.
|
||||
# Do not trigger this workflow.
|
||||
|
||||
on:
|
||||
workflow_dispatch: # manual only — no automatic triggers (deprecated)
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
_disabled:
|
||||
description: 'This workflow is deprecated. Use build.yml → sign.yml → release.yml instead.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-sign-release:
|
||||
name: Build, Sign & Release PDFs
|
||||
noop:
|
||||
name: Deprecated — no-op
|
||||
runs-on: ubuntu-latest
|
||||
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 pillow numpy
|
||||
|
||||
- name: 🖼️ Install poppler (pdftoppm) and qpdf
|
||||
- name: ❌ Workflow is deprecated
|
||||
run: |
|
||||
sudo apt-get update
|
||||
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: 🔑 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
|
||||
echo "This workflow is deprecated."
|
||||
echo "Use build.yml → sign.yml → release.yml instead."
|
||||
exit 1
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
name: 📝 Update Changelog
|
||||
|
||||
# Manual only — automatic triggering is disabled to prevent version mismatches
|
||||
# between the generated PDF and the GitHub release. Run this manually after
|
||||
# a release is published and the version is confirmed.
|
||||
# Manual only — run after a release is published. Provide the exact version
|
||||
# string (e.g. v1.2.4) to prepend to the changelog. Version is required to
|
||||
# prevent silent auto-increment drift from release tags.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version string (e.g. v1.2.4) — leave blank to auto-increment'
|
||||
required: false
|
||||
description: 'Version string to record (e.g. v1.2.4) — required'
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run — print entry without committing'
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: 📝 Generate and prepend changelog entry
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run || 'false' }}
|
||||
MANUAL_VERSION: ${{ inputs.version || '' }}
|
||||
MANUAL_VERSION: ${{ inputs.version }}
|
||||
GH_SHA: ${{ github.sha }}
|
||||
GH_REF: ${{ github.ref_name }}
|
||||
TRIGGERING_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
name: 🚀 Release
|
||||
|
||||
# Manual only — run this deliberately after build and sign are confirmed good.
|
||||
# Provide the exact version tag and the sign.yml run ID to pull artifacts from.
|
||||
# Provide the sign.yml run ID to pull artifacts from. The release tag is
|
||||
# generated automatically as release-YYYYMMDD-<short-sha> — no version input
|
||||
# needed, no semver drift possible.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version tag (e.g. v1.2.4) — must not already exist'
|
||||
required: true
|
||||
type: string
|
||||
sign_run_id:
|
||||
description: 'sign.yml run ID to pull signatures and PDFs from'
|
||||
required: true
|
||||
@@ -29,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 🛠️ Checkout (for tags and pgp/)
|
||||
- name: 🛠️ Checkout (for pgp/)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -66,8 +64,8 @@ jobs:
|
||||
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
|
||||
echo "light_b2=$(read_hash thgtoa.pdf.b2sum)" >> $GITHUB_OUTPUT
|
||||
echo "dark_b2=$(read_hash thgtoa-dark.pdf.b2sum)" >> $GITHUB_OUTPUT
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# VirusTotal
|
||||
@@ -98,30 +96,18 @@ jobs:
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Validate explicit version input — refuse to auto-increment or
|
||||
# overwrite an existing tag
|
||||
# Generate release tag — timestamp + short SHA, always unique
|
||||
# ------------------------------------------------------------------ #
|
||||
- name: 🏷️ Validate release tag
|
||||
- name: 🏷️ Generate release tag
|
||||
id: tag
|
||||
run: |
|
||||
git fetch --tags --quiet
|
||||
VERSION="${{ inputs.version }}"
|
||||
|
||||
# Enforce vX.Y.Z format
|
||||
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::Version '$VERSION' is not valid semver. Use format: v1.2.3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Refuse to overwrite an existing tag
|
||||
if git tag --list | grep -qx "$VERSION"; then
|
||||
echo "::error::Tag '$VERSION' already exists. Bump the version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "tag=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "name=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Tag: $VERSION"
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
DATE=$(date -u +'%Y%m%d')
|
||||
TAG="release-${DATE}-${SHORT_SHA}"
|
||||
NAME="Release ${DATE} (${SHORT_SHA})"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "name=$NAME" >> $GITHUB_OUTPUT
|
||||
echo "Tag: $TAG"
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Create GitHub Release
|
||||
@@ -151,9 +137,9 @@ jobs:
|
||||
| `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) |
|
||||
| `thgtoa.pdf.b2sum` | BLAKE2b — light PDF |
|
||||
| `thgtoa-dark.pdf.b2sum` | BLAKE2b — dark PDF |
|
||||
| `*.asc` | GPG detached signatures (ASCII armor) |
|
||||
|
||||
---
|
||||
|
||||
@@ -180,12 +166,12 @@ jobs:
|
||||
gpg --import pgp/anonymousplanet-release.asc
|
||||
|
||||
# Verify PDFs
|
||||
gpg --verify thgtoa.pdf.sig thgtoa.pdf
|
||||
gpg --verify thgtoa-dark.pdf.sig thgtoa-dark.pdf
|
||||
gpg --verify thgtoa.pdf.asc thgtoa.pdf
|
||||
gpg --verify thgtoa-dark.pdf.asc thgtoa-dark.pdf
|
||||
|
||||
# Verify hash files
|
||||
gpg --verify sha256sums.txt.sig sha256sums.txt
|
||||
gpg --verify b2sums.txt.sig b2sums.txt
|
||||
gpg --verify sha256sums.txt.asc sha256sums.txt
|
||||
gpg --verify b2sums.txt.asc b2sums.txt
|
||||
```
|
||||
|
||||
---
|
||||
@@ -204,9 +190,9 @@ jobs:
|
||||
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
|
||||
release/thgtoa.pdf.b2sum
|
||||
release/thgtoa-dark.pdf.b2sum
|
||||
release/thgtoa.pdf.asc
|
||||
release/thgtoa-dark.pdf.asc
|
||||
release/sha256sums.txt.asc
|
||||
release/b2sums.txt.asc
|
||||
|
||||
+21
-32
@@ -11,8 +11,8 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_run_id:
|
||||
description: 'build.yml run ID to download PDFs from (leave blank for latest)'
|
||||
required: false
|
||||
description: 'build.yml run ID to download PDFs from'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
@@ -39,31 +39,21 @@ jobs:
|
||||
with:
|
||||
sparse-checkout: pgp
|
||||
|
||||
# Download PDFs from the manually specified run ID (required for manual dispatch)
|
||||
- name: 📥 Resolve source run ID
|
||||
id: src
|
||||
run: |
|
||||
if [ -z "${{ inputs.build_run_id }}" ]; then
|
||||
echo "::error::build_run_id is required — provide the build.yml run ID to pull PDFs from."
|
||||
exit 1
|
||||
fi
|
||||
echo "run_id=${{ inputs.build_run_id }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 📥 Download PDF artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pdfs
|
||||
path: export/
|
||||
run-id: ${{ steps.src.outputs.run_id }}
|
||||
name: pdfs
|
||||
path: export/
|
||||
run-id: ${{ inputs.build_run_id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 📋 List downloaded files
|
||||
run: ls -lh export/
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Hash
|
||||
# Hash — extensions match export/ conventions: .sha256, .b2sum
|
||||
# ------------------------------------------------------------------ #
|
||||
- name: #️⃣ Hash PDFs
|
||||
- name: "#️⃣ Hash PDFs"
|
||||
id: hashes
|
||||
run: |
|
||||
cd export
|
||||
@@ -71,20 +61,19 @@ jobs:
|
||||
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"
|
||||
b2sum "$f" | awk '{print $1}' > "${f}.b2sum"
|
||||
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 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 "")
|
||||
light_b2=$(cat thgtoa.pdf.b2sum 2>/dev/null || echo "")
|
||||
dark_b2=$(cat thgtoa-dark.pdf.b2sum 2>/dev/null || echo "")
|
||||
|
||||
echo "light_sha256=$light_sha256" >> $GITHUB_OUTPUT
|
||||
echo "dark_sha256=$dark_sha256" >> $GITHUB_OUTPUT
|
||||
@@ -97,7 +86,7 @@ jobs:
|
||||
cat b2sums.txt
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# GPG sign (maintainer-verifiable detached signatures for release)
|
||||
# GPG sign — detached ASCII-armor signatures use .asc extension
|
||||
# ------------------------------------------------------------------ #
|
||||
- name: 🔑 Install GPG
|
||||
run: |
|
||||
@@ -122,8 +111,8 @@ jobs:
|
||||
[ -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"
|
||||
--detach-sign --armor --output "${file}.asc" "$file"
|
||||
echo "Signed: $file → ${file}.asc"
|
||||
}
|
||||
sign export/thgtoa.pdf
|
||||
sign export/thgtoa-dark.pdf
|
||||
@@ -166,7 +155,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Upload — PDFs + all signatures and hashes together
|
||||
# Upload artifacts for release.yml to consume
|
||||
# ------------------------------------------------------------------ #
|
||||
- name: 📤 Upload signatures artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -177,12 +166,12 @@ jobs:
|
||||
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
|
||||
export/thgtoa.pdf.b2sum
|
||||
export/thgtoa-dark.pdf.b2sum
|
||||
export/thgtoa.pdf.asc
|
||||
export/thgtoa-dark.pdf.asc
|
||||
export/sha256sums.txt.asc
|
||||
export/b2sums.txt.asc
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
compression-level: 0
|
||||
|
||||
+4
-4
@@ -21,9 +21,9 @@ _site/
|
||||
_site_test/
|
||||
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-dark.pdf.sha256
|
||||
export/thgtoa.pdf.b2
|
||||
export/thgtoa-dark.pdf.b2
|
||||
*.sig
|
||||
export/thgtoa.pdf.b2sum
|
||||
export/thgtoa-dark.pdf.b2sum
|
||||
export/*.asc
|
||||
|
||||
+198
-136
@@ -1,6 +1,6 @@
|
||||
# Developer Guide
|
||||
|
||||
This page covers everything you need to contribute to the project, run the build pipeline locally, configure GitHub secrets, and cut a signed release.
|
||||
This page covers everything you need to contribute to the project, run the build pipeline locally, configure GitHub Secrets, and publish a release.
|
||||
|
||||
---
|
||||
|
||||
@@ -49,27 +49,29 @@ You also need **Google Chrome** or **Microsoft Edge** installed for the light-mo
|
||||
|
||||
## Repository layout
|
||||
|
||||
```bash
|
||||
```
|
||||
.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 ← auto-updates docs/changelog/index.md
|
||||
publish.yml ← deploys MkDocs site to GitHub Pages
|
||||
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 (gitignored except .sha256, .b2, .sig)
|
||||
pgp/ ← public signing keys
|
||||
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
|
||||
tag_release.py ← interactive signed-tag helper for maintainers
|
||||
update_changelog.py← auto-generates changelog entries from git log
|
||||
setup_workflow.py ← GitHub Secrets setup assistant
|
||||
verify_pdf.py ← signature verification helper
|
||||
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)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -116,26 +118,41 @@ Opens at `http://127.0.0.1:8000`.
|
||||
|
||||
---
|
||||
|
||||
## Pushing changes
|
||||
## CI/CD pipeline overview
|
||||
|
||||
The pipeline triggers automatically when you push to `main` — no manual steps are needed for normal contributions.
|
||||
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.
|
||||
|
||||
```bash
|
||||
push to main
|
||||
│
|
||||
▼
|
||||
build.yml builds thgtoa.pdf + thgtoa-dark.pdf
|
||||
│ (workflow_run on success)
|
||||
▼
|
||||
sign.yml SHA-256 + BLAKE2b hashes, GPG detached signatures
|
||||
│ (workflow_run on success)
|
||||
▼
|
||||
release.yml VirusTotal scan → tagged GitHub Release
|
||||
│
|
||||
changelog.yml prepends new ## [vX.Y.Z] entry → commits back to main
|
||||
```
|
||||
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 runs independently and can be re-triggered manually from the Actions tab. If the build succeeds but signing fails (e.g. an expired key), you can re-run only `sign.yml` pointing at the existing build artifact without rebuilding the PDFs.
|
||||
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"
|
||||
|
||||
@@ -145,16 +162,127 @@ Each stage runs independently and can be re-triggered manually from the Actions
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
These must be configured 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.
|
||||
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
|
||||
# Export the release signing key
|
||||
gpg --armor --export-secret-keys C3023DBEA3FB38C438BA1EEDCEC60AEDE8B992A2
|
||||
```
|
||||
|
||||
@@ -163,111 +291,39 @@ Copy the entire output (including `-----BEGIN PGP PRIVATE KEY BLOCK-----` and th
|
||||
!!! 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 the release.
|
||||
|
||||
Get one by creating a free account at `virustotal.com` → API key under your profile. The free tier allows 4 lookups/minute and 500/day, which is sufficient for the two PDFs per release.
|
||||
|
||||
---
|
||||
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.
|
||||
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.
|
||||
|
||||
**Why it's needed:** `changelog.yml` commits back to `main` after each build. Commits made with the default `GITHUB_TOKEN` do not trigger further workflow runs (GitHub's loop-prevention policy). A PAT bypasses this so the changelog commit itself can be picked up by downstream workflows if needed.
|
||||
|
||||
**Creating one:**
|
||||
|
||||
1. Go to GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens
|
||||
2. Set repository access to **Only select repositories** → this repo
|
||||
3. Under Permissions → Repository permissions, set **Contents** to **Read and write**
|
||||
4. Set an expiration and add it as the `CHANGELOG_PAT` secret
|
||||
|
||||
If this secret is absent, `changelog.yml` falls back to `GITHUB_TOKEN` — the commit still happens, it just won't trigger further 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 `.sig` files produced |
|
||||
| `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 still updates, but commit won't trigger downstream workflows |
|
||||
|
||||
---
|
||||
|
||||
## Cutting a release
|
||||
|
||||
Releases are tagged manually by maintainers. The `tag_release.py` script handles everything interactively.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Your GPG keyring must contain the release signing key (`C302 3DBE A3FB 38C4 38BA 1EED CEC6 0AED E8B9 92A2`)
|
||||
- The working tree must be clean
|
||||
- You must be on the `main` branch
|
||||
- A `## [vX.Y.Z]` entry must exist in `docs/changelog/index.md` for the version you are tagging
|
||||
|
||||
### Import the release key (first time only)
|
||||
|
||||
```bash
|
||||
gpg --import pgp/anonymousplanet-release.asc
|
||||
```
|
||||
|
||||
### Run the release tagger
|
||||
|
||||
```bash
|
||||
python scripts/tag_release.py
|
||||
```
|
||||
|
||||
The script will:
|
||||
|
||||
1. Check the working tree is clean and you are on `main`
|
||||
2. Detect the latest tag and propose the next patch version
|
||||
3. Pull the matching changelog entry and format it as the tag message
|
||||
4. Show you the full tag message for review
|
||||
5. Ask for confirmation before creating anything
|
||||
6. Create a GPG-signed annotated tag with `git tag -s`
|
||||
7. Verify the signature
|
||||
8. Print the push command
|
||||
|
||||
To specify a version explicitly:
|
||||
|
||||
```bash
|
||||
python scripts/tag_release.py --version v1.2.4
|
||||
```
|
||||
|
||||
To preview without creating the tag:
|
||||
|
||||
```bash
|
||||
python scripts/tag_release.py --dry-run
|
||||
```
|
||||
|
||||
To use a different signing key:
|
||||
|
||||
```bash
|
||||
python scripts/tag_release.py --key <fingerprint>
|
||||
```
|
||||
|
||||
### Push the tag
|
||||
|
||||
```bash
|
||||
git push origin v1.2.4
|
||||
```
|
||||
|
||||
### Trigger the release workflow
|
||||
|
||||
Pushing a tag does **not** automatically trigger `release.yml` (it listens to `workflow_run` from `sign.yml`, not tag pushes). After pushing the tag, go to **Actions → Release → Run workflow** and paste the most recent `sign.yml` run ID to publish the GitHub Release.
|
||||
| `CHANGELOG_PAT` | `changelog.yml` | Falls back to `GITHUB_TOKEN` — changelog updates but commit won't trigger downstream workflows |
|
||||
|
||||
---
|
||||
|
||||
@@ -280,12 +336,12 @@ Anyone can verify the authenticity of a release download.
|
||||
gpg --import pgp/anonymousplanet-release.asc
|
||||
|
||||
# Verify the PDFs
|
||||
gpg --verify thgtoa.pdf.sig thgtoa.pdf
|
||||
gpg --verify thgtoa-dark.pdf.sig thgtoa-dark.pdf
|
||||
gpg --verify thgtoa.pdf.asc thgtoa.pdf
|
||||
gpg --verify thgtoa-dark.pdf.asc thgtoa-dark.pdf
|
||||
|
||||
# Verify the hash files themselves
|
||||
gpg --verify sha256sums.txt.sig sha256sums.txt
|
||||
gpg --verify b2sums.txt.sig b2sums.txt
|
||||
# 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
|
||||
@@ -294,7 +350,7 @@ b2sum -c b2sums.txt
|
||||
|
||||
A successful verify looks like:
|
||||
|
||||
```bash
|
||||
```
|
||||
gpg: Signature made ...
|
||||
gpg: Good signature from "Anonymous Planet (Release) ..."
|
||||
```
|
||||
@@ -304,22 +360,28 @@ gpg: Good signature from "Anonymous Planet (Release) ..."
|
||||
## Troubleshooting
|
||||
|
||||
**`cairosvg` missing during MkDocs build**
|
||||
Install the imaging extras: `pip install "mkdocs-material[imaging]"`. This is required by the `social` plugin.
|
||||
Install the imaging extras: `pip install "mkdocs-material[imaging]"`. Required by the `social` plugin.
|
||||
|
||||
**`KeyError: 'JPEG'` in convert.py**
|
||||
Pillow needs libjpeg for RGB→PDF encoding. The script works around this by quantizing to palette mode before saving, so this error should not appear with the current code. If it does, reinstall Pillow after installing libjpeg: `sudo apt install libjpeg-dev && pip install --force-reinstall pillow`.
|
||||
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`**
|
||||
An older version of `convert.py` tried to pass PNG files to qpdf. Make sure you are running the current version — qpdf only accepts PDF inputs to `--pages`.
|
||||
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 the header and footer lines.
|
||||
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 carefully with no surrounding whitespace.
|
||||
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 the workflow after a few minutes.
|
||||
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 `[^N]` in the body text. Add the citation where it belongs in the guide, or remove the orphaned definition.
|
||||
A footnote definition `[^N]:` exists without a matching inline citation. Add the citation or remove the orphaned definition.
|
||||
|
||||
@@ -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. |
|
||||
@@ -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 = "C3023DBEA3FB38C438BA1EEDCEC60AEDE8B992A2"
|
||||
|
||||
CHANGELOG = Path(__file__).resolve().parent.parent / "docs" / "changelog" / "index.md"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, check=check)
|
||||
|
||||
|
||||
def git(*args: str, check: bool = True) -> str:
|
||||
return run(["git", *args], check=check).stdout.strip()
|
||||
|
||||
|
||||
def check_gpg_installed() -> bool:
|
||||
try:
|
||||
return run(["gpg", "--version"], check=False).returncode == 0
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Git state checks
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def check_clean_tree() -> bool:
|
||||
"""Return True if the working tree and index are clean."""
|
||||
status = git("status", "--porcelain")
|
||||
return status == ""
|
||||
|
||||
|
||||
def current_branch() -> str:
|
||||
return git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
|
||||
|
||||
def latest_tag() -> str | None:
|
||||
"""Return the most recent vX.Y.Z tag, or None."""
|
||||
out = run(["git", "tag", "--sort=-version:refname", "--list", "v*"], check=False).stdout
|
||||
for line in out.splitlines():
|
||||
if re.match(r"^v\d+\.\d+\.\d+", line.strip()):
|
||||
return line.strip()
|
||||
return None
|
||||
|
||||
|
||||
def tag_exists(version: str) -> bool:
|
||||
out = run(["git", "tag", "--list", version], check=False).stdout.strip()
|
||||
return bool(out)
|
||||
|
||||
|
||||
def head_sha() -> str:
|
||||
return git("rev-parse", "HEAD")
|
||||
|
||||
|
||||
def short_sha() -> str:
|
||||
return git("rev-parse", "--short", "HEAD")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Version logic
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def auto_increment(tag: str | None) -> str:
|
||||
"""Bump the patch number of tag, or return v1.0.0 if no tag exists."""
|
||||
if not tag:
|
||||
return "v1.0.0"
|
||||
m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", tag)
|
||||
if not m:
|
||||
return "v1.0.0"
|
||||
return f"v{m.group(1)}.{m.group(2)}.{int(m.group(3)) + 1}"
|
||||
|
||||
|
||||
def normalise_version(v: str) -> str:
|
||||
return v if v.startswith("v") else f"v{v}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Changelog parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def extract_changelog_entry(version: str) -> str | None:
|
||||
"""Return the raw text of the ## [version] section, or None if not found."""
|
||||
if not CHANGELOG.exists():
|
||||
return None
|
||||
|
||||
content = CHANGELOG.read_text(encoding="utf-8")
|
||||
# Find the heading for this version
|
||||
pattern = re.compile(
|
||||
rf"^(## \[{re.escape(version)}\].*?)(?=^## \[|\Z)",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
m = pattern.search(content)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1).strip()
|
||||
|
||||
|
||||
def changelog_to_tag_message(version: str, entry: str | None, sha: str) -> str:
|
||||
"""Convert a changelog entry into a clean tag message."""
|
||||
if not entry:
|
||||
return f"{version}\n\nNo changelog entry found for {version}.\nCommit: {sha}"
|
||||
|
||||
lines = []
|
||||
current_bucket = None
|
||||
|
||||
for line in entry.splitlines():
|
||||
# Skip the ## [vX.Y.Z] heading — we'll add our own first line
|
||||
if re.match(r"^## \[", line):
|
||||
continue
|
||||
# Skip admonition headers like !!! Note "Added" → use bucket name as section
|
||||
m = re.match(r'^!!!\s+\w+\s+"(.+)"', line)
|
||||
if m:
|
||||
current_bucket = m.group(1)
|
||||
lines.append(f"\n{current_bucket}:")
|
||||
continue
|
||||
# Skip Meta bucket entirely
|
||||
if current_bucket == "Meta":
|
||||
continue
|
||||
# Strip 4-space admonition indent and leading bullet dash
|
||||
stripped = re.sub(r"^ {4}", "", line)
|
||||
stripped = re.sub(r"^- ", " - ", stripped)
|
||||
# Skip blank lines inside admonitions (keep structure clean)
|
||||
if stripped.strip():
|
||||
lines.append(stripped)
|
||||
|
||||
body = "\n".join(lines).strip()
|
||||
return f"{version}\n\n{body}\n\nCommit: {sha}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GPG key selection
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def list_secret_keys() -> list[dict]:
|
||||
"""Return a list of available secret keys with fingerprint and uid."""
|
||||
result = run(["gpg", "--list-secret-keys", "--with-colons"], check=False)
|
||||
keys = []
|
||||
current: dict = {}
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split(":")
|
||||
if parts[0] == "sec":
|
||||
if current:
|
||||
keys.append(current)
|
||||
current = {"fingerprint": None, "uid": None, "key_id": parts[4] if len(parts) > 4 else ""}
|
||||
elif parts[0] == "fpr" and current is not None:
|
||||
if not current.get("fingerprint"):
|
||||
current["fingerprint"] = parts[9] if len(parts) > 9 else ""
|
||||
elif parts[0] == "uid" and current is not None:
|
||||
if not current.get("uid"):
|
||||
current["uid"] = parts[9] if len(parts) > 9 else ""
|
||||
if current:
|
||||
keys.append(current)
|
||||
return keys
|
||||
|
||||
|
||||
def resolve_signing_key(preferred: str) -> str | None:
|
||||
"""
|
||||
Return the fingerprint to use for signing.
|
||||
Prefers the key matching `preferred` (full fingerprint or key ID suffix).
|
||||
Falls back to interactive selection if not found.
|
||||
"""
|
||||
keys = list_secret_keys()
|
||||
if not keys:
|
||||
return None
|
||||
|
||||
# Try to match preferred fingerprint/key ID
|
||||
preferred_upper = preferred.upper()
|
||||
for k in keys:
|
||||
fp = (k.get("fingerprint") or "").upper()
|
||||
kid = (k.get("key_id") or "").upper()
|
||||
if preferred_upper in (fp, kid) or fp.endswith(preferred_upper) or kid.endswith(preferred_upper):
|
||||
return k["fingerprint"]
|
||||
|
||||
# Not found — show what's available and ask
|
||||
print("\n⚠ Preferred key not found in keyring. Available keys:\n")
|
||||
for i, k in enumerate(keys, 1):
|
||||
print(f" {i}. {k.get('fingerprint', 'N/A')}")
|
||||
print(f" {k.get('uid', 'Unknown')}\n")
|
||||
|
||||
try:
|
||||
choice = input(f"Select key (1–{len(keys)}) or press Enter to abort: ").strip()
|
||||
if not choice:
|
||||
return None
|
||||
idx = int(choice) - 1
|
||||
if 0 <= idx < len(keys):
|
||||
return keys[idx]["fingerprint"]
|
||||
except (ValueError, EOFError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tag creation and verification
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def create_signed_tag(version: str, key_fingerprint: str, message: str, dry_run: bool) -> bool:
|
||||
"""Create a GPG-signed annotated tag. Returns True on success."""
|
||||
sha = head_sha()
|
||||
cmd = [
|
||||
"git", "tag",
|
||||
"-s", version,
|
||||
sha,
|
||||
"-u", key_fingerprint,
|
||||
"-m", message,
|
||||
]
|
||||
|
||||
print(f"\n{'─' * 70}")
|
||||
print(f" Tag: {version}")
|
||||
print(f" Commit: {sha[:12]}")
|
||||
print(f" Key: {key_fingerprint}")
|
||||
print(f"{'─' * 70}")
|
||||
print("\nTag message:\n")
|
||||
for line in message.splitlines():
|
||||
print(f" {line}")
|
||||
print()
|
||||
|
||||
if dry_run:
|
||||
print("── DRY RUN — tag not created. Remove --dry-run to proceed.\n")
|
||||
return True
|
||||
|
||||
confirm = input("Create this tag? [y/N] ").strip().lower()
|
||||
if confirm != "y":
|
||||
print("Aborted.")
|
||||
return False
|
||||
|
||||
result = run(cmd, check=False)
|
||||
if result.returncode != 0:
|
||||
print(f"\n✗ Tag creation failed:\n{result.stderr}")
|
||||
return False
|
||||
|
||||
print(f"\n✓ Tag {version} created.")
|
||||
return True
|
||||
|
||||
|
||||
def verify_tag(version: str) -> bool:
|
||||
"""Verify the GPG signature on the tag."""
|
||||
result = run(["git", "tag", "-v", version], check=False)
|
||||
output = result.stdout + result.stderr
|
||||
if result.returncode == 0:
|
||||
print("\n✓ Tag signature verified:")
|
||||
for line in output.splitlines():
|
||||
if any(k in line for k in ("gpg:", "Primary", "using", "issuer", "Good")):
|
||||
print(f" {line.strip()}")
|
||||
return True
|
||||
else:
|
||||
print(f"\n✗ Tag signature verification failed:\n{output}")
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Main
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Create a GPG-signed release tag from the changelog."
|
||||
)
|
||||
ap.add_argument(
|
||||
"--version", "-v",
|
||||
help="Version to tag, e.g. v1.2.4 (default: auto-increment from latest tag)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--key", "-k",
|
||||
default=DEFAULT_SIGNING_KEY,
|
||||
help=f"GPG key fingerprint to sign with (default: {DEFAULT_SIGNING_KEY})",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--dry-run", "-n",
|
||||
action="store_true",
|
||||
help="Print the tag message and exit without creating the tag",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
root = repo_root()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" RELEASE TAGGER — Anonymous Planet")
|
||||
print("=" * 70)
|
||||
|
||||
# 1. GPG available?
|
||||
if not check_gpg_installed():
|
||||
print("\n✗ GPG is not installed or not in PATH.")
|
||||
print(" Install with: sudo apt install gnupg | brew install gnupg")
|
||||
return 1
|
||||
|
||||
# 2. Working tree clean?
|
||||
if not check_clean_tree():
|
||||
print("\n✗ Working tree is not clean. Commit or stash changes first.")
|
||||
result = run(["git", "status", "--short"], check=False)
|
||||
print(result.stdout)
|
||||
return 1
|
||||
|
||||
# 3. On main?
|
||||
branch = current_branch()
|
||||
if branch != "main":
|
||||
print(f"\n⚠ You are on branch '{branch}', not 'main'.")
|
||||
confirm = input(" Tag anyway? [y/N] ").strip().lower()
|
||||
if confirm != "y":
|
||||
return 1
|
||||
|
||||
# 4. Resolve version
|
||||
last_tag = latest_tag()
|
||||
version = normalise_version(args.version) if args.version else auto_increment(last_tag)
|
||||
print(f"\n Latest tag: {last_tag or '(none)'}")
|
||||
print(f" New version: {version}")
|
||||
print(f" Branch: {branch}")
|
||||
print(f" HEAD: {short_sha()}")
|
||||
|
||||
if tag_exists(version) and not args.dry_run:
|
||||
print(f"\n✗ Tag {version} already exists.")
|
||||
print(" Use --version to specify a different version, or delete the tag first:")
|
||||
print(f" git tag -d {version}")
|
||||
return 1
|
||||
|
||||
# 5. Build tag message from changelog
|
||||
entry = extract_changelog_entry(version)
|
||||
if not entry:
|
||||
print(f"\n⚠ No changelog entry found for {version}.")
|
||||
print(f" Add a '## [{version}]' section to {CHANGELOG.relative_to(root)}")
|
||||
print(" and re-run, or proceed with a minimal message.")
|
||||
confirm = input(" Proceed with minimal message? [y/N] ").strip().lower()
|
||||
if confirm != "y":
|
||||
return 1
|
||||
|
||||
message = changelog_to_tag_message(version, entry, head_sha())
|
||||
|
||||
# 6. Resolve signing key
|
||||
print(f"\n Signing key: {args.key}")
|
||||
key = resolve_signing_key(args.key)
|
||||
if not key:
|
||||
print("\n✗ Could not resolve a signing key. Aborting.")
|
||||
print(" Import the release key with:")
|
||||
print(" gpg --import pgp/anonymousplanet-release.asc")
|
||||
return 1
|
||||
print(f" Resolved to: {key}")
|
||||
|
||||
# 7. Create tag
|
||||
if not create_signed_tag(version, key, message, args.dry_run):
|
||||
return 1
|
||||
|
||||
if args.dry_run:
|
||||
return 0
|
||||
|
||||
# 8. Verify
|
||||
if not verify_tag(version):
|
||||
return 1
|
||||
|
||||
# 9. Push instructions
|
||||
print("\n" + "=" * 70)
|
||||
print(" ✓ All done. Push the tag with:")
|
||||
print(f"\n git push origin {version}\n")
|
||||
print(" The release.yml workflow can then be triggered manually from")
|
||||
print(" GitHub Actions to publish the GitHub Release for this tag.")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+28
-39
@@ -2,14 +2,18 @@
|
||||
"""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,
|
||||
changelog version, categorises commits by conventional-commit prefix,
|
||||
and prepends a new ## [vX.Y.Z] section in the MkDocs admonition format used
|
||||
by the rest of the file.
|
||||
|
||||
Environment variables (all optional — reasonable defaults apply):
|
||||
MANUAL_VERSION Override the auto-incremented version string.
|
||||
Environment variables:
|
||||
MANUAL_VERSION Version string to record (required when run from CI).
|
||||
Falls back to auto-increment from the changelog for local runs.
|
||||
TRIGGERING_SHA The commit SHA that triggered this run (used as range end).
|
||||
DRY_RUN If "true", print the entry and exit without writing.
|
||||
|
||||
Note: version is sourced from the changelog file, not from git tags. Git tags
|
||||
are no longer used as the version authority. The changelog is the source of truth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +21,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -52,15 +55,6 @@ def run(cmd: list[str]) -> str:
|
||||
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:
|
||||
@@ -73,7 +67,11 @@ def auto_increment_version(current: str | None) -> str:
|
||||
|
||||
|
||||
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():
|
||||
return None
|
||||
for line in CHANGELOG.read_text(encoding="utf-8").splitlines():
|
||||
@@ -86,22 +84,17 @@ def version_from_changelog() -> str | None:
|
||||
def commits_since(ref: str | None, until: str) -> list[str]:
|
||||
"""Return one-line commit messages between ref and until (exclusive/inclusive).
|
||||
|
||||
When no ref is given (no prior tag exists) we fall back to the merge-base
|
||||
between HEAD and origin/main rather than walking the entire history, which
|
||||
would otherwise dump every commit ever made into the changelog.
|
||||
When no ref is given we fall back to the merge-base between HEAD and
|
||||
origin/main to avoid dumping the entire history into the changelog.
|
||||
"""
|
||||
if ref:
|
||||
log_range = f"{ref}..{until}"
|
||||
else:
|
||||
# No previous tag — scope to commits not yet on origin/main
|
||||
merge_base = run(
|
||||
["git", "merge-base", "HEAD", "origin/main"], check=False
|
||||
).stdout.strip()
|
||||
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
|
||||
# to avoid dumping the whole history
|
||||
log_range = f"-50 {until}"
|
||||
out = run(["git", "log", "--pretty=format:%s", log_range])
|
||||
return [line.strip() for line in out.splitlines() if line.strip()]
|
||||
@@ -111,10 +104,9 @@ 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}
|
||||
|
||||
# Patterns that are never useful in a human-readable changelog
|
||||
NOISE = re.compile(
|
||||
r"""
|
||||
\[skip\ ci\] # CI skip marker
|
||||
\[skip\ ci\] # CI skip marker
|
||||
| ^Merge\ (pull\ request|branch) # merge commits
|
||||
| ^chore:\ bump # version bump chores
|
||||
| update\ changelog # self-referential
|
||||
@@ -133,11 +125,9 @@ def categorise(messages: list[str]) -> dict[str, list[str]]:
|
||||
)
|
||||
|
||||
for msg in messages:
|
||||
# Skip noise
|
||||
if NOISE.search(msg):
|
||||
continue
|
||||
|
||||
# Strip conventional-commit prefix to get the plain description
|
||||
m = re.match(r"^(\w+)(?:\([^)]+\))?!?:\s*(.+)$", msg)
|
||||
if m:
|
||||
prefix = m.group(1).lower()
|
||||
@@ -147,7 +137,6 @@ def categorise(messages: list[str]) -> dict[str, list[str]]:
|
||||
description = msg
|
||||
bucket = "Changed"
|
||||
|
||||
# Capitalise first letter
|
||||
description = description[0].upper() + description[1:] if description else description
|
||||
buckets[bucket].append(description)
|
||||
|
||||
@@ -165,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:
|
||||
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
lines = [f"## [{version}]", ""]
|
||||
lines.append(f'!!! Note "Meta"')
|
||||
lines.append('!!! Note "Meta"')
|
||||
lines.append("")
|
||||
lines.append(f" - Released {date} from [`{sha[:7]}`](https://github.com/Anon-Planet/thgtoa/commit/{sha})")
|
||||
lines.append("")
|
||||
@@ -174,7 +163,6 @@ def build_entry(version: str, buckets: dict[str, list[str]], sha: str) -> str:
|
||||
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("")
|
||||
@@ -188,10 +176,8 @@ 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 :]
|
||||
@@ -210,13 +196,11 @@ def main() -> int:
|
||||
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)
|
||||
# Version authority: MANUAL_VERSION (required in CI) → changelog → auto-increment.
|
||||
# Git tags are intentionally not consulted.
|
||||
last_cl_ver = version_from_changelog()
|
||||
new_version = manual_version or auto_increment_version(last_cl_ver)
|
||||
|
||||
print(f"Last 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}")
|
||||
@@ -225,8 +209,13 @@ def main() -> int:
|
||||
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)
|
||||
if already_has_version(new_version) and manual_version:
|
||||
print(f"::error::Changelog already contains {new_version}. Choose a different version.")
|
||||
return 1
|
||||
|
||||
# Collect commits since the last changelog version (using it as a git ref
|
||||
# is a best-effort — if the tag doesn't exist, commits_since handles it gracefully).
|
||||
messages = commits_since(last_cl_ver, triggering_sha)
|
||||
print(f"Commits found: {len(messages)}")
|
||||
for m in messages:
|
||||
print(f" {m}")
|
||||
|
||||
Reference in New Issue
Block a user