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
51 changes: 50 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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
Expand Down
61 changes: 2 additions & 59 deletions continuous_delivery_scripts/generate_news.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
108 changes: 108 additions & 0 deletions continuous_delivery_scripts/utils/changelog.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions news/181.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:bug: Improve Towncrier Markdown changelog handling (https://github.com/twisted/towncrier/issues/758) and document the required configuration.
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down
Loading
Loading