mirror of
https://github.com/Anon-Planet/thgtoa.git
synced 2026-06-11 00:02:29 +02:00
ci(pipeline): more meta changes to the pipeline
pre-commit install --install-hooks
This commit is contained in:
@@ -1,10 +1,9 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
|
|
||||||
# Version management is handled manually via the changelog.
|
# enforce sign-off below as well
|
||||||
# commitizen is used here only to enforce conventional commit message format.
|
extra_arguments = ["-S", "--signoff"]
|
||||||
# -S ensures SSH signing is applied to every commit regardless of global git config state.
|
|
||||||
extra_arguments = ["-S"]
|
|
||||||
|
|
||||||
|
# harmless redundancy
|
||||||
[tool.commitizen.customize]
|
[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: .+ <.+@.+>'
|
||||||
|
|||||||
@@ -20,3 +20,11 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: commitizen
|
- id: commitizen
|
||||||
stages: [commit-msg]
|
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]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 <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())
|
|
||||||
Reference in New Issue
Block a user