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
95 changes: 95 additions & 0 deletions .github/scripts/group-release-notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Group release-note commits by conventional-commit type.

Reads `<short-hash><TAB><subject>` lines on stdin (one commit each, as
produced by `git log --pretty=format:'%h%x09%s'`) and writes Markdown
release notes to stdout, bucketed by conventional-commit type (`feat`,
`fix`, `docs`, ...) instead of by author.

The header is drawn from the `NEW_TAG` / `PREV_TAG` environment variables
so the output matches the previous shortlog-based notes.
"""

import os
import re
import sys

# Conventional-commit types in the order they should appear, mapped to
# their section headings. Types not listed here fall through to "Other".
SECTIONS = [
("feat", "🚀 Features"),
("fix", "🐛 Fixes"),
("perf", "⚡ Performance"),
("refactor", "♻️ Refactoring"),
("docs", "📚 Documentation"),
("test", "✅ Tests"),
("build", "📦 Build"),
("ci", "👷 CI"),
("chore", "🔧 Chores"),
("revert", "⏪ Reverts"),
]
SECTION_TITLES = dict(SECTIONS)
OTHER_KEY = "other"

# `type(optional scope)!: subject` — captures the type and the remaining
# subject. The optional `!` and scope are conventional-commit syntax.
COMMIT_RE = re.compile(r"^(?P<type>[a-z]+)(?:\([^)]*\))?!?:\s*(?P<subject>.*)$")


def classify(subject):
"""Return the `section_key` for a commit subject line.

Only the type is parsed off, for bucketing; the subject itself is kept
verbatim (prefix included) by the caller so the rendered notes still
show the `feat:` / `fix:` convention.
"""
match = COMMIT_RE.match(subject)
if match and match.group("type") in SECTION_TITLES:
return match.group("type")
return OTHER_KEY


def main():
buckets = {}
for line in sys.stdin:
line = line.rstrip("\n")
if not line:
continue
short_hash, _, subject = line.partition("\t")
key = classify(subject)
buckets.setdefault(key, []).append((short_hash, subject.strip()))

new_tag = os.environ.get("NEW_TAG", "")
prev_tag = os.environ.get("PREV_TAG", "")

out = []
out.append(f"## {new_tag}")
out.append("")
out.append(
f"_Auto-drafted from commits in `{prev_tag}..{new_tag}`, grouped by "
"conventional-commit type. Edit on the release page after publish if "
"you want a polished writeup; the canonical release notes live on the "
"website._"
)
out.append("")

# Known types first, in declared order; then the "Other" catch-all.
for key, title in SECTIONS + [(OTHER_KEY, "🔖 Other")]:
commits = buckets.get(key)
if not commits:
continue
out.append(f"### {title}")
out.append("")
for short_hash, subject in commits:
out.append(f"- {subject} ({short_hash})")
out.append("")

if not any(buckets.values()):
out.append("_No commits in range._")
out.append("")

sys.stdout.write("\n".join(out).rstrip() + "\n")


if __name__ == "__main__":
main()
23 changes: 22 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,22 @@ on:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
# Callable as a gate by release.yml, which passes the pinned release SHA as
# `ref` so the whole matrix runs against the exact commit being released
# (not the branch tip). When triggered by a PR, `ref` is empty and every
# checkout falls back to the triggering ref.
workflow_call:
inputs:
ref:
description: "Commit SHA / ref to check out. Empty = triggering ref."
required: false
type: string
default: ""

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Distinct group per release ref so a release-triggered run never cancels (or
# is cancelled by) a PR's CI. PR runs still coalesce per PR number.
group: ${{ github.workflow }}-${{ inputs.ref || github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
Expand All @@ -20,6 +33,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
Expand Down Expand Up @@ -48,6 +63,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
Expand Down Expand Up @@ -94,6 +111,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
Expand Down Expand Up @@ -135,6 +154,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
Expand Down
Loading
Loading