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
4 changes: 4 additions & 0 deletions changelog.d/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ becomes, under `### Added`:
The release notes list only the bold summary, so write it for someone deciding
whether the release matters to them.

A notice for the top of the release notes, such as "This is the last 0.3.x
release", is not a fragment: write it into `CHANGELOG.md` directly under
`## [Unreleased]`, above the first heading or entry.

`tests/test_changelog_release.py` checks every fragment here, so a malformed one
(a bad name, an unknown section, no bold summary, a leading `- `, its own issue
link) fails CI in the pull request that adds it. This README and dotfiles are
Expand Down
11 changes: 11 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,17 @@ heading nor a `- ` entry. `tests/test_changelog_release.py` runs the same check
on the real `CHANGELOG.md`, so the pull request that adds an entry without a
summary fails CI instead of the release.

A release can also open with a notice, such as "This is the last 0.3.x
release". Write it into `CHANGELOG.md` by hand, directly under
`## [Unreleased]` and above the first `###` heading or `- ` entry; a fragment
cannot carry one. The notice is copied verbatim to the top of the release body
and stays under the version's heading in `CHANGELOG.md`, so it goes through
review like any other change and remains on record. The fresh
`## [Unreleased]` the release opens has no notice, so each one appears in one
release only. Only that top position counts: a paragraph anywhere else in the
section still fails the run, and a section holding a notice but no entries is
still nothing to release.

Two details of the promotion are worth knowing, because both have bitten this
project:

Expand Down
39 changes: 32 additions & 7 deletions scripts/changelog_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,21 @@ def promote(text: str, version: str, date: str) -> tuple[str, str]:
return rewritten, body.strip() + "\n"


def _split_notice(body: str) -> tuple[str, str]:
"""Split the notice off the top of a changelog section.

The notice is the text above the first `###` heading and the first `- `
entry, such as "This is the last 0.3.x release". Returns
`(notice, rest)`; the notice is `""` when the section has none.
"""
lines = body.splitlines()
end = next(
(index for index, line in enumerate(lines) if line.startswith(("### ", "- "))),
len(lines),
)
return "\n".join(_trim(lines[:end])), "\n".join(lines[end:])


def _entries(body: str) -> list[tuple[str, str]]:
"""Split a changelog section into `(### heading, entry text)` pairs.

Expand Down Expand Up @@ -397,24 +412,34 @@ def release_notes(body: str, version: str, date: str) -> str:
date: The release date, `YYYY-MM-DD`.

Returns:
Each entry's bold summary and issue links, under the section's `###`
headings, then a link to the full entries on the documentation site.
The section's notice, if any, verbatim; each entry's bold summary and
issue links, under the section's `###` headings; then a link to the
full entries on the documentation site.

Raises:
ChangelogError: A line is neither a heading nor an entry, or an entry
does not open with a bold summary.
ChangelogError: The section has no entries, a line below the notice
is neither a heading nor an entry, or an entry does not open with
a bold summary.
"""
lines: list[str] = []
notice, rest = _split_notice(body)
entries = _entries(rest)
if not entries:
msg = (
"the release has no changelog entries; a notice alone is nothing to release"
)
raise ChangelogError(msg)

lines: list[str] = [notice, ""] if notice else []
missing: list[str] = []
heading = ""
for section, text in _entries(body):
for section, text in entries:
summary = _SUMMARY.match(text)
if summary is None:
missing.append(text[:70])
continue
if section != heading:
heading = section
lines += ["", heading, ""] if lines else [heading, ""]
lines += ["", heading, ""] if lines and lines[-1] else [heading, ""]
links = " ".join(_ISSUE_LINK.findall(text))
line = " ".join(summary["summary"].split())
lines.append(f"- {line} {links}".rstrip())
Expand Down
76 changes: 76 additions & 0 deletions tests/test_changelog_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,82 @@ def test_release_notes_accept_entries_before_any_heading():
assert notes.startswith("- Loose entry.\n\n**Full changelog**: ")


NOTICE = (
"0.3.9 is the last 0.3.x release. 0.4.0 contains breaking changes;\n"
"see [Migrating to 0.4.0](https://example.com/migrating)."
)


def test_release_notes_open_with_the_notice_verbatim():
body = f"{NOTICE}\n\nA second paragraph.\n\n### Added\n\n- **A thing.** Details.\n"

notes = release_notes(body, "1.2.3", "2026-10-01")

assert notes.startswith(
f"{NOTICE}\n\nA second paragraph.\n\n### Added\n\n- A thing.\n\n"
)


def test_a_notice_comes_before_loose_entries():
notes = release_notes(
f"{NOTICE}\n\n- **Loose.**\n\n### Fixed\n\n- **Fixed.**\n",
"1.2.3",
"2026-10-01",
)

assert notes.startswith(
f"{NOTICE}\n\n- Loose.\n\n### Fixed\n\n- Fixed.\n\n**Full changelog**: "
)


def test_text_below_the_first_heading_is_still_not_a_notice():
body = f"### Added\n\n- **A thing.**\n\n{NOTICE}\n"

with pytest.raises(ChangelogError, match="neither a `###` heading"):
release_notes(body, "1.2.3", "2026-10-01")


@pytest.mark.parametrize("body", [f"{NOTICE}\n", f"{NOTICE}\n\n### Added\n", ""])
def test_a_release_without_entries_is_an_error(body: str):
with pytest.raises(ChangelogError, match="no changelog entries"):
release_notes(body, "1.2.3", "2026-10-01")


def test_a_notice_is_released_with_the_fragments_and_kept_in_the_changelog(
tmp_path: Path,
):
noticed = CHANGELOG.replace(
"## [Unreleased]\n\n### Added", f"## [Unreleased]\n\n{NOTICE}\n\n### Added"
)
directory = _fragments(tmp_path, {"2.fixed.md": "**Two.**"})
notes_path = tmp_path / "notes.md"
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text(noticed, encoding="utf-8")

assert (
main(
[
"--version=0.3.5",
"--date=2026-09-14",
f"--changelog={changelog}",
f"--fragments={directory}",
f"--release-notes={notes_path}",
]
)
== 0
)

assert notes_path.read_text(encoding="utf-8").startswith(
f"{NOTICE}\n\n### Added\n\n- A thing. ([#1]({BASE}/issues/1))\n\n"
f"### Fixed\n\n- Two. {_link(2)}\n\n"
)
rewritten = changelog.read_text(encoding="utf-8")
assert (
f"## [Unreleased]\n\n## [0.3.5] - 2026-09-14\n\n{NOTICE}\n\n### Added\n"
in rewritten
)


def test_main_refuses_to_release_an_entry_without_a_summary(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
):
Expand Down
Loading