Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 249 additions & 0 deletions .github/scripts/generate_release_notes.py
Original file line number Diff line number Diff line change
@@ -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"^(?P<type>feat|fix|perf|refactor|docs|test|build|ci|chore|style|revert)"
r"(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s*(?P<desc>.+)$",
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<title>.+) \(#(?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()
41 changes: 41 additions & 0 deletions .github/workflows/pr-title-lint.yml
Original file line number Diff line number Diff line change
@@ -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

Comment on lines +8 to +14
permissions:
pull-requests: read

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Comment on lines +23 to +24
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*$).+$
79 changes: 79 additions & 0 deletions .github/workflows/release-notes.yaml
Original file line number Diff line number Diff line change
@@ -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 }})."
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ docs/api
site
.architectury-transformer
*/.kotlin
__pycache__/
*.pyc

# Engram local graph (personal)
.engram/
Loading
Loading