ci: add deterministic changelog-draft workflow
Some checks failed
changelog-draft / changelog-draft (push) Failing after 2s
Some checks failed
changelog-draft / changelog-draft (push) Failing after 2s
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).
This commit is contained in:
parent
d3250b95bb
commit
cf846a44e8
105
.github/scripts/changelog-draft.py
vendored
Normal file
105
.github/scripts/changelog-draft.py
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
#!/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()
|
||||
39
.github/workflows/changelog-draft.yml
vendored
Normal file
39
.github/workflows/changelog-draft.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
name: changelog-draft
|
||||
|
||||
# Drafts CHANGELOG_PENDING.md entries from Conventional Commit subjects on
|
||||
# every push to main. Deterministic -- regex-parses commit subjects only,
|
||||
# no LLM call, no network access. Buckets feat/fix/refactor/perf/security
|
||||
# into Added/Fixed/Changed; skips chore/ci/test/docs/style/merge commits and
|
||||
# anything mentioning internal infra hostnames/addresses. Never touches
|
||||
# CHANGELOG.md itself -- human review still required to fold an entry in.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
changelog-draft:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Draft pending changelog entry
|
||||
run: python3 .github/scripts/changelog-draft.py "${{ github.event.before }}" "${{ github.sha }}"
|
||||
|
||||
- name: Commit draft if changed
|
||||
run: |
|
||||
if ! git diff --quiet CHANGELOG_PENDING.md 2>/dev/null; then
|
||||
git config user.name "changelog-bot"
|
||||
git config user.email "changelog-bot@context-x.org"
|
||||
git add CHANGELOG_PENDING.md
|
||||
git commit -m "chore(changelog): draft pending entries [skip ci]"
|
||||
git push origin HEAD:main
|
||||
else
|
||||
echo "No changelog-worthy commits in this push."
|
||||
fi
|
||||
Loading…
x
Reference in New Issue
Block a user