diff --git a/.github/scripts/changelog-draft.py b/.github/scripts/changelog-draft.py new file mode 100644 index 0000000..f9e96d7 --- /dev/null +++ b/.github/scripts/changelog-draft.py @@ -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" + "\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 = "\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() diff --git a/.github/workflows/changelog-draft.yml b/.github/workflows/changelog-draft.yml new file mode 100644 index 0000000..ff7b9f4 --- /dev/null +++ b/.github/workflows/changelog-draft.yml @@ -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