Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1153dae
Test what a changelog fragment is before the definition exists
anth-volk Oct 29, 2026
6c5189c
Define a changelog fragment once, from [tool.towncrier]
anth-volk Oct 29, 2026
3567c22
Test that the PR check needs the pull request's own readable fragment
anth-volk Oct 29, 2026
7a54bf5
Test that empty folders and submodules are handled as git checks them…
anth-volk Oct 29, 2026
dd1d2f7
Check the pull request's own fragments against the shared definition
anth-volk Oct 29, 2026
e63b7c0
Test that the version bump reads fragments through the shared definition
anth-volk Oct 29, 2026
a37f0c1
Bump the version through the shared fragment definition
anth-volk Oct 29, 2026
e9b27ff
Test that the locked Towncrier stops at files it can't read
anth-volk Oct 29, 2026
2c25218
Make Towncrier fail on files in changelog.d/ it can't read
anth-volk Oct 29, 2026
0e79882
Describe the stricter changelog fragment rule and its exact form
anth-volk Oct 29, 2026
f3b34ed
Test that a fragment of only invisible characters is refused
anth-volk Oct 29, 2026
c8995b0
Refuse a fragment with no visible character
anth-volk Oct 29, 2026
a95087a
Test that a fragment the base branch already released doesn't count
anth-volk Oct 29, 2026
1770bb9
Refuse a fragment the base branch has already released
anth-volk Oct 29, 2026
34c9a40
Test that only a released fragment's own text counts as released
anth-volk Oct 29, 2026
8919c32
Count a fragment as released only when it holds the released text
anth-volk Oct 29, 2026
9aeb9d3
Say "created from" for a test branch's starting point
anth-volk Oct 29, 2026
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
130 changes: 97 additions & 33 deletions .github/bump_version.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,126 @@
"""Infer semver bump from towncrier fragment types and update version."""
"""Raise the version in pyproject.toml from the pending changelog fragments' types.

The versioning job in .github/workflows/push.yml and ``make changelog`` run
this before Towncrier builds CHANGELOG.md. It reads the fragments through
``FragmentDefinition`` in changelog_fragments.py, beside this file, the
definition the PR check uses too (rule api.process.changelog-fragment). It
exits with status 1, changing nothing, when changelog.d/ holds anything that
isn't a fragment, holds no fragment, or when ``BUMP_BY_TYPE`` and the types in
[tool.towncrier] differ.
"""

import re
import sys
from pathlib import Path
from typing import TextIO

# A fragment named "<issue>.<category>.md" has at least two dot-separated stem parts.
STEM_PARTS_WITH_CATEGORY = 2
from typing import NoReturn, TextIO

from changelog_fragments import (
CONFIG_TABLE,
ConfigurationError,
FragmentDefinition,
)

# The version part each fragment type raises: breaking the major version, added
# and removed the minor version, and changed and fixed the patch version. Its
# keys must be the types in [tool.towncrier]; infer_bump refuses to run when
# they differ.
BUMP_BY_TYPE = {
"breaking": "major",
"added": "minor",
"changed": "patch",
"fixed": "patch",
"removed": "minor",
}
# Version parts from the smallest bump to the largest.
BUMP_SIZES = ("patch", "minor", "major")


def _listed(names: list[str]) -> str:
"""Return ``names`` as "a, b and c", or "none"."""
if not names:
return "none"
if len(names) == 1:
return names[0]
return f"{', '.join(names[:-1])} and {names[-1]}"


def _report(message: str, *, file: TextIO | None = None) -> None:
"""Print one line of this command's output, to stdout unless ``file`` is given."""
print(message, file=file)


def _fail(message: str) -> NoReturn:
"""Print ``message`` to stderr and exit with status 1."""
_report(message, file=sys.stderr)
sys.exit(1)


def get_current_version(pyproject_path: Path) -> str:
"""Return the version in pyproject.toml, exiting with status 1 if it is absent."""
text = pyproject_path.read_text()
match = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', text, re.MULTILINE)
if not match:
_report(
"Could not find version in pyproject.toml",
file=sys.stderr,
)
sys.exit(1)
_fail("Could not find version in pyproject.toml")
return match.group(1)


def infer_bump(changelog_dir: Path) -> str:
"""Return "major", "minor" or "patch" from the pending changelog fragment types.
def _require_a_bump_for_each_type(types: tuple[str, ...]) -> None:
"""Exit with status 1 unless ``BUMP_BY_TYPE`` has a bump for exactly ``types``."""
if set(types) == set(BUMP_BY_TYPE):
return
_fail(
f"The fragment types in {CONFIG_TABLE} are {_listed(list(types))}, but "
f"BUMP_BY_TYPE in .github/bump_version.py has a bump for "
f"{_listed(list(BUMP_BY_TYPE))}. Types with no bump: "
f"{_listed([name for name in types if name not in BUMP_BY_TYPE])}. "
f"Bumps with no type: "
f"{_listed([name for name in BUMP_BY_TYPE if name not in types])}. "
"Change both together."
)


def infer_bump(root: Path) -> str:
"""Return "major", "minor" or "patch" from the fragments in ``root``'s changelog.d/.

Any breaking fragment makes a major bump and any added or removed fragment a
minor one; exit with status 1 when there are no fragments.
minor one. Exit with status 1 when the directory holds a file that isn't a
fragment, or no fragment.
"""
fragments = [
f for f in changelog_dir.iterdir() if f.is_file() and f.name != ".gitkeep"
try:
definition = FragmentDefinition.load(root)
except ConfigurationError as error:
_fail(str(error))
_require_a_bump_for_each_type(definition.types)
scan = definition.scan(root)
problems = [
*scan.problems.values(),
*(definition.empty_folder_problem(path) for path in scan.empty_folders),
]
if not fragments:
_report("No changelog fragments found", file=sys.stderr)
sys.exit(1)

categories = {f.suffix.lstrip(".") for f in fragments}
for f in fragments:
parts = f.stem.split(".")
if len(parts) >= STEM_PARTS_WITH_CATEGORY:
categories.add(parts[-1])

if "breaking" in categories:
return "major"
if "added" in categories or "removed" in categories:
return "minor"
return "patch"
if problems:
for problem in problems:
_report(problem, file=sys.stderr)
if len(problems) == 1:
unreadable = "1 of them is not a fragment. Fix it"
else:
unreadable = f"{len(problems)} of them are not fragments. Fix them"
_fail(
f"The release reads every file in {definition.directory}/, and "
f"{unreadable} in a pull request; the release stops until then."
)
if not scan.fragments:
_fail(
f"No changelog fragments found in {definition.directory}/: there is "
"nothing to release."
)
return max(
(BUMP_BY_TYPE[fragment.fragment_type] for fragment in scan.fragments),
key=BUMP_SIZES.index,
)


def bump_version(version: str, bump: str) -> str:
"""Return ``version`` raised by one ``bump`` level, resetting the lower parts."""
major, minor, patch = (int(x) for x in version.split("."))
major, minor, patch = (int(part) for part in version.split("."))
if bump == "major":
return f"{major + 1}.0.0"
if bump == "minor":
Expand All @@ -79,10 +144,9 @@ def main() -> None:
"""Bump the version in pyproject.toml according to the changelog.d fragments."""
root = Path(__file__).resolve().parent.parent
pyproject = root / "pyproject.toml"
changelog_dir = root / "changelog.d"

current = get_current_version(pyproject)
bump = infer_bump(changelog_dir)
bump = infer_bump(root)
new = bump_version(current, bump)

_report(f"Version: {current} -> {new} ({bump})")
Expand Down
Loading
Loading