diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..5b2232a --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,53 @@ +name: Changelog + +on: + workflow_dispatch: + inputs: + from_ref: + description: Start revision (exclusive); defaults to the latest tag + required: false + type: string + to_ref: + description: End revision (inclusive) + required: false + default: HEAD + type: string + version: + description: Version heading, for example 0.2.0 or Unreleased + required: false + default: Unreleased + type: string + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: changelog-${{ github.ref }} + cancel-in-progress: true + +jobs: + generate: + name: Generate changelog section + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Generate section (dry run) + run: | + python tools/changelog.py \ + --from "${{ github.event.inputs.from_ref }}" \ + --to "${{ github.event.inputs.to_ref || 'HEAD' }}" \ + --version "${{ github.event.inputs.version || github.event.release.tag_name || 'Unreleased' }}" \ + --output build/changelog-section.md + - name: Upload generated section + uses: actions/upload-artifact@v4 + with: + name: changelog-section + path: build/changelog-section.md + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b6767d..aa1acac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,5 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Documentation: ecosystem research, architecture, getting started, CLI reference, project status, roadmap. - CI: format, clippy, and tests on Linux, Windows, and macOS. +- Tooling: `tools/changelog.py` generates reviewable release sections from + Conventional Commits; a dry-run workflow runs on demand and on release. - Governance: README, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, LICENSE, pull-request and issue templates. diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..dbb424f --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,85 @@ +# Release guide + +This guide describes how DevKit release notes are produced. Publishing releases +is handled separately by the maintainer. + +## Versioning + +The workspace follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The version lives in the `[workspace.package]` table of the root `Cargo.toml`. + +## Changelog + +`CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Release notes are generated from [Conventional Commits](https://www.conventionalcommits.org/) +so the file cannot drift from merged work, while remaining hand-editable. + +### How generation works + +`tools/changelog.py` reads the commits in a range and groups them by type: + +| Commit type | Section | +| ----------- | ------- | +| `feat` | Added | +| `fix` | Fixed | +| `perf` | Performance | +| `refactor` | Changed | +| `security` | Security | +| `docs` | Documentation | +| `test` | Tests | +| `ci` | CI | +| `build` | Build | + +A `!` after the type/scope, or a `BREAKING CHANGE:` footer, moves the entry to +**Breaking Changes**. Non-releasable types such as `chore` and `style`, and +non-conventional subjects, are ignored. Entries are sorted by section, scope, +and description, so the same range always produces the same section. + +If the range contains no releasable changes, the tool fails with a clear error +instead of emitting an empty section. + +### Dry run (reviewable) + +Preview the next section without touching the file: + +```bash +python tools/changelog.py --version 0.2.0 --to HEAD +``` + +By default the range starts at the latest tag. Use `--from`/`--to` to pin a +sample range explicitly, and `--output FILE` to save the section for review: + +```bash +python tools/changelog.py --from v0.1.0 --to HEAD --version 0.2.0 --output build/section.md +``` + +### Writing the changelog + +Apply the generated block to `CHANGELOG.md`: + +```bash +python tools/changelog.py --version 0.2.0 --write +``` + +Only the region between the `` and +`` markers is replaced. Text outside the markers +is preserved, so you can keep hand-written notes alongside generated entries. + +## Automated generation + +`.github/workflows/changelog.yml` runs the generator on demand +(`workflow_dispatch`) and whenever a release is published. It performs a dry run +and uploads the generated section as the `changelog-section` artifact for +review. The workflow never commits on its own; a maintainer applies the section +with `--write` during the release. + +## Release checklist + +1. Confirm the version in the root `Cargo.toml`. +2. Generate and review the section (dry run above). +3. Apply it with `--write` and edit prose as needed. +4. Run the local gate: + `cargo fmt --all -- --check`, + `cargo clippy --all-targets --all-features -- -D warnings`, + `cargo test --all`. +5. Tag the release, publish it, and confirm the workflow uploaded the artifact. diff --git a/tools/changelog.py b/tools/changelog.py new file mode 100644 index 0000000..9e32b2c --- /dev/null +++ b/tools/changelog.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Generate a changelog section from Conventional Commits. + +The generator is deterministic and reviewable: the same commit range always +produces the same section, and by default the section is only printed to stdout +(a dry run). Pass ``--write`` to splice the generated block into ``CHANGELOG.md`` +between the ``changelog:generated`` markers, leaving human-written text alone. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_CHANGELOG = ROOT / "CHANGELOG.md" + +START_MARKER = "" +END_MARKER = "" + +CONVENTIONAL_RE = re.compile( + r"^(?P[a-zA-Z]+)(?:\((?P[^)]*)\))?(?P!)?: (?P.+)$" +) +BREAKING_RE = re.compile(r"^BREAKING[ -]CHANGE:", re.MULTILINE) + +TYPE_TO_SECTION = { + "feat": "Added", + "fix": "Fixed", + "perf": "Performance", + "refactor": "Changed", + "security": "Security", + "docs": "Documentation", + "test": "Tests", + "ci": "CI", + "build": "Build", +} + +SECTION_ORDER = [ + "Breaking Changes", + "Added", + "Fixed", + "Performance", + "Changed", + "Security", + "Documentation", + "Tests", + "CI", + "Build", +] + +RELEASABLE = frozenset(TYPE_TO_SECTION) + + +@dataclass(frozen=True) +class Entry: + """A single changelog entry derived from one commit.""" + + section: str + scope: str + description: str + + +def parse_commit(subject: str, body: str = "") -> Entry | None: + """Classify one commit into a changelog entry, or return ``None``. + + Non-conventional commits and non-releasable types (for example ``chore`` or + ``style``) are ignored. Breaking changes are always releasable and land in + the ``Breaking Changes`` section. + """ + match = CONVENTIONAL_RE.match(subject.strip()) + if not match: + return None + commit_type = match.group("type").lower() + breaking = bool(match.group("breaking")) or bool(BREAKING_RE.search(body)) + if breaking: + section = "Breaking Changes" + elif commit_type in RELEASABLE: + section = TYPE_TO_SECTION[commit_type] + else: + return None + return Entry(section, (match.group("scope") or "").strip(), match.group("desc").strip()) + + +def collect_entries(commits: Iterable[tuple[str, str]]) -> list[Entry]: + """Classify ``(subject, body)`` pairs and return them in a stable order.""" + entries = [entry for entry in (parse_commit(s, b) for s, b in commits) if entry] + return sorted(entries, key=lambda e: (SECTION_ORDER.index(e.section), e.scope, e.description)) + + +def render(entries: Sequence[Entry], version: str, date: str | None) -> str: + """Render a Markdown changelog section for ``entries``.""" + heading = f"## [{version}]" + (f" - {date}" if date else "") + lines = [heading] + for section in SECTION_ORDER: + subset = [e for e in entries if e.section == section] + if not subset: + continue + lines += ["", f"### {section}", ""] + for entry in subset: + prefix = f"**{entry.scope}:** " if entry.scope else "" + lines.append(f"- {prefix}{entry.description}") + return "\n".join(lines) + "\n" + + +def _git(*args: str, cwd: Path) -> str: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + return result.stdout + + +def default_from_ref(cwd: Path) -> str | None: + """Return the most recent tag, or ``None`` to use the whole history.""" + try: + return _git("describe", "--tags", "--abbrev=0", cwd=cwd).strip() + except subprocess.CalledProcessError: + return None + + +def read_commits(from_ref: str | None, to_ref: str, cwd: Path) -> list[tuple[str, str]]: + """Read ``(subject, body)`` pairs for the range ``from_ref..to_ref``.""" + revision = f"{from_ref}..{to_ref}" if from_ref else to_ref + raw = _git("log", "--no-merges", "--format=%s%x1f%b%x1e", revision, cwd=cwd) + commits = [] + for record in raw.split("\x1e"): + record = record.strip("\n") + if not record: + continue + subject, _, body = record.partition("\x1f") + commits.append((subject, body)) + return commits + + +def splice(changelog: str, block: str) -> str: + """Replace the generated markers in ``changelog`` with ``block``.""" + generated = f"{START_MARKER}\n{block}{END_MARKER}" + if START_MARKER in changelog and END_MARKER in changelog: + pattern = re.compile( + re.escape(START_MARKER) + r".*?" + re.escape(END_MARKER), re.DOTALL + ) + return pattern.sub(lambda _: generated, changelog, count=1) + anchor = changelog.find("## [") + if anchor == -1: + return changelog.rstrip("\n") + "\n\n" + generated + "\n" + return changelog[:anchor] + generated + "\n\n" + changelog[anchor:] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--from", dest="from_ref", help="start revision (exclusive)") + parser.add_argument("--to", dest="to_ref", default="HEAD", help="end revision (inclusive)") + parser.add_argument("--version", default="Unreleased", help="version heading") + parser.add_argument("--date", default=None, help="release date (YYYY-MM-DD)") + parser.add_argument( + "--changelog", type=Path, default=DEFAULT_CHANGELOG, help="path to CHANGELOG.md" + ) + parser.add_argument("--output", type=Path, help="write the section here instead of stdout") + parser.add_argument( + "--write", action="store_true", help="splice the section into CHANGELOG.md" + ) + parser.add_argument("--repo-root", type=Path, default=ROOT, help="repository root") + return parser + + +def _clean_ref(value: str | None) -> str | None: + return value.strip() if value and value.strip() else None + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + to_ref = _clean_ref(args.to_ref) or "HEAD" + from_ref = _clean_ref(args.from_ref) + if from_ref is None: + from_ref = default_from_ref(args.repo_root) + + try: + commits = read_commits(from_ref, to_ref, args.repo_root) + except subprocess.CalledProcessError as error: + print(f"error: git failed: {error}", file=sys.stderr) + return 2 + + entries = collect_entries(commits) + if not entries: + revision = f"{from_ref}..{to_ref}" if from_ref else to_ref + print( + f"error: no releasable changes found in {revision}; " + "use a conventional-commit prefix such as feat, fix, or perf", + file=sys.stderr, + ) + return 1 + + date = args.date + if date is None and args.version != "Unreleased": + date = dt.date.today().isoformat() + block = render(entries, args.version, date) + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(block, encoding="utf-8", newline="\n") + print(f"wrote {args.output}") + elif args.write: + changelog = args.changelog.read_text(encoding="utf-8") + args.changelog.write_text(splice(changelog, block), encoding="utf-8", newline="\n") + print(f"updated {args.changelog}") + else: + sys.stdout.write(block) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_changelog.py b/tools/tests/test_changelog.py new file mode 100644 index 0000000..a778438 --- /dev/null +++ b/tools/tests/test_changelog.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Tests for the deterministic changelog generator.""" + +from __future__ import annotations + +import io +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import changelog # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + + +class ParseTests(unittest.TestCase): + def test_feat_with_scope(self) -> None: + entry = changelog.parse_commit("feat(rpc): add live transport") + self.assertEqual(entry.section, "Added") + self.assertEqual(entry.scope, "rpc") + self.assertEqual(entry.description, "add live transport") + + def test_breaking_bang_flag(self) -> None: + entry = changelog.parse_commit("feat(cli)!: drop legacy flag") + self.assertEqual(entry.section, "Breaking Changes") + + def test_breaking_footer(self) -> None: + entry = changelog.parse_commit( + "refactor(core): reshape errors", "BREAKING CHANGE: error layout moved" + ) + self.assertEqual(entry.section, "Breaking Changes") + + def test_non_releasable_type_ignored(self) -> None: + self.assertIsNone(changelog.parse_commit("chore: bump cache")) + self.assertIsNone(changelog.parse_commit("style: format imports")) + + def test_non_conventional_ignored(self) -> None: + self.assertIsNone(changelog.parse_commit("Merge pull request #1")) + + +class RenderTests(unittest.TestCase): + def test_sections_are_ordered_and_sorted(self) -> None: + commits = [ + ("fix(z): late fix", ""), + ("feat(b): second", ""), + ("feat(a): first", ""), + ("perf: faster", ""), + ] + entries = changelog.collect_entries(commits) + rendered = changelog.render(entries, "1.0.0", "2026-09-22") + self.assertLess(rendered.index("### Added"), rendered.index("### Fixed")) + self.assertLess(rendered.index("### Fixed"), rendered.index("### Performance")) + self.assertLess(rendered.index("first"), rendered.index("second")) + + def test_render_is_deterministic(self) -> None: + commits = [("feat(a): one", ""), ("fix(b): two", "")] + first = changelog.render(changelog.collect_entries(commits), "Unreleased", None) + second = changelog.render(changelog.collect_entries(reversed(commits)), "Unreleased", None) + self.assertEqual(first, second) + + +class CliTests(unittest.TestCase): + def test_no_releasable_changes_fails(self) -> None: + stderr = io.StringIO() + stdout = io.StringIO() + with redirect_stderr(stderr), redirect_stdout(stdout): + code = changelog.main( + ["--from", "HEAD", "--to", "HEAD", "--repo-root", str(REPO_ROOT)] + ) + self.assertEqual(code, 1) + self.assertIn("no releasable changes", stderr.getvalue()) + + def test_dry_run_sample_range(self) -> None: + import subprocess + + root = subprocess.run( + ["git", "rev-list", "--max-parents=0", "HEAD"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + stdout = io.StringIO() + with redirect_stdout(stdout): + code = changelog.main( + ["--from", root, "--to", "HEAD", "--repo-root", str(REPO_ROOT)] + ) + self.assertEqual(code, 0) + self.assertIn("### Added", stdout.getvalue()) + + +if __name__ == "__main__": + unittest.main()