llm-gateway/.github/scripts/changelog-draft.py
Rene Fichtmueller f720096d3b
All checks were successful
changelog-draft / changelog-draft (push) Successful in 29s
ci: add deterministic changelog-draft workflow
Drafts CHANGELOG_PENDING.md entries from Conventional Commit subjects on
every push to main -- feat/fix/refactor/perf/security bucketed into
Added/Fixed/Changed, chore/ci/test/docs/style/merge skipped, anything
mentioning internal infra hostnames dropped. No LLM call, no network
access. Never touches CHANGELOG.md -- still needs a human to fold an
entry in. Rolled out fleet-wide from the PeerCortex pilot (2026-07-18).
2026-07-18 07:20:42 +00:00

106 lines
3.3 KiB
Python

#!/usr/bin/env python3
"""Draft CHANGELOG_PENDING.md entries from Conventional Commit messages
pushed since the last run. Deterministic -- no LLM call, no network access.
Human review still required before folding an entry into CHANGELOG.md.
"""
import re
import subprocess
import sys
from datetime import datetime, timezone
INCLUDE_TYPES = {
"feat": "Added",
"fix": "Fixed",
"refactor": "Changed",
"perf": "Changed",
"security": "Fixed",
}
SKIP_TYPES = {"chore", "ci", "test", "docs", "style", "merge"}
# Anything mentioning internal infra by name/address is dropped rather than
# drafted -- better a missing entry (caught in human review) than a leaked one.
INTERNAL_PATTERN = re.compile(
r"\b(erik|gitea\.context-x|192\.168\.|10\.10\.0\.|opnsense|ssh |systemd|pm2 |crontab)\b",
re.IGNORECASE,
)
PENDING_HEADER = (
"# Pending Changelog Entries\n\n"
"Drafted automatically from commit messages since the last processed "
"push. Review, edit as needed, and fold into CHANGELOG.md -- then clear "
"this file.\n\n"
"<!-- PENDING_ENTRIES -->\n"
)
def run(cmd):
return subprocess.check_output(cmd, shell=True, text=True).strip()
def main():
before = sys.argv[1] if len(sys.argv) > 1 else ""
after = sys.argv[2] if len(sys.argv) > 2 else "HEAD"
if not before or set(before) == {"0"}:
print("No usable commit range (first push on branch) -- skipping.")
return
try:
log = run("git log {}..{} --format='%s'".format(before, after))
except subprocess.CalledProcessError:
print("Could not diff commit range -- skipping.")
return
if not log:
print("No new commits.")
return
buckets = {"Added": [], "Fixed": [], "Changed": []}
for subject in log.splitlines():
m = re.match(r"^(\w+)(\([\w./-]+\))?!?:\s*(.+)$", subject)
if not m:
continue
ctype, _, desc = m.groups()
ctype = ctype.lower()
if ctype in SKIP_TYPES or ctype not in INCLUDE_TYPES:
continue
if INTERNAL_PATTERN.search(desc):
continue
buckets[INCLUDE_TYPES[ctype]].append(desc.strip())
if not any(buckets.values()):
print("Nothing changelog-worthy in this push.")
return
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
lines = ["## Pending -- {}\n".format(date)]
for section in ("Added", "Fixed", "Changed"):
if buckets[section]:
lines.append("### {}".format(section))
for item in buckets[section]:
lines.append("- {}".format(item))
lines.append("")
entry = "\n".join(lines).rstrip() + "\n\n"
try:
with open("CHANGELOG_PENDING.md", "r") as f:
existing = f.read()
if not existing.strip():
existing = PENDING_HEADER # pre-existing but empty placeholder file
except FileNotFoundError:
existing = PENDING_HEADER
marker = "<!-- PENDING_ENTRIES -->\n"
if marker in existing:
head, tail = existing.split(marker, 1)
updated = head + marker + entry + tail.lstrip("\n")
else:
updated = existing.rstrip("\n") + "\n\n" + entry
with open("CHANGELOG_PENDING.md", "w") as f:
f.write(updated)
print("Wrote entry:\n" + entry)
if __name__ == "__main__":
main()