From cdc54d8b3bc2b286827b23921d8d4062f85295cf Mon Sep 17 00:00:00 2001 From: nopeitsnothing Date: Fri, 22 May 2026 17:46:37 -0400 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20add=20tag=5Frelease.py=20?= =?UTF-8?q?=E2=80=94=20guided=20signed=20release=20tagger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive script for maintainers to create GPG-signed annotated tags. Checks clean tree and branch, auto-increments version from latest tag, pulls the message from the matching changelog entry, resolves the release signing key (default: 9FA5436D0EE360985157382517ECA05F768DEDF6), creates the tag, verifies the signature, then prints the push command. Usage: python scripts/tag_release.py # auto version python scripts/tag_release.py --version v1.2.4 python scripts/tag_release.py --dry-run # preview only Signed-off-by: nopeitsnothing --- scripts/tag_release.py | 407 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 scripts/tag_release.py diff --git a/scripts/tag_release.py b/scripts/tag_release.py new file mode 100644 index 0000000..8ba255a --- /dev/null +++ b/scripts/tag_release.py @@ -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 + 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 = "9FA5436D0EE360985157382517ECA05F768DEDF6" + +CHANGELOG = Path(__file__).resolve().parent.parent / "docs" / "changelog" / "index.md" + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, check=check) + + +def git(*args: str, check: bool = True) -> str: + return run(["git", *args], check=check).stdout.strip() + + +def check_gpg_installed() -> bool: + try: + return run(["gpg", "--version"], check=False).returncode == 0 + except FileNotFoundError: + return False + + +# --------------------------------------------------------------------------- # +# Git state checks +# --------------------------------------------------------------------------- # + +def check_clean_tree() -> bool: + """Return True if the working tree and index are clean.""" + status = git("status", "--porcelain") + return status == "" + + +def current_branch() -> str: + return git("rev-parse", "--abbrev-ref", "HEAD") + + +def latest_tag() -> str | None: + """Return the most recent vX.Y.Z tag, or None.""" + out = run(["git", "tag", "--sort=-version:refname", "--list", "v*"], check=False).stdout + for line in out.splitlines(): + if re.match(r"^v\d+\.\d+\.\d+", line.strip()): + return line.strip() + return None + + +def tag_exists(version: str) -> bool: + out = run(["git", "tag", "--list", version], check=False).stdout.strip() + return bool(out) + + +def head_sha() -> str: + return git("rev-parse", "HEAD") + + +def short_sha() -> str: + return git("rev-parse", "--short", "HEAD") + + +# --------------------------------------------------------------------------- # +# Version logic +# --------------------------------------------------------------------------- # + +def auto_increment(tag: str | None) -> str: + """Bump the patch number of tag, or return v1.0.0 if no tag exists.""" + if not tag: + return "v1.0.0" + m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", tag) + if not m: + return "v1.0.0" + return f"v{m.group(1)}.{m.group(2)}.{int(m.group(3)) + 1}" + + +def normalise_version(v: str) -> str: + return v if v.startswith("v") else f"v{v}" + + +# --------------------------------------------------------------------------- # +# Changelog parsing +# --------------------------------------------------------------------------- # + +def extract_changelog_entry(version: str) -> str | None: + """Return the raw text of the ## [version] section, or None if not found.""" + if not CHANGELOG.exists(): + return None + + content = CHANGELOG.read_text(encoding="utf-8") + # Find the heading for this version + pattern = re.compile( + rf"^(## \[{re.escape(version)}\].*?)(?=^## \[|\Z)", + re.MULTILINE | re.DOTALL, + ) + m = pattern.search(content) + if not m: + return None + return m.group(1).strip() + + +def changelog_to_tag_message(version: str, entry: str | None, sha: str) -> str: + """Convert a changelog entry into a clean tag message.""" + if not entry: + return f"{version}\n\nNo changelog entry found for {version}.\nCommit: {sha}" + + lines = [] + current_bucket = None + + for line in entry.splitlines(): + # Skip the ## [vX.Y.Z] heading — we'll add our own first line + if re.match(r"^## \[", line): + continue + # Skip admonition headers like !!! Note "Added" → use bucket name as section + m = re.match(r'^!!!\s+\w+\s+"(.+)"', line) + if m: + current_bucket = m.group(1) + lines.append(f"\n{current_bucket}:") + continue + # Skip Meta bucket entirely + if current_bucket == "Meta": + continue + # Strip 4-space admonition indent and leading bullet dash + stripped = re.sub(r"^ {4}", "", line) + stripped = re.sub(r"^- ", " - ", stripped) + # Skip blank lines inside admonitions (keep structure clean) + if stripped.strip(): + lines.append(stripped) + + body = "\n".join(lines).strip() + return f"{version}\n\n{body}\n\nCommit: {sha}" + + +# --------------------------------------------------------------------------- # +# GPG key selection +# --------------------------------------------------------------------------- # + +def list_secret_keys() -> list[dict]: + """Return a list of available secret keys with fingerprint and uid.""" + result = run(["gpg", "--list-secret-keys", "--with-colons"], check=False) + keys = [] + current: dict = {} + for line in result.stdout.splitlines(): + parts = line.split(":") + if parts[0] == "sec": + if current: + keys.append(current) + current = {"fingerprint": None, "uid": None, "key_id": parts[4] if len(parts) > 4 else ""} + elif parts[0] == "fpr" and current is not None: + if not current.get("fingerprint"): + current["fingerprint"] = parts[9] if len(parts) > 9 else "" + elif parts[0] == "uid" and current is not None: + if not current.get("uid"): + current["uid"] = parts[9] if len(parts) > 9 else "" + if current: + keys.append(current) + return keys + + +def resolve_signing_key(preferred: str) -> str | None: + """ + Return the fingerprint to use for signing. + Prefers the key matching `preferred` (full fingerprint or key ID suffix). + Falls back to interactive selection if not found. + """ + keys = list_secret_keys() + if not keys: + return None + + # Try to match preferred fingerprint/key ID + preferred_upper = preferred.upper() + for k in keys: + fp = (k.get("fingerprint") or "").upper() + kid = (k.get("key_id") or "").upper() + if preferred_upper in (fp, kid) or fp.endswith(preferred_upper) or kid.endswith(preferred_upper): + return k["fingerprint"] + + # Not found — show what's available and ask + print("\n⚠ Preferred key not found in keyring. Available keys:\n") + for i, k in enumerate(keys, 1): + print(f" {i}. {k.get('fingerprint', 'N/A')}") + print(f" {k.get('uid', 'Unknown')}\n") + + try: + choice = input(f"Select key (1–{len(keys)}) or press Enter to abort: ").strip() + if not choice: + return None + idx = int(choice) - 1 + if 0 <= idx < len(keys): + return keys[idx]["fingerprint"] + except (ValueError, EOFError): + pass + + return None + + +# --------------------------------------------------------------------------- # +# Tag creation and verification +# --------------------------------------------------------------------------- # + +def create_signed_tag(version: str, key_fingerprint: str, message: str, dry_run: bool) -> bool: + """Create a GPG-signed annotated tag. Returns True on success.""" + sha = head_sha() + cmd = [ + "git", "tag", + "-s", version, + sha, + "-u", key_fingerprint, + "-m", message, + ] + + print(f"\n{'─' * 70}") + print(f" Tag: {version}") + print(f" Commit: {sha[:12]}") + print(f" Key: {key_fingerprint}") + print(f"{'─' * 70}") + print("\nTag message:\n") + for line in message.splitlines(): + print(f" {line}") + print() + + if dry_run: + print("── DRY RUN — tag not created. Remove --dry-run to proceed.\n") + return True + + confirm = input("Create this tag? [y/N] ").strip().lower() + if confirm != "y": + print("Aborted.") + return False + + result = run(cmd, check=False) + if result.returncode != 0: + print(f"\n✗ Tag creation failed:\n{result.stderr}") + return False + + print(f"\n✓ Tag {version} created.") + return True + + +def verify_tag(version: str) -> bool: + """Verify the GPG signature on the tag.""" + result = run(["git", "tag", "-v", version], check=False) + output = result.stdout + result.stderr + if result.returncode == 0: + print("\n✓ Tag signature verified:") + for line in output.splitlines(): + if any(k in line for k in ("gpg:", "Primary", "using", "issuer", "Good")): + print(f" {line.strip()}") + return True + else: + print(f"\n✗ Tag signature verification failed:\n{output}") + return False + + +# --------------------------------------------------------------------------- # +# Main +# --------------------------------------------------------------------------- # + +def main() -> int: + ap = argparse.ArgumentParser( + description="Create a GPG-signed release tag from the changelog." + ) + ap.add_argument( + "--version", "-v", + help="Version to tag, e.g. v1.2.4 (default: auto-increment from latest tag)", + ) + ap.add_argument( + "--key", "-k", + default=DEFAULT_SIGNING_KEY, + help=f"GPG key fingerprint to sign with (default: {DEFAULT_SIGNING_KEY})", + ) + ap.add_argument( + "--dry-run", "-n", + action="store_true", + help="Print the tag message and exit without creating the tag", + ) + args = ap.parse_args() + + root = repo_root() + + print("\n" + "=" * 70) + print(" RELEASE TAGGER — Anonymous Planet") + print("=" * 70) + + # 1. GPG available? + if not check_gpg_installed(): + print("\n✗ GPG is not installed or not in PATH.") + print(" Install with: sudo apt install gnupg | brew install gnupg") + return 1 + + # 2. Working tree clean? + if not check_clean_tree(): + print("\n✗ Working tree is not clean. Commit or stash changes first.") + result = run(["git", "status", "--short"], check=False) + print(result.stdout) + return 1 + + # 3. On main? + branch = current_branch() + if branch != "main": + print(f"\n⚠ You are on branch '{branch}', not 'main'.") + confirm = input(" Tag anyway? [y/N] ").strip().lower() + if confirm != "y": + return 1 + + # 4. Resolve version + last_tag = latest_tag() + version = normalise_version(args.version) if args.version else auto_increment(last_tag) + print(f"\n Latest tag: {last_tag or '(none)'}") + print(f" New version: {version}") + print(f" Branch: {branch}") + print(f" HEAD: {short_sha()}") + + if tag_exists(version) and not args.dry_run: + print(f"\n✗ Tag {version} already exists.") + print(" Use --version to specify a different version, or delete the tag first:") + print(f" git tag -d {version}") + return 1 + + # 5. Build tag message from changelog + entry = extract_changelog_entry(version) + if not entry: + print(f"\n⚠ No changelog entry found for {version}.") + print(f" Add a '## [{version}]' section to {CHANGELOG.relative_to(root)}") + print(" and re-run, or proceed with a minimal message.") + confirm = input(" Proceed with minimal message? [y/N] ").strip().lower() + if confirm != "y": + return 1 + + message = changelog_to_tag_message(version, entry, head_sha()) + + # 6. Resolve signing key + print(f"\n Signing key: {args.key}") + key = resolve_signing_key(args.key) + if not key: + print("\n✗ Could not resolve a signing key. Aborting.") + print(" Import the release key with:") + print(" gpg --import pgp/anonymousplanet-release.asc") + return 1 + print(f" Resolved to: {key}") + + # 7. Create tag + if not create_signed_tag(version, key, message, args.dry_run): + return 1 + + if args.dry_run: + return 0 + + # 8. Verify + if not verify_tag(version): + return 1 + + # 9. Push instructions + print("\n" + "=" * 70) + print(" ✓ All done. Push the tag with:") + print(f"\n git push origin {version}\n") + print(" The release.yml workflow can then be triggered manually from") + print(" GitHub Actions to publish the GitHub Release for this tag.") + print("=" * 70 + "\n") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())