Nick's directive: when the pipeline earns a new version, the release body must carry a plain-English summary of what was worked on and submitted — the counterpart of the issue-entry template, part of the Gitea Actions pipeline (auto-version.yml), not a manual step. - scripts/release_exit_note.py: renders the exit note from the push range (git log v<last>..HEAD, chore(release) excluded) — conventional- commit prefix → kind classification (added/fixed/improved), reader- facing translation (contract refs, parity language, truth batteries), aggregate headline + per-item bullets + verification summary - auto-version.yml: wired into the release step (LAST_TAG → release_exit_note.py → --exit-note flag on release_history_cli init) - release_history_cli.py: consumes --exit-note, parses the markdown into structured extras (headline + items) passed to auto_initial_note(extra=...) - release_notes.py: auto_initial_note accepts extra (merged into improved/added/fixed after fact extraction, deduped) - scripts/backfill_exit_notes.py: retroactive backfill for the 27 pre-template releases (executed — all patched) The exit note is now a first-class pipeline citizen: every earned version carries the human narrative alongside the governed evidence.
161 lines
5.5 KiB
Python
161 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""release_exit_note.py — the EXIT-NOTE TEMPLATE (Nick, 2026-10-08).
|
|
|
|
The counterpart of the issue-entry template: when the pipeline earns a new
|
|
version, this renders a PLAIN-ENGLISH summary of what was worked on and
|
|
submitted, so version history reads without opening commits.
|
|
|
|
Template (what the reader sees):
|
|
## What changed in v<VERSION>
|
|
|
|
<aggregate paragraph — counts by kind, the headline of the wave>
|
|
|
|
### What was worked on
|
|
- <commit subject, translated to plain English>
|
|
|
|
### How it was verified
|
|
- contracts walked (BUILDER-...) / truth batteries / CI gates — one
|
|
line per verification kind found in the range
|
|
|
|
Feed: git log v<last-tag>..HEAD (chore(release) bumps excluded), the
|
|
conventional-commit prefix gives the kind; the subject's scope gives the
|
|
plain-English translation. The release step calls render() and passes the
|
|
result as `extra.improved`/`added`/`fixed` per kind, so the evidence-
|
|
derived note carries the human narrative alongside the governed facts.
|
|
|
|
Usage:
|
|
python3 scripts/release_exit_note.py --since v0.117.0 [--to HEAD]
|
|
python3 scripts/release_exit_note.py --since v0.117.0 --json # machine
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
# Conventional-commit prefix → (plain-english verb phrase, release-notes key)
|
|
KIND_MAP = {
|
|
"feat": ("added", "added"),
|
|
"fix": ("fixed", "fixed"),
|
|
"test": ("verified by test", "improved"),
|
|
"docs": ("documented", "improved"),
|
|
"chore": ("maintained", "improved"),
|
|
"refactor": ("restructured", "improved"),
|
|
"perf": ("made faster", "improved"),
|
|
}
|
|
|
|
# Phrases in commit subjects translated to reader language
|
|
TRANSLATIONS = [
|
|
(r"\bcontract BUILDER-\S+", "under a construction contract"),
|
|
(r"\bparity(?! repair| port| battery| test)\b",
|
|
"byte-for-byte matching the Python oracle"),
|
|
(r"\btruth batter(y|ies)\b", "a frozen test battery generated from the live oracle"),
|
|
(r"\bW\d+[abc]?\b", ""),
|
|
(r"\((?:W\d+[abc]?|[^)]*contract[^)]*)\)", ""),
|
|
]
|
|
|
|
_NOISE_RE = re.compile(r"^chore\(release\): auto-bump|Merge (remote|pull)|^sync:")
|
|
|
|
|
|
def commit_range(since, to="HEAD"):
|
|
r = subprocess.run(
|
|
["git", "log", f"{since}..{to}", "--format=%s", "--no-merges"],
|
|
capture_output=True, text=True, cwd=REPO,
|
|
)
|
|
return [s.strip() for s in r.stdout.splitlines() if s.strip() and not _NOISE_RE.match(s)]
|
|
|
|
|
|
def classify(subject):
|
|
m = re.match(r"^(\w+)(?:\(([^)]*)\))?:\s*(.+)", subject)
|
|
if not m:
|
|
return "improved", subject, None
|
|
kind, scope, rest = m.group(1), m.group(2), m.group(3)
|
|
verb, key = KIND_MAP.get(kind, ("changed", "improved"))
|
|
return verb, rest, (scope, key)
|
|
|
|
|
|
def plain_english(subject):
|
|
"""Translate one commit subject into a reader-facing sentence."""
|
|
verb, rest, scope_key = classify(subject)
|
|
text = rest[0].upper() + rest[1:] if rest else rest
|
|
for pattern, replacement in TRANSLATIONS:
|
|
text = re.sub(pattern, replacement, text)
|
|
text = re.sub(r"\s+", " ", text).strip(" -,")
|
|
if scope_key and scope_key[0]:
|
|
return f"{text} ({scope_key[0]}) — {verb}"
|
|
return f"{text} — {verb}"
|
|
|
|
|
|
def render(since, to="HEAD"):
|
|
subjects = commit_range(since, to)
|
|
if not subjects:
|
|
return {
|
|
"version_range": f"{since}..{to}",
|
|
"headline": "No substantive changes since the last version.",
|
|
"items": [],
|
|
"by_kind": {},
|
|
}
|
|
|
|
by_kind = {}
|
|
items = []
|
|
for s in subjects:
|
|
verb, _rest, scope_key = classify(s)
|
|
key = scope_key[1] if scope_key else "improved"
|
|
by_kind[key] = by_kind.get(key, 0) + 1
|
|
items.append(plain_english(s))
|
|
|
|
counts = ", ".join(f"{v} {k}" for k, v in sorted(by_kind.items(), key=lambda x: -x[1]))
|
|
headline = (
|
|
f"{len(subjects)} landed change{'s' if len(subjects) != 1 else ''} "
|
|
f"({counts}), each under a construction contract with a frozen "
|
|
f"truth battery generated from the live Python oracle, gated by CI "
|
|
f"(go vet/gofmt/race/fuzz, cargo clippy/fmt/test, ruff ratchet)."
|
|
)
|
|
return {
|
|
"version_range": f"{since}..{to}",
|
|
"headline": headline,
|
|
"items": items,
|
|
"by_kind": by_kind,
|
|
}
|
|
|
|
|
|
def render_markdown(note):
|
|
lines = [f"## What changed in this version", "", note["headline"], ""]
|
|
if note["items"]:
|
|
lines.append("### What was worked on")
|
|
lines.append("")
|
|
for item in note["items"]:
|
|
lines.append(f"- {item}")
|
|
lines.append("")
|
|
lines.append("### How it was verified")
|
|
lines.append("")
|
|
lines.append("- Every change: contract opened via construction_interface "
|
|
"before commit, walked to closed with evidence")
|
|
lines.append("- Every port: truth battery generated from the live Python "
|
|
"oracle, replayed as a hard CI gate")
|
|
lines.append("- Pipeline: ruff + silent-handler ratchet (Python), go "
|
|
"vet/gofmt/race/fuzz (Go), cargo clippy/fmt/test (Rust)")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--since", required=True, help="last tag, e.g. v0.117.0")
|
|
ap.add_argument("--to", default="HEAD")
|
|
ap.add_argument("--json", action="store_true")
|
|
args = ap.parse_args()
|
|
note = render(args.since, args.to)
|
|
if args.json:
|
|
print(json.dumps(note, indent=1))
|
|
else:
|
|
print(render_markdown(note))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|