From 3031cec9a4136cdbfdfb67c2c59879243ee3ee1a Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 06:52:08 +0000 Subject: [PATCH] feat(release): open the release notes with a notice written above the entries Text directly under ## [Unreleased], above the first heading or entry, is copied verbatim to the top of the GitHub release notes and kept under the version's heading in CHANGELOG.md. It used to fail the release as a line that is neither a heading nor an entry. A section with a notice but no entries is still nothing to release. Closes #354 --- changelog.d/README.md | 4 ++ docs/DEVELOPMENT.md | 11 +++++ scripts/changelog_release.py | 39 ++++++++++++++--- tests/test_changelog_release.py | 76 +++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 7 deletions(-) diff --git a/changelog.d/README.md b/changelog.d/README.md index 1d70c4f..a5eaca4 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 00dda63..2842442 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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: diff --git a/scripts/changelog_release.py b/scripts/changelog_release.py index ad7348b..509abd0 100644 --- a/scripts/changelog_release.py +++ b/scripts/changelog_release.py @@ -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. @@ -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()) diff --git a/tests/test_changelog_release.py b/tests/test_changelog_release.py index 07ce2f9..fa1c5eb 100644 --- a/tests/test_changelog_release.py +++ b/tests/test_changelog_release.py @@ -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] ):