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] ):