From d1817e9049826988a4df9b81a368fbb31a9e165e Mon Sep 17 00:00:00 2001 From: nopeitsnothing Date: Wed, 27 May 2026 23:45:09 -0400 Subject: [PATCH] ci(pipeline): more meta changes to the pipeline pre-commit install --install-hooks --- .cz.toml | 9 +- .pre-commit-config.yaml | 8 + scripts/hooks/require-commit-body.sh | 58 ++++ scripts/tag_release.py | 407 --------------------------- 4 files changed, 70 insertions(+), 412 deletions(-) create mode 100644 scripts/hooks/require-commit-body.sh delete mode 100644 scripts/tag_release.py diff --git a/.cz.toml b/.cz.toml index ca9def6..970ba4f 100644 --- a/.cz.toml +++ b/.cz.toml @@ -1,10 +1,9 @@ [tool.commitizen] name = "cz_conventional_commits" -# 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"] +# enforce sign-off below as well +extra_arguments = ["-S", "--signoff"] +# harmless redundancy [tool.commitizen.customize] -schema_pattern = '^(feat|feature|add|fix|bugfix|revert|security|perf|refactor|change|chore|ci|docs|style|test|build)(\(.+\))?(!)?: .{1,72}(\n.*)*$' +schema_pattern = '^(feat|add|fix|bugfix|revert|security|perf|refactor|change|chore|ci|docs|style|test|build)(\(.+\))?(!)?: .{1,72}(\n.*)*\nSigned-off-by: .+ <.+@.+>' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 206545e..3cbd118 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,3 +20,11 @@ repos: hooks: - id: commitizen 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] diff --git a/scripts/hooks/require-commit-body.sh b/scripts/hooks/require-commit-body.sh new file mode 100644 index 0000000..d81452e --- /dev/null +++ b/scripts/hooks/require-commit-body.sh @@ -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 diff --git a/scripts/tag_release.py b/scripts/tag_release.py deleted file mode 100644 index 3147757..0000000 --- a/scripts/tag_release.py +++ /dev/null @@ -1,407 +0,0 @@ -#!/usr/bin/env python3 -"""Release tagging helper for Anonymous Planet maintainers. - -Creates a GPG-signed annotated git tag using the release key, with a -message derived from the changelog entry for that version. - -Usage: - python scripts/tag_release.py # auto-detect next version - python scripts/tag_release.py --version v1.2.4 - python scripts/tag_release.py --version v1.2.4 --key - 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 -m - 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())