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.
127 lines
4.0 KiB
Python
127 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
"""backfill_exit_notes.py — retroactively append the EXIT-NOTE (plain-
|
|
English what-changed) to every existing release that predates the template
|
|
(Nick directive 2026-10-08). For each tag: render the exit note for the
|
|
tag's own range (v_prev..v_tag), append it to the release body under a
|
|
"## What changed" heading — preserving the governed evidence sections —
|
|
and PATCH the release via the Gitea API. Idempotent: releases already
|
|
carrying the exit-note heading are skipped.
|
|
|
|
Usage: python3 scripts/backfill_exit_notes.py [--dry-run] [--limit N]
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO / "scripts"))
|
|
sys.path.insert(0, str(REPO / "src"))
|
|
|
|
from release_exit_note import render_markdown, render # noqa: E402
|
|
|
|
EXIT_HEADING = "## What changed"
|
|
SERVER = "http://127.0.0.1:3000"
|
|
REPO_SLUG = "conductor/rune"
|
|
|
|
|
|
def _token() -> str:
|
|
sys.path.insert(0, str(REPO / ".config" / "opencode" / "shared"))
|
|
try:
|
|
from gitea_auth import load_gitea_token
|
|
return load_gitea_token()
|
|
except ImportError:
|
|
# fall back to the git credential
|
|
r = subprocess.run(
|
|
["git", "credential", "fill"], input="url=http://127.0.0.1:3000\n",
|
|
capture_output=True, text=True, cwd=REPO)
|
|
for line in r.stdout.splitlines():
|
|
if line.startswith("password="):
|
|
return line.split("=", 1)[1]
|
|
return ""
|
|
|
|
|
|
def api(path, method="GET", data=None):
|
|
import urllib.request
|
|
req = urllib.request.Request(
|
|
f"{SERVER}/api/v1/repos/{REPO_SLUG}{path}",
|
|
data=json.dumps(data).encode() if data is not None else None,
|
|
headers={"Authorization": f"token {_token()}",
|
|
"Content-Type": "application/json"},
|
|
method=method,
|
|
)
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
return json.loads(r.read())
|
|
|
|
|
|
def tags():
|
|
r = subprocess.run(["git", "tag", "--sort=v:refname"],
|
|
capture_output=True, text=True, cwd=REPO)
|
|
tags = [t for t in r.stdout.splitlines() if t.startswith("v")]
|
|
# exit-note applies from v0.117.0 onward (the factory-era releases)
|
|
try:
|
|
start = tags.index("v0.117.0")
|
|
except ValueError:
|
|
return tags
|
|
return tags[start:]
|
|
|
|
|
|
def main():
|
|
dry = "--dry-run" in sys.argv
|
|
limit = 999
|
|
if "--limit" in sys.argv:
|
|
limit = int(sys.argv[sys.argv.index("--limit") + 1])
|
|
|
|
all_tags = tags()
|
|
releases = api("/releases?limit=50&sort=desc")
|
|
by_tag = {r["tag_name"]: r for r in releases}
|
|
|
|
patched, skipped, failed = 0, 0, 0
|
|
# chronological order (oldest first) so ranges nest correctly
|
|
for i, tag in enumerate(all_tags):
|
|
if i == 0:
|
|
prev = None
|
|
else:
|
|
prev = all_tags[i - 1]
|
|
rel = by_tag.get(tag)
|
|
if rel is None:
|
|
skipped += 1
|
|
continue
|
|
body = rel.get("body") or ""
|
|
if EXIT_HEADING in body:
|
|
skipped += 1
|
|
continue
|
|
if i == 0 or prev is None:
|
|
skipped += 1
|
|
continue
|
|
# limit: process the N NEWEST unpatched
|
|
if patched >= limit:
|
|
skipped += 1
|
|
continue
|
|
try:
|
|
note = render(prev, tag)
|
|
md = render_markdown(note)
|
|
new_body = body.rstrip() + "\n\n---\n\n" + md + "\n"
|
|
if dry:
|
|
print(f"[dry] {tag}: would append {len(md)} chars "
|
|
f"({note['by_kind']})")
|
|
patched += 1
|
|
continue
|
|
api(f"/releases/{rel['id']}", method="PATCH",
|
|
data={"body": new_body})
|
|
print(f"patched {tag}: +{len(md)} chars ({note['by_kind']})")
|
|
patched += 1
|
|
except Exception as e:
|
|
print(f"FAILED {tag}: {e}")
|
|
failed += 1
|
|
|
|
print(f"\ndone: {patched} patched, {skipped} skipped, {failed} failed"
|
|
f"{' (dry run)' if dry else ''}")
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|