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
52 changes: 52 additions & 0 deletions continuous_delivery_scripts/generate_news.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -51,6 +56,53 @@ 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)


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:
Expand Down
1 change: 1 addition & 0 deletions news/20261002184519412000000.bugfix
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 64 additions & 2 deletions tests/versioning/test_generate_news.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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):
Expand Down Expand Up @@ -72,3 +74,63 @@ 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)
Loading