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
53 changes: 53 additions & 0 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
85 changes: 85 additions & 0 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
@@ -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 `<!-- changelog:generated:start -->` and
`<!-- changelog:generated:end -->` 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.
222 changes: 222 additions & 0 deletions tools/changelog.py
Original file line number Diff line number Diff line change
@@ -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 = "<!-- changelog:generated:start -->"
END_MARKER = "<!-- changelog:generated:end -->"

CONVENTIONAL_RE = re.compile(
r"^(?P<type>[a-zA-Z]+)(?:\((?P<scope>[^)]*)\))?(?P<breaking>!)?: (?P<desc>.+)$"
)
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())
Loading
Loading