From 78bcaaaae09b785bd6a9a0469e9da542ef8170e9 Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Fri, 2 Oct 2026 21:48:50 +0100 Subject: [PATCH 1/3] :bug: Fix markdown changelog release headings --- continuous_delivery_scripts/generate_news.py | 57 ++++++++++++++++++ news/20261002184519412000000.bugfix | 1 + tests/versioning/test_generate_news.py | 62 +++++++++++++++++++- 3 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 news/20261002184519412000000.bugfix diff --git a/continuous_delivery_scripts/generate_news.py b/continuous_delivery_scripts/generate_news.py index 020aec1..8b3c030 100644 --- a/continuous_delivery_scripts/generate_news.py +++ b/continuous_delivery_scripts/generate_news.py @@ -9,7 +9,9 @@ import argparse import logging import os +import re import subprocess +from pathlib import Path from continuous_delivery_scripts.utils.versioning import calculate_version, determine_version_string from typing import Optional, Tuple, Dict @@ -20,6 +22,9 @@ logger = logging.getLogger(__name__) +_MARKDOWN_CHANGELOG_SUFFIXES = {".md", ".markdown"} +_RELEASE_TITLE_PATTERN = re.compile(r'^"?[vV]?\d+\.\d+\.\d+[^\n]*$') + def version_project(commit_type: CommitType) -> Tuple[bool, Optional[str], Dict[str, str]]: """Versions the project. @@ -51,6 +56,58 @@ def _generate_changelog(version: Optional[str], use_news_files: bool) -> None: project_config_path = configuration.get_value(ConfigurationVariable.PROJECT_CONFIG) with cd(os.path.dirname(project_config_path)): subprocess.check_call(["towncrier", "build", "--yes", "--name", "", "--version", str(version)]) + _normalise_markdown_release_headings(version) + + +def _normalise_markdown_release_headings(version: Optional[str]) -> None: + """Promote the latest markdown release title to a heading when Towncrier omits it. + + Recent Towncrier markdown output renders the release title as plain text but + still emits section headings with leading `#`. Promote the newest release + title to a markdown heading and demote its section headings by one level so + GitHub renders the release block hierarchy correctly. + """ + if not version: + return + + changelog_path = Path(str(configuration.get_value(ConfigurationVariable.CHANGELOG_FILE_PATH))) + if changelog_path.suffix.lower() not in _MARKDOWN_CHANGELOG_SUFFIXES or not changelog_path.exists(): + return + + original = changelog_path.read_text(encoding="utf8") + lines = original.splitlines() + version_index = next( + ( + index + for index, line in enumerate(lines) + if line.startswith(f"{version} ") and not line.startswith("#") + ), + None, + ) + if version_index is None: + return + + next_release_index = next( + ( + index + for index in range(version_index + 1, len(lines)) + if _RELEASE_TITLE_PATTERN.match(lines[index]) and not lines[index].startswith("#") + ), + len(lines), + ) + section_indexes = [ + index for index in range(version_index + 1, next_release_index) if lines[index].startswith("# ") + ] + if not section_indexes: + return + + lines[version_index] = f"# {lines[version_index]}" + for index in section_indexes: + lines[index] = f"#{lines[index]}" + + rendered = "\n".join(lines) + ("\n" if original.endswith("\n") else "") + if rendered != original: + changelog_path.write_text(rendered, encoding="utf8") def main() -> None: diff --git a/news/20261002184519412000000.bugfix b/news/20261002184519412000000.bugfix new file mode 100644 index 0000000..720ae87 --- /dev/null +++ b/news/20261002184519412000000.bugfix @@ -0,0 +1 @@ +:bug: Preserve markdown changelog headings by promoting the latest Towncrier release title to a heading and demoting its section headings so GitHub renders release notes correctly. diff --git a/tests/versioning/test_generate_news.py b/tests/versioning/test_generate_news.py index 4e2f719..11b5a02 100644 --- a/tests/versioning/test_generate_news.py +++ b/tests/versioning/test_generate_news.py @@ -8,13 +8,14 @@ from tempfile import TemporaryDirectory from unittest import TestCase, mock, skipUnless -from continuous_delivery_scripts.generate_news import _generate_changelog +from continuous_delivery_scripts.generate_news import _generate_changelog, _normalise_markdown_release_headings from continuous_delivery_scripts.utils.configuration import ConfigurationVariable, configuration class TestGenerateNews(TestCase): + @mock.patch("continuous_delivery_scripts.generate_news._normalise_markdown_release_headings") @mock.patch("continuous_delivery_scripts.generate_news.subprocess.check_call") - def test_generate_changelog_invokes_towncrier_with_separate_option_values(self, check_call): + def test_generate_changelog_invokes_towncrier_with_separate_option_values(self, check_call, normalise): with TemporaryDirectory() as temp_dir: project_config = Path(temp_dir) / "pyproject.toml" project_config.write_text("[tool.towncrier]\n", encoding="utf8") @@ -30,6 +31,7 @@ def test_generate_changelog_invokes_towncrier_with_separate_option_values(self, _generate_changelog("1.2.3", True) check_call.assert_called_once_with(["towncrier", "build", "--yes", "--name", "", "--version", "1.2.3"]) + normalise.assert_called_once_with("1.2.3") @skipUnless(which("towncrier"), "towncrier executable required") def test_generate_changelog_builds_release_notes_with_towncrier(self): @@ -72,3 +74,59 @@ def test_generate_changelog_builds_release_notes_with_towncrier(self): self.assertIn("1.2.3", rendered) self.assertIn("Bugfixes", rendered) self.assertIn("Fixed example bug.", rendered) + + def test_normalise_markdown_release_headings_promotes_latest_release_title(self): + with TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + changelog = root / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n\n" + "[//]: # (begin_release_notes)\n\n" + "1.2.3 (2026-10-02)\n\n" + "# Bugfixes\n\n" + "- Fixed example bug. (#123)\n\n" + '"1.2.2" (2026-10-01)\n' + "====================\n\n" + "Bugfixes\n" + "--------\n", + encoding="utf8", + ) + get_value = configuration.get_value + + with mock.patch.object( + configuration, + "get_value", + side_effect=lambda key: str(changelog) if key == ConfigurationVariable.CHANGELOG_FILE_PATH else get_value(key), + ): + _normalise_markdown_release_headings("1.2.3") + + rendered = changelog.read_text(encoding="utf8") + + self.assertIn("# 1.2.3 (2026-10-02)", rendered) + self.assertIn("## Bugfixes", rendered) + self.assertIn('"1.2.2" (2026-10-01)', rendered) + + def test_normalise_markdown_release_headings_leaves_existing_heading_alone(self): + with TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + changelog = root / "CHANGELOG.md" + original = ( + "# Changelog\n\n" + "[//]: # (begin_release_notes)\n\n" + "# 1.2.3 (2026-10-02)\n\n" + "## Bugfixes\n\n" + "- Fixed example bug. (#123)\n" + ) + changelog.write_text(original, encoding="utf8") + get_value = configuration.get_value + + with mock.patch.object( + configuration, + "get_value", + side_effect=lambda key: str(changelog) if key == ConfigurationVariable.CHANGELOG_FILE_PATH else get_value(key), + ): + _normalise_markdown_release_headings("1.2.3") + + rendered = changelog.read_text(encoding="utf8") + + self.assertEqual(rendered, original) From 794c055fe34f3536f6ec122d4092c79d827bf9a8 Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Fri, 2 Oct 2026 21:58:30 +0100 Subject: [PATCH 2/3] :memo: Note Towncrier workaround removal condition --- continuous_delivery_scripts/generate_news.py | 1 + 1 file changed, 1 insertion(+) diff --git a/continuous_delivery_scripts/generate_news.py b/continuous_delivery_scripts/generate_news.py index 8b3c030..18af586 100644 --- a/continuous_delivery_scripts/generate_news.py +++ b/continuous_delivery_scripts/generate_news.py @@ -56,6 +56,7 @@ def _generate_changelog(version: Optional[str], use_news_files: bool) -> None: project_config_path = configuration.get_value(ConfigurationVariable.PROJECT_CONFIG) with cd(os.path.dirname(project_config_path)): subprocess.check_call(["towncrier", "build", "--yes", "--name", "", "--version", str(version)]) + # FIXME: Remove this workaround when https://github.com/twisted/towncrier/issues/758 is fixed. _normalise_markdown_release_headings(version) From 4933c327b4302371a5f412b939e686c4c3174fa7 Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Fri, 2 Oct 2026 22:00:35 +0100 Subject: [PATCH 3/3] :art: Format markdown changelog workaround --- continuous_delivery_scripts/generate_news.py | 10 ++-------- tests/versioning/test_generate_news.py | 8 ++++++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/continuous_delivery_scripts/generate_news.py b/continuous_delivery_scripts/generate_news.py index 18af586..09f4ed6 100644 --- a/continuous_delivery_scripts/generate_news.py +++ b/continuous_delivery_scripts/generate_news.py @@ -78,11 +78,7 @@ def _normalise_markdown_release_headings(version: Optional[str]) -> None: original = changelog_path.read_text(encoding="utf8") lines = original.splitlines() version_index = next( - ( - index - for index, line in enumerate(lines) - if line.startswith(f"{version} ") and not line.startswith("#") - ), + (index for index, line in enumerate(lines) if line.startswith(f"{version} ") and not line.startswith("#")), None, ) if version_index is None: @@ -96,9 +92,7 @@ def _normalise_markdown_release_headings(version: Optional[str]) -> None: ), len(lines), ) - section_indexes = [ - index for index in range(version_index + 1, next_release_index) if lines[index].startswith("# ") - ] + section_indexes = [index for index in range(version_index + 1, next_release_index) if lines[index].startswith("# ")] if not section_indexes: return diff --git a/tests/versioning/test_generate_news.py b/tests/versioning/test_generate_news.py index 11b5a02..709ce64 100644 --- a/tests/versioning/test_generate_news.py +++ b/tests/versioning/test_generate_news.py @@ -96,7 +96,9 @@ def test_normalise_markdown_release_headings_promotes_latest_release_title(self) with mock.patch.object( configuration, "get_value", - side_effect=lambda key: str(changelog) if key == ConfigurationVariable.CHANGELOG_FILE_PATH else get_value(key), + side_effect=lambda key: ( + str(changelog) if key == ConfigurationVariable.CHANGELOG_FILE_PATH else get_value(key) + ), ): _normalise_markdown_release_headings("1.2.3") @@ -123,7 +125,9 @@ def test_normalise_markdown_release_headings_leaves_existing_heading_alone(self) with mock.patch.object( configuration, "get_value", - side_effect=lambda key: str(changelog) if key == ConfigurationVariable.CHANGELOG_FILE_PATH else get_value(key), + side_effect=lambda key: ( + str(changelog) if key == ConfigurationVariable.CHANGELOG_FILE_PATH else get_value(key) + ), ): _normalise_markdown_release_headings("1.2.3")