diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d93f3b01..29180e21 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -70,7 +70,7 @@ corresponding workflows; you only need to configure workflows you use. | `PROGRAMMING_LANGUAGE` | Selecting the plugin that provides language-specific build, documentation and release operations (for example, `"Python"` or `"Golang"`). | | `MASTER_BRANCH` | Comparing pull-request branches with the main development branch; set this explicitly if your branch is `main` rather than the built-in `master` default. | | `NEWS_DIR` | Creating and checking news fragments and triggering changelog updates. | -| `VERSION_FILE_PATH`, `CHANGELOG_FILE_PATH` | Updating version and release-note files during a release. Also configure `[AutoVersionConfig]` and `[tool.towncrier]` for version and changelog generation. | +| `VERSION_FILE_PATH`, `CHANGELOG_FILE_PATH` | Updating version and release-note files during a release. Also configure `[AutoVersionConfig]`; release-note generation additionally needs Towncrier configuration, documented below. | | `SOURCE_DIR` | Locating source files for plugins and SPDX file scanning where supported. | | `PACKAGE_NAME` | Identifying the installed distribution for the Python metadata fetcher when SPDX reporting is supported. | | `PROJECT_UUID` | Identifying the project's package within generated SPDX documents; also define the separate `[spdx]` namespace settings below. | @@ -93,6 +93,55 @@ and use specialised tools, such as GoReleaser for Go. See the Provide tokens and publication credentials through your CI environment or secret store, rather than committing them to `pyproject.toml`. +### Changelog configuration + +Release-note generation uses [Towncrier](https://towncrier.readthedocs.io/en/stable/) +and reads its settings from `[tool.towncrier]` in `pyproject.toml`. See the +Towncrier [configuration reference](https://towncrier.readthedocs.io/en/stable/configuration.html) +for the full set of options. + +The commands in this repository expect the following Towncrier configuration: + +| Setting | Needed for | +| --- | --- | +| `[tool.towncrier].directory` | Locating the news fragments used to build release notes. This should match `NEWS_DIR` from `[ProjectConfig]`. | +| `[tool.towncrier].filename` | Choosing which changelog file Towncrier updates. This should match `CHANGELOG_FILE_PATH` from `[ProjectConfig]`. | +| `[tool.towncrier].package` | Resolving project metadata used by Towncrier when building release notes. | +| `[tool.towncrier].title_format` | Rendering the release title. For Markdown changelogs, use an explicit heading such as `# {version} ({project_date})`; see the Towncrier [`title_format` documentation](https://towncrier.readthedocs.io/en/stable/configuration.html#title-format). | +| `[tool.towncrier].start_string` | Marking where generated release notes begin inside the changelog file. | +| `[[tool.towncrier.type]]` with `directory`, `name`, `showcontent` | Defining the fragment categories that contributors can create and that releases render. | + +This repository also carries a Towncrier Markdown workaround for +[towncrier issue #758](https://github.com/twisted/towncrier/issues/758): if +`title_format` does not already contain a Markdown heading, the release command +adds `# ` in a temporary Towncrier config before running `towncrier build`; see +the Towncrier [`build` command reference](https://towncrier.readthedocs.io/en/stable/cli.html#build). +If your `title_format` already starts with `#`, or otherwise already defines a +Markdown heading, the workaround leaves it unchanged. + +Example: + +```toml +[tool.towncrier] +directory = "news" +filename = "CHANGELOG.md" +package = "example_package" +title_format = "# {version} ({project_date})" +start_string = """ +[//]: # (begin_release_notes) +""" + +[[tool.towncrier.type]] +directory = "feature" +name = "Features" +showcontent = true + +[[tool.towncrier.type]] +directory = "bugfix" +name = "Bugfixes" +showcontent = true +``` + ### Proprietary licences For proprietary projects, set `FILE_LICENCE_IDENTIFIER = "Proprietary"` in diff --git a/continuous_delivery_scripts/generate_news.py b/continuous_delivery_scripts/generate_news.py index 09f4ed69..ee81eec7 100644 --- a/continuous_delivery_scripts/generate_news.py +++ b/continuous_delivery_scripts/generate_news.py @@ -8,23 +8,15 @@ 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 -from continuous_delivery_scripts.utils.configuration import configuration, ConfigurationVariable +from continuous_delivery_scripts.utils.changelog import generate_changelog from continuous_delivery_scripts.utils.definitions import CommitType -from continuous_delivery_scripts.utils.filesystem_helpers import cd from continuous_delivery_scripts.utils.logging import log_exception, set_log_level 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. @@ -53,56 +45,7 @@ def _generate_changelog(version: Optional[str], use_news_files: bool) -> None: """ if use_news_files: logger.info(":: Generating a new changelog") - 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") + generate_changelog(version) def main() -> None: diff --git a/continuous_delivery_scripts/utils/changelog.py b/continuous_delivery_scripts/utils/changelog.py new file mode 100644 index 00000000..fa6bf238 --- /dev/null +++ b/continuous_delivery_scripts/utils/changelog.py @@ -0,0 +1,108 @@ +# +# Copyright (C) 2020-2026 Arm Limited or its affiliates and Contributors. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +"""Helpers for generating and repairing changelogs.""" + +from contextlib import contextmanager +import os +from pathlib import Path +import re +import subprocess +import tempfile +from typing import Iterator, Optional + +import toml + +from continuous_delivery_scripts.utils.configuration import ConfigurationVariable, configuration +from continuous_delivery_scripts.utils.filesystem_helpers import cd + +_MARKDOWN_CHANGELOG_SUFFIXES = {".md", ".markdown"} +_RELEASE_TITLE_PATTERN = re.compile(r'^"?[vV]?\d+\.\d+\.\d+[^\n]*$') + + +def generate_changelog(version: Optional[str]) -> None: + """Builds the changelog with Towncrier and normalises Markdown output when needed.""" + project_config_path = configuration.get_value(ConfigurationVariable.PROJECT_CONFIG) + with _create_towncrier_workaround_config(project_config_path) as workaround_config_path: + # See Towncrier's build command docs: + # https://towncrier.readthedocs.io/en/stable/cli.html#build + command = ["towncrier", "build", "--yes", "--name", "", "--version", str(version)] + if workaround_config_path: + command.extend(["--config", workaround_config_path]) + + with cd(os.path.dirname(project_config_path)): + subprocess.check_call(command) + + # 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.""" + 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 _title_format_has_markdown_heading(title_format: str) -> bool: + """Checks whether a Towncrier title format already defines a Markdown heading.""" + return any(line.lstrip().startswith("#") for line in title_format.splitlines() if line.strip()) + + +@contextmanager +def _create_towncrier_workaround_config(project_config_path: str) -> Iterator[Optional[str]]: + """Yields a temporary Towncrier config with a Markdown title heading if needed.""" + # FIXME: Remove this workaround when https://github.com/twisted/towncrier/issues/758 is fixed. + # Towncrier documents Markdown title handling under title_format: + # https://towncrier.readthedocs.io/en/stable/configuration.html#title-format + config = toml.load(project_config_path) + towncrier_config = config.get("tool", {}).get("towncrier", {}) + title_format = towncrier_config.get("title_format") + if not isinstance(title_format, str) or _title_format_has_markdown_heading(title_format): + yield None + return + + config["tool"]["towncrier"]["title_format"] = f"# {title_format}" + config_dir = os.path.dirname(project_config_path) + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf8", suffix=".toml", prefix="towncrier-", dir=config_dir, delete=False + ) as temp_config: + toml.dump(config, temp_config) + + try: + yield temp_config.name + finally: + os.remove(temp_config.name) diff --git a/news/181.bugfix b/news/181.bugfix new file mode 100644 index 00000000..a0e9cb7b --- /dev/null +++ b/news/181.bugfix @@ -0,0 +1 @@ +:bug: Improve Towncrier Markdown changelog handling (https://github.com/twisted/towncrier/issues/758) and document the required configuration. diff --git a/pyproject.toml b/pyproject.toml index e31b22c2..7fcaee28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,10 @@ patch = "news/*.bugfix" directory = "news" filename = "CHANGELOG.md" package = "continuous_delivery_scripts" -title_format = "{version} ({project_date})" +# Towncrier's newer Markdown behaviour requires the heading marker in +# title_format. Keep this explicit until the regression/behaviour change is +# clearer upstream. +title_format = "# {version} ({project_date})" start_string = """ [//]: # (begin_release_notes) """ diff --git a/tests/versioning/test_changelog.py b/tests/versioning/test_changelog.py new file mode 100644 index 00000000..6f3cbb9a --- /dev/null +++ b/tests/versioning/test_changelog.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +# +# Copyright (C) 2020-2026 Arm Limited or its affiliates and Contributors. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import TestCase, mock + +import toml + +from continuous_delivery_scripts.utils.changelog import generate_changelog, normalise_markdown_release_headings +from continuous_delivery_scripts.utils.configuration import ConfigurationVariable, configuration + + +class TestChangelog(TestCase): + @mock.patch("continuous_delivery_scripts.utils.changelog.normalise_markdown_release_headings") + @mock.patch("continuous_delivery_scripts.utils.changelog.subprocess.check_call") + def test_generate_changelog_does_not_duplicate_existing_markdown_heading(self, check_call, normalise): + with TemporaryDirectory() as temp_dir: + project_config = Path(temp_dir) / "pyproject.toml" + project_config.write_text( + "[tool.towncrier]\n" 'title_format = "# {version} ({project_date})"\n', encoding="utf8" + ) + get_value = configuration.get_value + + with mock.patch.object( + configuration, + "get_value", + side_effect=lambda key: ( + str(project_config) if key == ConfigurationVariable.PROJECT_CONFIG else get_value(key) + ), + ): + generate_changelog("1.2.3") + + check_call.assert_called_once_with(["towncrier", "build", "--yes", "--name", "", "--version", "1.2.3"]) + normalise.assert_called_once_with("1.2.3") + + @mock.patch("continuous_delivery_scripts.utils.changelog.normalise_markdown_release_headings") + @mock.patch("continuous_delivery_scripts.utils.changelog.subprocess.check_call") + def test_generate_changelog_adds_markdown_heading_workaround_when_missing(self, check_call, normalise): + with TemporaryDirectory() as temp_dir: + project_config = Path(temp_dir) / "pyproject.toml" + project_config.write_text( + "[tool.towncrier]\n" 'title_format = "{version} ({project_date})"\n', encoding="utf8" + ) + get_value = configuration.get_value + temp_config_path = None + + def inspect_call(command): + nonlocal temp_config_path + temp_config_path = Path(command[command.index("--config") + 1]) + config = toml.load(temp_config_path) + self.assertEqual(config["tool"]["towncrier"]["title_format"], "# {version} ({project_date})") + + check_call.side_effect = inspect_call + + with mock.patch.object( + configuration, + "get_value", + side_effect=lambda key: ( + str(project_config) if key == ConfigurationVariable.PROJECT_CONFIG else get_value(key) + ), + ): + generate_changelog("1.2.3") + + self.assertIsNotNone(temp_config_path) + self.assertFalse(temp_config_path.exists()) + normalise.assert_called_once_with("1.2.3") + + 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) diff --git a/tests/versioning/test_generate_news.py b/tests/versioning/test_generate_news.py index 709ce640..e4cd2715 100644 --- a/tests/versioning/test_generate_news.py +++ b/tests/versioning/test_generate_news.py @@ -8,30 +8,16 @@ from tempfile import TemporaryDirectory from unittest import TestCase, mock, skipUnless -from continuous_delivery_scripts.generate_news import _generate_changelog, _normalise_markdown_release_headings +from continuous_delivery_scripts.generate_news import _generate_changelog 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, normalise): - with TemporaryDirectory() as temp_dir: - project_config = Path(temp_dir) / "pyproject.toml" - project_config.write_text("[tool.towncrier]\n", encoding="utf8") - get_value = configuration.get_value - - with mock.patch.object( - configuration, - "get_value", - side_effect=lambda key: ( - str(project_config) if key == ConfigurationVariable.PROJECT_CONFIG else get_value(key) - ), - ): - _generate_changelog("1.2.3", True) + @mock.patch("continuous_delivery_scripts.generate_news.generate_changelog") + def test_generate_changelog_invokes_changelog_utility(self, generate): + _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") + generate.assert_called_once_with("1.2.3") @skipUnless(which("towncrier"), "towncrier executable required") def test_generate_changelog_builds_release_notes_with_towncrier(self): @@ -71,66 +57,12 @@ def test_generate_changelog_builds_release_notes_with_towncrier(self): rendered = changelog.read_text(encoding="utf8") - self.assertIn("1.2.3", rendered) + 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") + def test_generate_changelog_does_nothing_when_news_files_are_disabled(self): + with mock.patch("continuous_delivery_scripts.generate_news.generate_changelog") as generate: + _generate_changelog("1.2.3", False) - self.assertEqual(rendered, original) + generate.assert_not_called()