From f5332f17071c02adf0c3811db3754091ba080a12 Mon Sep 17 00:00:00 2001 From: KernelPanic Date: Thu, 6 Aug 2026 15:39:55 -0400 Subject: [PATCH] Add automated changelog + release-notes blog post generation On tagging a release (git tag vX.Y.Z, pushed to a N.N.x/main branch), a new workflow walks the first-parent history since the previous tag, resolving each merged PR's title (falling back to the commit itself for anything pushed directly) and grouping entries by their Conventional Commits prefix (feat/fix/perf/refactor/docs/test/build/ci/chore/style/revert, with an "Other Changes" catch-all for anything that doesn't parse - expected for pre-adoption history). From that it generates/updates: - Archie/CHANGELOG.md - the file Archie/build.gradle.kts's (currently debug-only) modpublisher config already expects at `changelog = file("CHANGELOG.md")`, which didn't exist until now. - a dated Archie/docs/news/posts/.md entry, using the mkdocs-material blog plugin already configured in Archie/mkdocs.yml (blog_dir: news) - picked up automatically by the next docs.yaml deploy. Both land via an auto-opened PR against the tagged branch, not a direct push. Since the changelog is sourced from PR titles, adds a PR-title lint workflow enforcing the Conventional Commits format going forward (existing/pre-adoption history is untouched and simply buckets into "Other Changes"). Documented the requirement, and the release-cutting process, in AGENTS.md. Verified .github/scripts/generate_release_notes.py end to end (dry-run and real file output) against this repo's actual recent history, including both a real merged-PR entry (correctly resolving its true title via `gh pr view`, not the generic merge-commit subject) and several free-form direct commits falling into "Other Changes" as expected. Co-Authored-By: Claude Sonnet 5 --- .github/scripts/generate_release_notes.py | 249 ++++++++++++++++++++++ .github/workflows/pr-title-lint.yml | 41 ++++ .github/workflows/release-notes.yaml | 79 +++++++ .gitignore | 2 + AGENTS.md | 12 +- Archie/CHANGELOG.md | 3 + 6 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/generate_release_notes.py create mode 100644 .github/workflows/pr-title-lint.yml create mode 100644 .github/workflows/release-notes.yaml create mode 100644 Archie/CHANGELOG.md diff --git a/.github/scripts/generate_release_notes.py b/.github/scripts/generate_release_notes.py new file mode 100644 index 000000000..0768f8c3f --- /dev/null +++ b/.github/scripts/generate_release_notes.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Generates a CHANGELOG.md section and a mkdocs-material news/blog post for a tagged release. + +Walks the first-parent history between the previous tag (auto-detected if not given) and the +new tag. Each entry on that line is either a merged pull request (its title, author, and URL are +looked up via `gh pr view`) or a commit pushed directly to the branch (its own subject line is +used as-is). Entries are parsed for a Conventional Commits prefix (`feat:`, `fix:`, ...) and +grouped into sections; anything that doesn't parse lands in "Other Changes" rather than being +dropped, since pre-adoption history won't be Conventional-Commits-shaped. + +Requires `git` (full history - the caller must checkout with fetch-depth: 0) and the `gh` CLI +(authenticated via GH_TOKEN) for PR metadata lookups. See .github/workflows/release-notes.yaml. +""" +from __future__ import annotations + +import argparse +import dataclasses +import datetime +import json +import re +import subprocess +import sys +from pathlib import Path + +CONVENTIONAL_RE = re.compile( + r"^(?Pfeat|fix|perf|refactor|docs|test|build|ci|chore|style|revert)" + r"(?:\((?P[^)]+)\))?(?P!)?:\s*(?P.+)$", + re.IGNORECASE, +) + +# Display order; sections with no entries are omitted. +SECTION_ORDER = [ + ("feat", "Features"), + ("fix", "Bug Fixes"), + ("perf", "Performance"), + ("refactor", "Refactoring"), + ("docs", "Documentation"), + ("test", "Tests"), + ("build", "Build System"), + ("ci", "CI/CD"), + ("chore", "Chores"), + ("style", "Style"), + ("revert", "Reverts"), + ("other", "Other Changes"), +] + + +@dataclasses.dataclass +class Entry: + title: str + url: str | None + link_text: str | None # e.g. "#123" or a short SHA - what to show for `url` + author: str | None + + +def run(*args: str, check: bool = True) -> str: + result = subprocess.run(args, capture_output=True, text=True) + if check and result.returncode != 0: + raise RuntimeError(f"command failed: {' '.join(args)}\n{result.stderr}") + return result.stdout.strip() + + +def gh_json(*args: str) -> dict | None: + result = subprocess.run(["gh", *args], capture_output=True, text=True) + if result.returncode != 0: + print(f"warning: `gh {' '.join(args)}` failed, falling back to git data:\n{result.stderr}", + file=sys.stderr) + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return None + + +def detect_previous_tag(new_tag: str) -> str | None: + """Most recent tag reachable from new_tag other than new_tag itself, by creation order.""" + tags = run("git", "tag", "--sort=creatordate", "--merged", new_tag).splitlines() + tags = [t for t in tags if t != new_tag] + return tags[-1] if tags else None + + +def range_start(prev_tag: str | None) -> str: + if prev_tag: + return prev_tag + # No previous tag at all (first-ever release): walk from the repo's root commit. + return run("git", "rev-list", "--max-parents=0", "HEAD").splitlines()[0] + + +def walk_first_parent(start: str, end: str) -> list[tuple[str, str, list[str]]]: + """Returns (sha, subject, parent_shas) for each commit on end's mainline, oldest first.""" + out = run( + "git", "log", f"{start}..{end}", "--first-parent", "--reverse", + "--pretty=format:%H%x1f%s%x1f%P", + ) + records = [] + for line in out.splitlines(): + if not line: + continue + sha, subject, parents = line.split("\x1f") + records.append((sha, subject, parents.split())) + return records + + +MERGE_PR_RE = re.compile(r"^Merge pull request #(\d+) from") +SQUASH_PR_RE = re.compile(r"^(?P.+) \(#(?P<num>\d+)\)$") + + +def resolve_entry(repo: str, sha: str, subject: str, parents: list[str]) -> Entry: + pr_number = None + if len(parents) > 1: + m = MERGE_PR_RE.match(subject) + if m: + pr_number = m.group(1) + else: + m = SQUASH_PR_RE.match(subject) + if m: + pr_number = m.group("num") + + if pr_number: + data = gh_json("pr", "view", pr_number, "--repo", repo, + "--json", "title,url,author,number") + if data: + author = data.get("author", {}).get("login") + return Entry(title=data["title"], url=data["url"], link_text=f"#{pr_number}", + author=f"@{author}" if author else None) + # `gh pr view` failed (rate limit, fork PR, ...) - the PR URL itself needs no API call. + return Entry(title=f"PR #{pr_number}", url=f"https://github.com/{repo}/pull/{pr_number}", + link_text=f"#{pr_number}", author=None) + + # A commit pushed directly to the branch, not via a merged/squashed PR. + author_name = run("git", "show", "-s", "--format=%an", sha) + short_sha = sha[:7] + return Entry(title=subject, url=f"https://github.com/{repo}/commit/{sha}", + link_text=short_sha, author=author_name) + + +def categorize(entries: list[Entry]) -> dict[str, list[tuple[Entry, re.Match | None]]]: + buckets: dict[str, list] = {key: [] for key, _ in SECTION_ORDER} + for entry in entries: + m = CONVENTIONAL_RE.match(entry.title) + key = m.group("type").lower() if m else "other" + key = key if key in buckets else "other" + buckets[key].append((entry, m)) + return buckets + + +def render_body(buckets: dict[str, list]) -> str: + lines = [] + for key, heading in SECTION_ORDER: + items = buckets.get(key, []) + if not items: + continue + lines.append(f"### {heading}\n") + for entry, m in items: + desc = m.group("desc") if m else entry.title + scope = m.group("scope") if m else None + breaking = " **BREAKING**" if m and m.group("breaking") else "" + scope_prefix = f"**{scope}:** " if scope else "" + link = f" ([{entry.link_text}]({entry.url}))" if entry.url else "" + by = f" - {entry.author}" if entry.author else "" + lines.append(f"- {scope_prefix}{desc}{breaking}{link}{by}") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def update_changelog(path: Path, version_heading: str, date: str, body: str) -> None: + # body already ends in exactly one newline (render_body), so this leaves one blank line + # after the section before whatever follows. + section = f"## {version_heading} - {date}\n\n{body}\n" + if path.exists(): + content = path.read_text() + else: + content = ( + "# Changelog\n\n" + "All notable changes to Archie are documented here, generated automatically from " + "merged pull requests and direct commits following " + "[Conventional Commits](https://www.conventionalcommits.org/).\n\n" + ) + marker = re.search(r"^## \[", content, re.MULTILINE) + if marker: + insert_at = marker.start() + content = content[:insert_at] + section + content[insert_at:] + else: + content = content.rstrip("\n") + "\n\n" + section + path.write_text(content) + + +def write_news_post(posts_dir: Path, repo: str, version_display: str, date: str, + prev_tag: str | None, body: str, entry_count: int) -> Path: + posts_dir.mkdir(parents=True, exist_ok=True) + slug = re.sub(r"[^a-zA-Z0-9.]+", "-", version_display).strip("-") + post_path = posts_dir / f"{slug}.md" + since = f" since [{prev_tag}](https://github.com/{repo}/releases/tag/{prev_tag})" if prev_tag else "" + front_matter = ( + "---\n" + f"date:\n created: {date}\n" + "categories:\n - Release\n" + "---\n\n" + ) + intro = ( + f"# Archie {version_display}\n\n" + f"{entry_count} change{'s' if entry_count != 1 else ''}{since}.\n\n" + "<!-- more -->\n\n" + ) + post_path.write_text(front_matter + intro + body) + return post_path + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", required=True, help="owner/name") + parser.add_argument("--new-tag", required=True) + parser.add_argument("--prev-tag", default=None, help="auto-detected if omitted") + parser.add_argument("--changelog-path", required=True, type=Path) + parser.add_argument("--posts-dir", required=True, type=Path) + parser.add_argument("--dry-run", action="store_true", help="print instead of writing files") + args = parser.parse_args() + + prev_tag = args.prev_tag or detect_previous_tag(args.new_tag) + start = range_start(prev_tag) + + records = walk_first_parent(start, args.new_tag) + if not records: + print(f"No commits between {prev_tag or '(root)'} and {args.new_tag} - nothing to generate.") + return + + entries = [resolve_entry(args.repo, sha, subject, parents) for sha, subject, parents in records] + buckets = categorize(entries) + body = render_body(buckets) + + version_display = args.new_tag[1:] if args.new_tag.startswith("v") else args.new_tag + date = datetime.date.today().isoformat() + + if args.dry_run: + print(f"# Previous tag: {prev_tag or '(none - first release)'}") + print(f"# {len(entries)} entries\n") + print(body) + return + + update_changelog(args.changelog_path, f"[{version_display}]", date, body) + post_path = write_news_post( + args.posts_dir, args.repo, version_display, date, prev_tag, body, len(entries), + ) + print(f"Updated {args.changelog_path}") + print(f"Wrote {post_path}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml new file mode 100644 index 000000000..56baf1d6d --- /dev/null +++ b/.github/workflows/pr-title-lint.yml @@ -0,0 +1,41 @@ +name: PR title lint + +# Enforces Conventional Commits (https://www.conventionalcommits.org/) on PR titles. The +# release-notes workflow (.github/workflows/release-notes.yaml) reads merged PR titles to build +# the changelog and news post for each tag, grouped by this prefix - an unparsed title doesn't +# break anything, it just lands in the catch-all "Other Changes" section instead of a proper one. + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + pull-requests: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + perf + refactor + docs + test + build + ci + chore + style + revert + requireScope: false + # Squash-merged PRs commonly get "(#123)" appended by GitHub - allow trailing + # whitespace/punctuation around that without rejecting an otherwise-valid title. + subjectPattern: ^(?!\s*$).+$ diff --git a/.github/workflows/release-notes.yaml b/.github/workflows/release-notes.yaml new file mode 100644 index 000000000..78fefea7c --- /dev/null +++ b/.github/workflows/release-notes.yaml @@ -0,0 +1,79 @@ +name: release-notes + +# Generates Archie/CHANGELOG.md and a dated Archie/docs/news/posts/ blog entry for a tagged +# release, then opens a PR with both. See .github/scripts/generate_release_notes.py for how +# entries are sourced (merged PR titles, parsed for a Conventional Commits prefix) and +# .github/workflows/pr-title-lint.yml for how those titles are kept in that shape. +# +# Cut a release by tagging the branch tip: `git tag v1.1.0 && git push origin v1.1.0` (or via +# `gh release create v1.1.0 --target 1.21.x`). Tag names must match `v*`. + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + pull-requests: write + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v7 + with: + python-version: '3.x' + + - name: Determine the branch this tag was cut from + id: base + run: | + git fetch origin --prune + BRANCH=$(git branch -r --contains "${{ github.sha }}" \ + | sed 's/^[* ]*origin\///' \ + | grep -E '^[0-9]+\.[0-9]+\.x$|^main$' \ + | head -n1) + if [ -z "$BRANCH" ]; then + echo "::error::Tag ${{ github.ref_name }} isn't on a recognized release branch (expected an N.N.x or main branch tip)." + exit 1 + fi + echo "Releasing from $BRANCH" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Generate changelog and news post + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python .github/scripts/generate_release_notes.py \ + --repo "${{ github.repository }}" \ + --new-tag "${{ github.ref_name }}" \ + --changelog-path Archie/CHANGELOG.md \ + --posts-dir Archie/docs/news/posts + + - name: Open pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if git diff --quiet -- Archie/CHANGELOG.md Archie/docs/news/posts; then + echo "Nothing new to record since the previous tag - skipping PR." + exit 0 + fi + + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + + BRANCH_NAME="release-notes/${{ github.ref_name }}" + git checkout -b "$BRANCH_NAME" + git add Archie/CHANGELOG.md Archie/docs/news/posts + git commit -m "docs: release notes for ${{ github.ref_name }}" + git push origin "$BRANCH_NAME" + + gh pr create \ + --base "${{ steps.base.outputs.branch }}" \ + --head "$BRANCH_NAME" \ + --title "docs: release notes for ${{ github.ref_name }}" \ + --body "Auto-generated changelog and news post for [${{ github.ref_name }}](https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }})." diff --git a/.gitignore b/.gitignore index 4b2ca1a02..d161eef74 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ docs/api site .architectury-transformer */.kotlin +__pycache__/ +*.pyc # Engram local graph (personal) .engram/ diff --git a/AGENTS.md b/AGENTS.md index ddf28eda2..d05d1a846 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,11 +35,21 @@ All commands below are run from inside `Archie/` (`cd Archie` first). - `common/build.gradle.kts` intentionally uses `modImplementation(libs.fabric.loader)` only for annotations/mixin deps; avoid importing random Fabric-only classes in common code. - Utility operators are used pervasively for IDs (`Archie % "main"`, `mod % "path"`, `"namespace" % "path"`) from `Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/util/ResourceLocation.kt`. +- PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, + `docs:`, `refactor:`, `perf:`, `test:`, `build:`, `ci:`, `chore:`, `style:`, `revert:`, optionally + scoped `type(scope):`) - enforced by `.github/workflows/pr-title-lint.yml`. Individual commits within a + PR don't need to conform, but a direct push to a release branch (no PR) does, since it's read the same + way. This isn't just style: cutting a release (`git tag vX.Y.Z`) triggers + `.github/workflows/release-notes.yaml`, which walks merged PR titles since the last tag to generate + `Archie/CHANGELOG.md` and a `Archie/docs/news/posts/` entry, grouped by this prefix - an unparsed title + doesn't break anything, it just lands in the catch-all "Other Changes" section instead of a real one. ## Dependency and integration touchpoints - Versions and plugin IDs are centralized in `gradle/libs.versions.toml` (repo root); update there first. - Packaging/publishing is configured at the `Archie/` build root via `modfusioner` (`fusejars`) and - `modpublisher` (CurseForge/Modrinth IDs and required deps) in `Archie/build.gradle.kts`. + `modpublisher` (CurseForge/Modrinth IDs and required deps) in `Archie/build.gradle.kts` - `modpublisher` + reads its changelog text straight from `Archie/CHANGELOG.md`, the same file the release-notes workflow + maintains. - Mixins are split by scope: loader mixins in `Archie/fabric/src/main/resources/archie.mixins.json` and `Archie/neoforge/src/main/resources/archie.mixins.json`, common mixin config in `Archie/common/src/main/resources/archie-common.mixins.json`. diff --git a/Archie/CHANGELOG.md b/Archie/CHANGELOG.md new file mode 100644 index 000000000..ada7815ba --- /dev/null +++ b/Archie/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to Archie are documented here, generated automatically from merged pull requests and direct commits following [Conventional Commits](https://www.conventionalcommits.org/).