From 521c79338cc0a4425ef89c98d4c7a5c51dd69334 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:47:31 -0400 Subject: [PATCH 1/7] Fail pull requests that add changelog fragments towncrier drops towncrier writes a fragment to CHANGELOG.md only when its name carries a configured type; any other file in changelog.d is never read or deleted, and an empty fragment is left out. Add a Changelog fragments check that fails a PR adding or changing such a file, and make bump_version.py count only the fragments towncrier consumes, warning about the rest. Refs #2314 Co-Authored-By: Claude Opus 5.5 --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/bump_version.py | 160 +++++++++++++++--- .github/check_changelog_fragments.py | 231 ++++++++++++++++++++++++++ .github/workflows/pr_changelog.yaml | 40 +++++ changelog.d/2314.fixed.md | 1 + docs/engineering/skills/github-prs.md | 16 +- 6 files changed, 425 insertions(+), 25 deletions(-) create mode 100644 .github/check_changelog_fragments.py create mode 100644 .github/workflows/pr_changelog.yaml create mode 100644 changelog.d/2314.fixed.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index efaaeeb295..3dddc19b41 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,7 +6,7 @@ Fixes # - [ ] For policy changes: YAML tests whose expected values come from an external source (legislation, official guidance, official calculator); otherwise mark not applicable. - [ ] `make format` -- [ ] Changelog fragment in `changelog.d/` +- [ ] Changelog fragment `changelog.d/..md` (see docs/engineering/skills/github-prs.md) axiom: encoded-correct | encoded | queued | n/a: diff --git a/.github/bump_version.py b/.github/bump_version.py index 779a82e385..ee78a5730d 100644 --- a/.github/bump_version.py +++ b/.github/bump_version.py @@ -1,9 +1,129 @@ -"""Infer semver bump from towncrier fragment types and update version.""" +"""Infer semver bump from towncrier fragment types and update version. +Only the fragments towncrier consumes count: files in the fragment directory +whose names carry a fragment type configured under ``[tool.towncrier]`` in +pyproject.toml, as towncrier parses them. towncrier never writes any other +file there to CHANGELOG.md and never deletes it, so such a file cannot justify +a release. This script warns about each one and ignores it. +``.github/check_changelog_fragments.py`` uses the same rules to reject them in +pull requests. + +Standard library only. +""" + +import fnmatch import re import sys +import tomllib +from dataclasses import dataclass from pathlib import Path +# towncrier's fragment types when [tool.towncrier] configures none +# (towncrier._settings.fragment_types.DefaultFragmentTypesLoader). +TOWNCRIER_DEFAULT_TYPES = ("feature", "bugfix", "doc", "removal", "misc") +# Names towncrier always skips in the fragment directory +# (towncrier._builder.find_fragments), besides the configured `ignore`. +TOWNCRIER_IGNORED_NAMES = ( + ".gitignore", + ".gitkeep", + ".keep", + "readme", + "readme.md", + "readme.rst", +) + +MAJOR_TYPES = {"breaking"} +MINOR_TYPES = {"added", "removed"} + + +@dataclass(frozen=True) +class TowncrierConfig: + """The parts of [tool.towncrier] that decide which files are fragments.""" + + directory: str + types: tuple[str, ...] + ignore: tuple[str, ...] + + +def parse_towncrier_config(pyproject_text: str) -> TowncrierConfig: + """Read [tool.towncrier] from pyproject.toml text the way towncrier does.""" + config = tomllib.loads(pyproject_text).get("tool", {}).get("towncrier") + if config is None: + raise ValueError("pyproject.toml has no [tool.towncrier] table") + if config.get("fragment"): + types = tuple(sorted(config["fragment"])) + elif config.get("type"): + types = tuple( + entry.get("directory", entry["name"].lower()) for entry in config["type"] + ) + else: + types = TOWNCRIER_DEFAULT_TYPES + directory = config.get("directory") + if directory is None: + directory = str( + Path(config.get("package_dir", ".")) / config["package"] / "newsfragments" + ) + ignore = [name.lower() for name in TOWNCRIER_IGNORED_NAMES] + ignore += [pattern.lower() for pattern in config.get("ignore") or []] + template = config.get("template") + if isinstance(template, str) and not template.startswith("towncrier:"): + # towncrier does not lower-case this one. + ignore.append(Path(template).name) + return TowncrierConfig( + directory=Path(directory).as_posix(), types=types, ignore=tuple(ignore) + ) + + +def load_towncrier_config(pyproject_path: Path) -> TowncrierConfig: + return parse_towncrier_config(pyproject_path.read_text()) + + +def is_ignored(name: str, config: TowncrierConfig) -> bool: + """Whether towncrier skips this name in the fragment directory.""" + return any(fnmatch.fnmatch(name.lower(), pattern) for pattern in config.ignore) + + +def towncrier_fragment_type(name: str, types) -> str | None: + """The type towncrier reads from a fragment's name, or None if it has none. + + Mirrors towncrier._builder.parse_newfragment_basename: the last + dot-separated part after the first that is a configured type. + """ + parts = name.split(".") + for part in reversed(parts[1:]): + if part in types: + return part + return None + + +def classify_fragments( + changelog_dir: Path, config: TowncrierConfig +) -> tuple[list[tuple[Path, str]], list[Path]]: + """Split the directory's files into (fragment, type) pairs and untyped files. + + Files towncrier ignores (.gitkeep, README and the configured `ignore`) + are in neither list. + """ + typed, untyped = [], [] + for path in sorted(changelog_dir.iterdir()): + if not path.is_file() or is_ignored(path.name, config): + continue + fragment_type = towncrier_fragment_type(path.name, config.types) + if fragment_type is None: + untyped.append(path) + else: + typed.append((path, fragment_type)) + return typed, untyped + + +def bump_for_types(fragment_types) -> str: + fragment_types = set(fragment_types) + if fragment_types & MAJOR_TYPES: + return "major" + if fragment_types & MINOR_TYPES: + return "minor" + return "patch" + def get_current_version(pyproject_path: Path) -> str: text = pyproject_path.read_text() @@ -17,25 +137,23 @@ def get_current_version(pyproject_path: Path) -> str: return match.group(1) -def infer_bump(changelog_dir: Path) -> str: - fragments = [ - f for f in changelog_dir.iterdir() if f.is_file() and f.name != ".gitkeep" - ] - if not fragments: - print("No changelog fragments found", file=sys.stderr) +def infer_bump(changelog_dir: Path, config: TowncrierConfig) -> str: + typed, untyped = classify_fragments(changelog_dir, config) + for path in untyped: + relative = f"{config.directory}/{path.name}" + print( + f"::warning file={relative}::{relative} has no towncrier type, so " + "towncrier leaves it out of CHANGELOG.md and this release ignores it. " + f"Fragments are named ..md with a type from " + f"{', '.join(config.types)}." + ) + if not typed: + print( + f"No changelog fragments with a towncrier type in {config.directory}", + 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) >= 2: - categories.add(parts[-1]) - - if "breaking" in categories: - return "major" - if "added" in categories or "removed" in categories: - return "minor" - return "patch" + return bump_for_types(fragment_type for _, fragment_type in typed) def bump_version(version: str, bump: str) -> str: @@ -62,10 +180,10 @@ def update_file(path: Path, old_version: str, new_version: str): def main(): root = Path(__file__).resolve().parent.parent pyproject = root / "pyproject.toml" - changelog_dir = root / "changelog.d" + config = load_towncrier_config(pyproject) current = get_current_version(pyproject) - bump = infer_bump(changelog_dir) + bump = infer_bump(root / config.directory, config) new = bump_version(current, bump) print(f"Version: {current} -> {new} ({bump})") diff --git a/.github/check_changelog_fragments.py b/.github/check_changelog_fragments.py new file mode 100644 index 0000000000..f4d8dff00e --- /dev/null +++ b/.github/check_changelog_fragments.py @@ -0,0 +1,231 @@ +"""Fail a pull request that adds a changelog fragment towncrier would drop. + +towncrier writes a fragment to CHANGELOG.md only when its name carries a +fragment type configured in pyproject.toml. It never reads, and never +deletes, any other file in the fragment directory, and it leaves an empty +fragment out. Neither tells the author. On 11 October 2026 main held 93 such +files, and every one of their notes was missing from CHANGELOG.md. + +The Changelog workflow (.github/workflows/pr_changelog.yaml) runs this on the +pull request's merge commit: + + python .github/check_changelog_fragments.py --base HEAD^1 --head HEAD + +Each file the pull request adds or changes in the fragment directory must: + +- sit directly in the directory; +- be named ``..md``, with a type configured in pyproject.toml at + ``--head``, and a slug of letters, digits, ``.``, ``_`` and ``-`` + (optionally after towncrier's orphan prefix ``+``); +- be UTF-8 text that is not blank. + +Files towncrier ignores (.gitkeep, README and the configured ``ignore``) are +not fragments and pass. Every name this accepts is one towncrier consumes, +with the same type. Deleting a fragment always passes. + +It warns, without failing, when the pull request adds no fragment, when a +fragment starts with a list marker (towncrier adds the bullet, so it renders +as ``- - ``), and when the directory already holds files towncrier drops. + +Exit status: 0 if every changed fragment passes, 1 if any fails, 2 on a usage +or git error. Standard library only. +""" + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from bump_version import ( # noqa: E402 + TowncrierConfig, + is_ignored, + parse_towncrier_config, + towncrier_fragment_type, +) + +SLUG = re.compile(r"\+?[A-Za-z0-9][A-Za-z0-9._-]*") +LIST_MARKER = re.compile(r"[-*+]\s") + + +@dataclass +class Report: + errors: list[tuple[str, str]] = field(default_factory=list) + warnings: list[tuple[str | None, str]] = field(default_factory=list) + + +def name_problem(name: str, types) -> str | None: + """Why a fragment's name is not ``..md``, or None if it is.""" + if not name.endswith(".md"): + return "does not end in .md" + slug, dot, fragment_type = name.removesuffix(".md").rpartition(".") + if not dot or fragment_type not in types: + return ( + "has no towncrier type, so towncrier never writes it to CHANGELOG.md " + "and never deletes it" + ) + if not SLUG.fullmatch(slug): + return ( + f"has the slug {slug!r}; use letters, digits, '.', '_' and '-' " + "(for example the issue number or branch name)" + ) + return None + + +def content_problems(data: bytes) -> tuple[str | None, str | None]: + """(error, warning) for a fragment's content; either may be None.""" + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + return "is not UTF-8 text, so towncrier cannot read it", None + if not text.strip(): + return "is empty, so towncrier leaves it out of CHANGELOG.md", None + first_line = next(line for line in text.splitlines() if line.strip()) + if LIST_MARKER.match(first_line.lstrip()): + return None, ( + "starts with a list marker; towncrier adds the bullet itself, so it " + "renders as '- - ...' in CHANGELOG.md" + ) + return None, None + + +def check_fragment( + path: str, data: bytes, config: TowncrierConfig +) -> tuple[str | None, str | None]: + """(error, warning) for a file the pull request adds or changes.""" + directory, _, name = path.rpartition("/") + if directory != config.directory: + return ( + f"is not directly in {config.directory}/, so towncrier never reads it", + None, + ) + if is_ignored(name, config): + return None, None + problem = name_problem(name, config.types) + if problem: + types = ", ".join(config.types) + return f"{problem}. Name it ..md with a type from {types}.", None + return content_problems(data) + + +def git(repo: Path, *args: str) -> bytes: + result = subprocess.run(["git", *args], cwd=repo, capture_output=True) + if result.returncode != 0: + raise RuntimeError( + f"git {' '.join(args)} failed: {result.stderr.decode().strip()}" + ) + return result.stdout + + +def changed_files(repo: Path, base: str, head: str, directory: str) -> list[str]: + """Paths under ``directory`` that ``head`` adds or changes relative to ``base``. + + Renames count as a deletion and an addition. + """ + out = git( + repo, + "diff", + "--name-status", + "--no-renames", + "-z", + base, + head, + "--", + f"{directory}/", + ).decode() + fields = out.split("\0") + paths = [] + for status, path in zip(fields[::2], fields[1::2]): + if status != "D": + paths.append(path) + return paths + + +def check(repo: Path, base: str, head: str) -> Report: + config = parse_towncrier_config( + git(repo, "show", f"{head}:pyproject.toml").decode() + ) + report = Report() + changed = changed_files(repo, base, head, config.directory) + added_fragment = False + for path in changed: + error, warning = check_fragment( + path, git(repo, "show", f"{head}:{path}"), config + ) + if error: + report.errors.append((path, error)) + elif warning: + report.warnings.append((path, warning)) + name = path.rpartition("/")[2] + if not error and not is_ignored(name, config): + added_fragment = True + tree = git(repo, "ls-tree", "--name-only", "-z", head, "--", f"{config.directory}/") + for path in filter(None, tree.decode().split("\0")): + name = path.rpartition("/")[2] + if path in changed or is_ignored(name, config): + continue + if towncrier_fragment_type(name, config.types) is None: + report.warnings.append( + ( + path, + "has no towncrier type, so towncrier never writes it to " + "CHANGELOG.md. This pull request does not change it; rename " + "or delete it separately.", + ) + ) + if not added_fragment: + report.warnings.append( + ( + None, + f"This pull request adds no changelog fragment to {config.directory}/. " + "Every pull request needs one (docs/engineering/skills/github-prs.md).", + ) + ) + return report + + +def escape_data(text: str) -> str: + return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def escape_property(text: str) -> str: + return escape_data(text).replace(":", "%3A").replace(",", "%2C") + + +def annotation(level: str, path: str | None, message: str) -> str: + """A GitHub Actions workflow command that annotates the file.""" + location = f" file={escape_property(path)}" if path else "" + text = f"{path} {message}" if path else message + return f"::{level}{location}::{escape_data(text)}" + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo", type=Path, default=Path(".")) + parser.add_argument("--base", default="HEAD^1", help="the pull request's base") + parser.add_argument("--head", default="HEAD", help="the pull request's merge") + args = parser.parse_args(argv) + try: + report = check(args.repo, args.base, args.head) + except (KeyError, RuntimeError, ValueError) as error: + print(f"::error::{escape_data(str(error))}") + return 2 + for path, message in report.warnings: + print(annotation("warning", path, message)) + for path, message in report.errors: + print(annotation("error", path, message)) + if report.errors: + print( + f"{len(report.errors)} changelog fragment(s) would be left out of " + "CHANGELOG.md. See docs/engineering/skills/github-prs.md.", + file=sys.stderr, + ) + return 1 + print("Changelog fragments OK.", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/pr_changelog.yaml b/.github/workflows/pr_changelog.yaml new file mode 100644 index 0000000000..8dddee6724 --- /dev/null +++ b/.github/workflows/pr_changelog.yaml @@ -0,0 +1,40 @@ +# Workflow that checks a pull request's changelog fragments. +# +# towncrier writes a fragment to CHANGELOG.md only when it is named +# ..md with a type configured in pyproject.toml, and never reads +# or deletes any other file in changelog.d. This fails a pull request that +# adds or changes such a file, or an empty fragment +# (.github/check_changelog_fragments.py). It has no path filter, so it +# reports on every pull request and can be a required check. + +name: Changelog +on: + pull_request: + branches: + - main + +# A new push to a pull request supersedes the run on its previous head. +concurrency: + group: pr-changelog-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + Fragments: + name: Changelog fragments + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + # The merge commit and its parents: the base branch and the PR head. + fetch-depth: 2 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Check changelog fragments + run: python .github/check_changelog_fragments.py --base HEAD^1 --head HEAD diff --git a/changelog.d/2314.fixed.md b/changelog.d/2314.fixed.md new file mode 100644 index 0000000000..b87acc070e --- /dev/null +++ b/changelog.d/2314.fixed.md @@ -0,0 +1 @@ +Pull requests now fail a Changelog fragments check when they add a `changelog.d` file without a towncrier type, or an empty fragment; towncrier silently left these out of CHANGELOG.md. `.github/bump_version.py` now counts only typed fragments and warns about the rest. diff --git a/docs/engineering/skills/github-prs.md b/docs/engineering/skills/github-prs.md index a1ac512107..234af85a60 100644 --- a/docs/engineering/skills/github-prs.md +++ b/docs/engineering/skills/github-prs.md @@ -40,11 +40,21 @@ changelog.d/1748.added.md changelog.d/1748.fixed.md ``` -Write the fragment as a short bullet describing the user-visible or maintainer -visible change: +towncrier writes only these names to `CHANGELOG.md`. It never reads, and never +deletes, a file without a configured type, such as `changelog.d/1748.md`, and +it leaves an empty fragment out. `.github/bump_version.py` counts only the +fragments towncrier consumes. The **Changelog fragments** check +(`.github/workflows/pr_changelog.yaml`, which runs +`.github/check_changelog_fragments.py`) fails a PR that adds or changes such a +file, or one outside `changelog.d/` itself. It warns when a PR adds no fragment +or a fragment starts with a list marker. + +Write the fragment as a short sentence describing the user-visible or +maintainer-visible change. towncrier adds the bullet itself, so do not start +with `- `; that renders as `- - ` in `CHANGELOG.md`: ```markdown -- Added model-agnostic AI engineering guidance for UK model work. +Added model-agnostic AI engineering guidance for UK model work. ``` The versioning workflow runs on pushes to `main` that touch `changelog.d/**` or From 93823764a0e4d23f4fe8f20313f45b9cd1f02b03 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:50:26 -0400 Subject: [PATCH 2/7] Delete 93 untyped changelog fragments whose changes were released Each was added by a merged PR whose release is on PyPI (2.86.1 to 2.99.1), but towncrier never consumed it, so none of their notes reached CHANGELOG.md and every versioning run since counted them. Converting them to typed fragments now would file old notes under the next version. The PR lists each file with its PR, merge date, release and note. Refs #2314 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1005.md | 1 - changelog.d/1008.md | 1 - changelog.d/1015.md | 1 - changelog.d/1031.md | 1 - changelog.d/1032.md | 1 - changelog.d/1042.md | 1 - changelog.d/1043.md | 1 - changelog.d/1044-explicit-leave.md | 1 - changelog.d/1044.md | 1 - changelog.d/1046.md | 1 - changelog.d/1047.md | 1 - changelog.d/1048.md | 1 - changelog.d/1055.md | 1 - changelog.d/1056.md | 1 - changelog.d/1065.md | 1 - changelog.d/1171.md | 1 - changelog.d/1379.md | 1 - changelog.d/1400.md | 1 - changelog.d/1437-revert.md | 1 - changelog.d/1458-pawhp-refs.md | 1 - changelog.d/1616.md | 1 - changelog.d/1632.md | 1 - changelog.d/1644-childcare-registry.md | 1 - changelog.d/1654-revert.md | 1 - changelog.d/1666.md | 1 - changelog.d/1670.md | 1 - changelog.d/1671.md | 1 - changelog.d/1681.md | 1 - changelog.d/1688.md | 3 --- changelog.d/1755.md | 1 - changelog.d/1765.md | 1 - changelog.d/1780.md | 1 - changelog.d/1785.md | 1 - changelog.d/1799.md | 1 - changelog.d/1826.md | 1 - changelog.d/201.md | 1 - changelog.d/257-winter-fuel.md | 1 - changelog.d/257.md | 1 - changelog.d/289.md | 1 - changelog.d/307.md | 1 - changelog.d/326.md | 1 - changelog.d/340.md | 1 - changelog.d/368.md | 1 - changelog.d/373.md | 1 - changelog.d/395.md | 1 - changelog.d/396.md | 1 - changelog.d/414.md | 1 - changelog.d/420.md | 1 - changelog.d/45.md | 1 - changelog.d/451.md | 1 - changelog.d/580.md | 1 - changelog.d/587-format.md | 1 - changelog.d/587.md | 1 - changelog.d/609.md | 1 - changelog.d/623-takeup-start-date.md | 1 - changelog.d/623.md | 1 - changelog.d/634.md | 1 - changelog.d/649.md | 1 - changelog.d/651.md | 1 - changelog.d/665.md | 1 - changelog.d/676.md | 1 - changelog.d/721.md | 1 - changelog.d/737.md | 1 - changelog.d/745.md | 1 - changelog.d/746.md | 1 - changelog.d/747.md | 1 - changelog.d/771-cost-of-living-docs.md | 1 - changelog.d/771-disability-legacy-docs.md | 1 - changelog.d/771-housing-benefit.md | 1 - changelog.d/771-tax-credits-docs.md | 1 - changelog.d/778.md | 1 - changelog.d/853.md | 1 - changelog.d/866.md | 1 - changelog.d/920.md | 1 - changelog.d/930.md | 1 - changelog.d/950.md | 1 - changelog.d/953.md | 1 - changelog.d/capital-gains-realisation-response.md | 4 ---- changelog.d/cgt-rate-effective-dates.md | 3 --- changelog.d/class4-maximum-steps.md | 1 - changelog.d/council-tax-reduction-kingston-upon-thames.md | 1 - changelog.d/council-tax-reduction-merton.md | 1 - changelog.d/council-tax-reduction-national.md | 1 - changelog.d/council-tax-reduction-newham.md | 1 - changelog.d/council-tax-reduction-oxford.md | 1 - changelog.d/council-tax-reduction-westminster.md | 1 - changelog.d/fuel-duty-litre-rate.md | 1 - changelog.d/fuel-volume-uprating.md | 1 - changelog.d/legal-child-qyp-rules.md | 4 ---- changelog.d/local-dataset-filepaths.md | 1 - changelog.d/tv-licence-fee-2026.md | 1 - changelog.d/uc-childcare-work-condition.md | 1 - changelog.d/uprate-electricity-and-gas-consumption.md | 1 - 93 files changed, 103 deletions(-) delete mode 100644 changelog.d/1005.md delete mode 100644 changelog.d/1008.md delete mode 100644 changelog.d/1015.md delete mode 100644 changelog.d/1031.md delete mode 100644 changelog.d/1032.md delete mode 100644 changelog.d/1042.md delete mode 100644 changelog.d/1043.md delete mode 100644 changelog.d/1044-explicit-leave.md delete mode 100644 changelog.d/1044.md delete mode 100644 changelog.d/1046.md delete mode 100644 changelog.d/1047.md delete mode 100644 changelog.d/1048.md delete mode 100644 changelog.d/1055.md delete mode 100644 changelog.d/1056.md delete mode 100644 changelog.d/1065.md delete mode 100644 changelog.d/1171.md delete mode 100644 changelog.d/1379.md delete mode 100644 changelog.d/1400.md delete mode 100644 changelog.d/1437-revert.md delete mode 100644 changelog.d/1458-pawhp-refs.md delete mode 100644 changelog.d/1616.md delete mode 100644 changelog.d/1632.md delete mode 100644 changelog.d/1644-childcare-registry.md delete mode 100644 changelog.d/1654-revert.md delete mode 100644 changelog.d/1666.md delete mode 100644 changelog.d/1670.md delete mode 100644 changelog.d/1671.md delete mode 100644 changelog.d/1681.md delete mode 100644 changelog.d/1688.md delete mode 100644 changelog.d/1755.md delete mode 100644 changelog.d/1765.md delete mode 100644 changelog.d/1780.md delete mode 100644 changelog.d/1785.md delete mode 100644 changelog.d/1799.md delete mode 100644 changelog.d/1826.md delete mode 100644 changelog.d/201.md delete mode 100644 changelog.d/257-winter-fuel.md delete mode 100644 changelog.d/257.md delete mode 100644 changelog.d/289.md delete mode 100644 changelog.d/307.md delete mode 100644 changelog.d/326.md delete mode 100644 changelog.d/340.md delete mode 100644 changelog.d/368.md delete mode 100644 changelog.d/373.md delete mode 100644 changelog.d/395.md delete mode 100644 changelog.d/396.md delete mode 100644 changelog.d/414.md delete mode 100644 changelog.d/420.md delete mode 100644 changelog.d/45.md delete mode 100644 changelog.d/451.md delete mode 100644 changelog.d/580.md delete mode 100644 changelog.d/587-format.md delete mode 100644 changelog.d/587.md delete mode 100644 changelog.d/609.md delete mode 100644 changelog.d/623-takeup-start-date.md delete mode 100644 changelog.d/623.md delete mode 100644 changelog.d/634.md delete mode 100644 changelog.d/649.md delete mode 100644 changelog.d/651.md delete mode 100644 changelog.d/665.md delete mode 100644 changelog.d/676.md delete mode 100644 changelog.d/721.md delete mode 100644 changelog.d/737.md delete mode 100644 changelog.d/745.md delete mode 100644 changelog.d/746.md delete mode 100644 changelog.d/747.md delete mode 100644 changelog.d/771-cost-of-living-docs.md delete mode 100644 changelog.d/771-disability-legacy-docs.md delete mode 100644 changelog.d/771-housing-benefit.md delete mode 100644 changelog.d/771-tax-credits-docs.md delete mode 100644 changelog.d/778.md delete mode 100644 changelog.d/853.md delete mode 100644 changelog.d/866.md delete mode 100644 changelog.d/920.md delete mode 100644 changelog.d/930.md delete mode 100644 changelog.d/950.md delete mode 100644 changelog.d/953.md delete mode 100644 changelog.d/capital-gains-realisation-response.md delete mode 100644 changelog.d/cgt-rate-effective-dates.md delete mode 100644 changelog.d/class4-maximum-steps.md delete mode 100644 changelog.d/council-tax-reduction-kingston-upon-thames.md delete mode 100644 changelog.d/council-tax-reduction-merton.md delete mode 100644 changelog.d/council-tax-reduction-national.md delete mode 100644 changelog.d/council-tax-reduction-newham.md delete mode 100644 changelog.d/council-tax-reduction-oxford.md delete mode 100644 changelog.d/council-tax-reduction-westminster.md delete mode 100644 changelog.d/fuel-duty-litre-rate.md delete mode 100644 changelog.d/fuel-volume-uprating.md delete mode 100644 changelog.d/legal-child-qyp-rules.md delete mode 100644 changelog.d/local-dataset-filepaths.md delete mode 100644 changelog.d/tv-licence-fee-2026.md delete mode 100644 changelog.d/uc-childcare-work-condition.md delete mode 100644 changelog.d/uprate-electricity-and-gas-consumption.md diff --git a/changelog.d/1005.md b/changelog.d/1005.md deleted file mode 100644 index 115f64996e..0000000000 --- a/changelog.d/1005.md +++ /dev/null @@ -1 +0,0 @@ -- Add `is_about_to_start_paid_work` and `is_newly_self_employed` as Person-level boolean input variables so front-ends can prompt users about childcare-program eligibility scenarios that existing variables do not surface generically. diff --git a/changelog.d/1008.md b/changelog.d/1008.md deleted file mode 100644 index d603ced248..0000000000 --- a/changelog.d/1008.md +++ /dev/null @@ -1 +0,0 @@ -- Add `scottish_funded_early_learning_childcare_eligible` and the supporting parameters under `gov/scottish_government/funded_early_learning_childcare/` (age min/max, annual hours) to model the universal age-based strand of Scotland's Funded Early Learning and Childcare — 1,140 hours per year for all 3- and 4-year-olds resident in Scotland, rolled out in full from August 2021. Wales's Childcare Offer, Northern Ireland's Pre-school Education Programme, and the eligible-two-year-old strand in Scotland remain to be added in follow-up work. diff --git a/changelog.d/1015.md b/changelog.d/1015.md deleted file mode 100644 index b1f3278b1d..0000000000 --- a/changelog.d/1015.md +++ /dev/null @@ -1 +0,0 @@ -- Restructure minimum wage parameter into apprentice and non-apprentice (age-based scale) files diff --git a/changelog.d/1031.md b/changelog.d/1031.md deleted file mode 100644 index 8f4c887c7c..0000000000 --- a/changelog.d/1031.md +++ /dev/null @@ -1 +0,0 @@ -Removed the inferred `is_parent` formula so datasets provide parent status directly. diff --git a/changelog.d/1032.md b/changelog.d/1032.md deleted file mode 100644 index 78dcafdc48..0000000000 --- a/changelog.d/1032.md +++ /dev/null @@ -1 +0,0 @@ -- Added Universal Credit carer element to extended childcare entitlement work-requirement exemptions. diff --git a/changelog.d/1042.md b/changelog.d/1042.md deleted file mode 100644 index d49c0604b7..0000000000 --- a/changelog.d/1042.md +++ /dev/null @@ -1 +0,0 @@ -Clarified that Tax-Free Childcare age parameters are exclusive upper thresholds. diff --git a/changelog.d/1043.md b/changelog.d/1043.md deleted file mode 100644 index 20d743d495..0000000000 --- a/changelog.d/1043.md +++ /dev/null @@ -1 +0,0 @@ -- Improved Tax-Free Childcare expected income and self-employment start-up period handling. diff --git a/changelog.d/1044-explicit-leave.md b/changelog.d/1044-explicit-leave.md deleted file mode 100644 index f96c1f20c3..0000000000 --- a/changelog.d/1044-explicit-leave.md +++ /dev/null @@ -1 +0,0 @@ -- Added explicit Tax-Free Childcare adoption and shared parental leave work-condition inputs. diff --git a/changelog.d/1044.md b/changelog.d/1044.md deleted file mode 100644 index c590192cc6..0000000000 --- a/changelog.d/1044.md +++ /dev/null @@ -1 +0,0 @@ -- Added Tax-Free Childcare temporary absence from work handling. diff --git a/changelog.d/1046.md b/changelog.d/1046.md deleted file mode 100644 index b13db94164..0000000000 --- a/changelog.d/1046.md +++ /dev/null @@ -1 +0,0 @@ -Added the Tax-Free Childcare UK connection eligibility condition. diff --git a/changelog.d/1047.md b/changelog.d/1047.md deleted file mode 100644 index 185711773c..0000000000 --- a/changelog.d/1047.md +++ /dev/null @@ -1 +0,0 @@ -- Added Tax-Free Childcare eligible declaration period handling. diff --git a/changelog.d/1048.md b/changelog.d/1048.md deleted file mode 100644 index 5de7a12619..0000000000 --- a/changelog.d/1048.md +++ /dev/null @@ -1 +0,0 @@ -- Added Tax-Free Childcare looked-after-child eligibility handling. diff --git a/changelog.d/1055.md b/changelog.d/1055.md deleted file mode 100644 index 41dbe2436c..0000000000 --- a/changelog.d/1055.md +++ /dev/null @@ -1 +0,0 @@ -- Refactored universal childcare entitlement age parameters into an `age` folder. diff --git a/changelog.d/1056.md b/changelog.d/1056.md deleted file mode 100644 index 83b13eb724..0000000000 --- a/changelog.d/1056.md +++ /dev/null @@ -1 +0,0 @@ -- Updated Tax-Free Childcare Universal Credit disqualification to include awards reduced to nil. diff --git a/changelog.d/1065.md b/changelog.d/1065.md deleted file mode 100644 index 7d20c7ce8a..0000000000 --- a/changelog.d/1065.md +++ /dev/null @@ -1 +0,0 @@ -- Updated targeted childcare entitlement tax credit criteria to match historical and current regulations. diff --git a/changelog.d/1171.md b/changelog.d/1171.md deleted file mode 100644 index 399dac91d3..0000000000 --- a/changelog.d/1171.md +++ /dev/null @@ -1 +0,0 @@ -- Add a docs page comparing PolicyEngine UK's nowcasting approach to the Resolution Foundation's Living Standards Outlook methodology — wage-floor handling, benefit uprating, take-up, calibration targets, and horizon — under `docs/book/assumptions/nowcasting-comparison.md`. diff --git a/changelog.d/1379.md b/changelog.d/1379.md deleted file mode 100644 index b587f2aef5..0000000000 --- a/changelog.d/1379.md +++ /dev/null @@ -1 +0,0 @@ -- Document the methodology and sources for the 2031+ long-run values in `yoy_growth.yaml` (CPI pinned to the BoE 2% target; RPI/CPIH ramp to 2.39% via the OBR long-run wedge; average earnings ramp to 3.83%) and add a `parameters/gov/economic_assumptions/README.md` listing each series, its source, and a refresh procedure for new EFO releases. diff --git a/changelog.d/1400.md b/changelog.d/1400.md deleted file mode 100644 index d8991f014e..0000000000 --- a/changelog.d/1400.md +++ /dev/null @@ -1 +0,0 @@ -- Add a standalone documentation page summarising the Resolution Foundation's nowcasting methodology (LSO 2025 appendix: wage-floor and spillover modelling, NLW age extensions, PIPR-based rent uprating, UC-statistics caseload anchoring, four- to five-year horizon) and pointing back at PolicyEngine's own assumptions for comparison. diff --git a/changelog.d/1437-revert.md b/changelog.d/1437-revert.md deleted file mode 100644 index 4cc1b166fb..0000000000 --- a/changelog.d/1437-revert.md +++ /dev/null @@ -1 +0,0 @@ -- Revert the fiscal-year uprating planning docs page (#1716) per maintainer request. diff --git a/changelog.d/1458-pawhp-refs.md b/changelog.d/1458-pawhp-refs.md deleted file mode 100644 index 7e527ff7ed..0000000000 --- a/changelog.d/1458-pawhp-refs.md +++ /dev/null @@ -1 +0,0 @@ -- Add legislative references and tidy labels for the Pension Age Winter Heating Payment (Scotland) parameter suite (amounts and eligibility), citing The Pension Age Winter Heating Payment (Scotland) Regulations 2024 (SSI 2024/250) and the June 2025 Scottish Government announcement reinstating a universal element. diff --git a/changelog.d/1616.md b/changelog.d/1616.md deleted file mode 100644 index 91633b6a84..0000000000 --- a/changelog.d/1616.md +++ /dev/null @@ -1 +0,0 @@ -- Add Ofgem Q2 2026 (April–June 2026) energy price cap of £1,641. diff --git a/changelog.d/1632.md b/changelog.d/1632.md deleted file mode 100644 index 6f7ed71759..0000000000 --- a/changelog.d/1632.md +++ /dev/null @@ -1 +0,0 @@ -- Add a State Pension documentation page at `docs/book/programs/gov/dwp/state-pension.md` describing the three composable variables (`basic_state_pension`, `new_state_pension`, `additional_state_pension`), the triple-lock uprating mechanics, and the residual -£12 bn vs OBR aggregate gap (#1632) now tracked as a data-side ASP-under-imputation issue in `policyengine-uk-data` after the formula-side fixes in PR #1634. diff --git a/changelog.d/1644-childcare-registry.md b/changelog.d/1644-childcare-registry.md deleted file mode 100644 index 7a3d7cb31c..0000000000 --- a/changelog.d/1644-childcare-registry.md +++ /dev/null @@ -1 +0,0 @@ -- Split the `childcare_entitlements` umbrella entry in `programs.yaml` into three rows (universal/extended/targeted) each bound to its variable and parameter prefix, removing an orphan registry row flagged in the childcare-coverage audit. diff --git a/changelog.d/1654-revert.md b/changelog.d/1654-revert.md deleted file mode 100644 index e13d8764ac..0000000000 --- a/changelog.d/1654-revert.md +++ /dev/null @@ -1 +0,0 @@ -- Revert the Benefits-in-Kind planning docs page (#1707) per maintainer request. diff --git a/changelog.d/1666.md b/changelog.d/1666.md deleted file mode 100644 index bd13ded8c9..0000000000 --- a/changelog.d/1666.md +++ /dev/null @@ -1 +0,0 @@ -- Prevent children in a married benefit unit from being assigned (negative) Marriage Allowance and being taxed on phantom income. diff --git a/changelog.d/1670.md b/changelog.d/1670.md deleted file mode 100644 index 42629adae5..0000000000 --- a/changelog.d/1670.md +++ /dev/null @@ -1 +0,0 @@ -- Exclude `high_value_council_tax_surcharge` from `household_tax` and `gov_tax` when `gov.contrib.abolish_council_tax` is in force, since the surcharge is defined on top of council tax bands. diff --git a/changelog.d/1671.md b/changelog.d/1671.md deleted file mode 100644 index 048a9d68c3..0000000000 --- a/changelog.d/1671.md +++ /dev/null @@ -1 +0,0 @@ -- Document `council_tax_less_benefit` as a public-facing convenience variable used by downstream consumers (`policyengine-app`, `council-tax-ctr-map`, `policyengine-uk-data` calibration targets) rather than the core model itself, so future audits do not flag it as dead code. diff --git a/changelog.d/1681.md b/changelog.d/1681.md deleted file mode 100644 index 9cb01cec72..0000000000 --- a/changelog.d/1681.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed PolicyBench audit issues in Pension Credit, Capital Gains Tax, and income-tax source ordering, including mixed-age Pension Credit eligibility, property income in Pension Credit, CGT rate-band allocation, savings/dividend ordering, HICBC regression coverage, and property income-tax banding. diff --git a/changelog.d/1688.md b/changelog.d/1688.md deleted file mode 100644 index a9e9fa49b4..0000000000 --- a/changelog.d/1688.md +++ /dev/null @@ -1,3 +0,0 @@ -- Updated published 2026-27 benefit and tax parameters, including Child Benefit, Pension Credit, Universal Credit, disability benefits, legacy benefit allowances, minimum wage, National Insurance, and student loan thresholds. -- Applied the higher Pension Credit first-child addition for children born before April 2017. -- Updated 2026-27 dividend tax thresholds and the baseline Universal Credit LCWRA rate. diff --git a/changelog.d/1755.md b/changelog.d/1755.md deleted file mode 100644 index 0d84e7b413..0000000000 --- a/changelog.d/1755.md +++ /dev/null @@ -1 +0,0 @@ -- Floor the Universal Credit housing costs element at zero after non-dependent deductions. diff --git a/changelog.d/1765.md b/changelog.d/1765.md deleted file mode 100644 index 542bd15374..0000000000 --- a/changelog.d/1765.md +++ /dev/null @@ -1 +0,0 @@ -- Correct the Class 4 National Insurance annual maximum calculation to use the current main Class 4 rate factor. diff --git a/changelog.d/1780.md b/changelog.d/1780.md deleted file mode 100644 index e56744d747..0000000000 --- a/changelog.d/1780.md +++ /dev/null @@ -1 +0,0 @@ -- Add `bus_fare_spending` Household input variable (COICOP 7.3.2 bus & coach fares), the companion to the LCFS bus fare imputation in policyengine-uk-data, providing the passenger fare households pay (distinct from the ETB-based `bus_subsidy_spending`) as a building block for modelling bus fare reforms. Resolves #1779. diff --git a/changelog.d/1785.md b/changelog.d/1785.md deleted file mode 100644 index 5bdb0d2964..0000000000 --- a/changelog.d/1785.md +++ /dev/null @@ -1 +0,0 @@ -- Add `employment_sector` (public/private) and `sic_industry_division` as Person-level input variables, enabling identification of public-sector workers and industry-level analysis once populated from the FRS. diff --git a/changelog.d/1799.md b/changelog.d/1799.md deleted file mode 100644 index e088dba0ce..0000000000 --- a/changelog.d/1799.md +++ /dev/null @@ -1 +0,0 @@ -- Add `person_bus_fare_spending`, allocating household bus and coach fare spending across members using a new `gov.dft.bus.fare_allocation_weight_by_age` parameter derived from National Travel Survey local bus use and adjusted for concessionary travel. diff --git a/changelog.d/1826.md b/changelog.d/1826.md deleted file mode 100644 index 6d31e204b4..0000000000 --- a/changelog.d/1826.md +++ /dev/null @@ -1 +0,0 @@ -- Fix a units bug in `dynamics/participation.py`: `impute_wages_for_nonworkers` divided earnings by `hours_worked * 52`, but `hours_worked` is annual, so the implied hourly wage was about £0.43 rather than £22 and a non-worker was imputed roughly £194 of annual earnings for entering part-time work instead of about £21,600. The hourly wage calculation is extracted into a new `hourly_wage` helper. `apply_participation_responses` also wrote the weekly `hours_for_new_entrants` straight into the annual `hours_worked` variable; it is now annualised. diff --git a/changelog.d/201.md b/changelog.d/201.md deleted file mode 100644 index 2bf40d1baf..0000000000 --- a/changelog.d/201.md +++ /dev/null @@ -1 +0,0 @@ -- Add regression tests pinning the existing UC vs Housing Benefit mutual-exclusion contract in `housing_benefit_eligible` (`& ~claiming_uc`), so a future refactor can't silently allow simultaneous UC + HB awards. diff --git a/changelog.d/257-winter-fuel.md b/changelog.d/257-winter-fuel.md deleted file mode 100644 index 69937a24b2..0000000000 --- a/changelog.d/257-winter-fuel.md +++ /dev/null @@ -1 +0,0 @@ -- Use `np.isin` instead of OR-chained equality in `winter_fuel_allowance`. diff --git a/changelog.d/257.md b/changelog.d/257.md deleted file mode 100644 index b3f1e1a5d7..0000000000 --- a/changelog.d/257.md +++ /dev/null @@ -1 +0,0 @@ -- Fix `AttributeError` in `cps.marriage_tax_reforms` when reform was instantiated with `child_education_levels` — replace nonexistent `np.is_in` (typo) with `np.isin`. diff --git a/changelog.d/289.md b/changelog.d/289.md deleted file mode 100644 index dda507095c..0000000000 --- a/changelog.d/289.md +++ /dev/null @@ -1 +0,0 @@ -- Consolidate UC work allowance parameters (`with_housing` / `without_housing`) into a single `work_allowance.yaml` file, with shared metadata propagated from the parent node. diff --git a/changelog.d/307.md b/changelog.d/307.md deleted file mode 100644 index b76aa65641..0000000000 --- a/changelog.d/307.md +++ /dev/null @@ -1 +0,0 @@ -- Add YAML tests for `household_equivalisation_bhc` and `household_equivalisation_ahc` covering the HBAI modified-OECD scale examples (single adult through couple-with-children), and refactor both formulas to read the scale weights from the existing `household.demographic.equiv.{bhc,ahc}` parameters instead of hard-coded constants. diff --git a/changelog.d/326.md b/changelog.d/326.md deleted file mode 100644 index a2822a6a81..0000000000 --- a/changelog.d/326.md +++ /dev/null @@ -1 +0,0 @@ -- Cite SI 2016/909 reg. 2 on the benefit cap parameter, which is where the 2016-11-07 values originate. diff --git a/changelog.d/340.md b/changelog.d/340.md deleted file mode 100644 index 625459d6a9..0000000000 --- a/changelog.d/340.md +++ /dev/null @@ -1 +0,0 @@ -- Add a structural test that every parameter description ends with a period, and bring the existing 178 parameter files into compliance. diff --git a/changelog.d/368.md b/changelog.d/368.md deleted file mode 100644 index a8b986d14b..0000000000 --- a/changelog.d/368.md +++ /dev/null @@ -1 +0,0 @@ -- Clarify the `current_education` label as "Current education enrolment" and add a docstring distinguishing it from `highest_education` (which captures attainment), so the variable's enrolment-vs-attainment semantics are explicit without an API-breaking rename. diff --git a/changelog.d/373.md b/changelog.d/373.md deleted file mode 100644 index b250a3d33f..0000000000 --- a/changelog.d/373.md +++ /dev/null @@ -1 +0,0 @@ -- Document the actual policy start years for Attendance Allowance (1971), Carer's Allowance (1976, as Invalid Care Allowance), Constant Attendance Allowance (1948) and Industrial Injuries Disablement Benefit (1948) in their `parameters/gov/dwp/*/README.md` files, with statutory references and an explicit note that the parameter tree only tracks rates from 2015-04-01 onwards. diff --git a/changelog.d/395.md b/changelog.d/395.md deleted file mode 100644 index 86e180f204..0000000000 --- a/changelog.d/395.md +++ /dev/null @@ -1 +0,0 @@ -- Parametrise the Marriage Allowance transferor-eligible tax bands as `gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands` and include `NONE` (below-personal-allowance earners) in the default list, matching ITA 2007 s.55B — a person with no taxable income is still eligible to transfer their unused allowance and was previously rejected by the hard-coded check. The CPS expanded-Marriage-Allowance reform now also consumes the parameter, so both code paths agree on the baseline eligible-band set. diff --git a/changelog.d/396.md b/changelog.d/396.md deleted file mode 100644 index 095625d152..0000000000 --- a/changelog.d/396.md +++ /dev/null @@ -1 +0,0 @@ -- Fill in 2020 and 2021 carbon intensity values for all 12 consumption categories by linear interpolation between the existing DEFRA-sourced 2019 and 2022 anchors, replacing the prior `backdate_parameters` artifact that held values flat at 2019 across the gap. diff --git a/changelog.d/414.md b/changelog.d/414.md deleted file mode 100644 index db30ef3337..0000000000 --- a/changelog.d/414.md +++ /dev/null @@ -1 +0,0 @@ -- Remove `trading_allowance`, `property_allowance`, and `dividend_allowance` variables that were pure pass-throughs around their same-named parameters. Callers now read the parameter directly, and the `taxable_self_employment_income` deductions list references `trading_allowance_deduction` (the capped amount actually deductible against self-employment income) rather than the raw allowance. diff --git a/changelog.d/420.md b/changelog.d/420.md deleted file mode 100644 index cccc917b0f..0000000000 --- a/changelog.d/420.md +++ /dev/null @@ -1 +0,0 @@ -- Pay the two-carer Carer Premium rate when a benefit unit has three or more carers. The previous `carers == 2` condition silently returned £0 for the (rare) case of three or more carers in the same benefit unit; the Carer Premium regulations don't define a higher rate, so the couple rate is the correct ceiling. diff --git a/changelog.d/45.md b/changelog.d/45.md deleted file mode 100644 index 1062f981e2..0000000000 --- a/changelog.d/45.md +++ /dev/null @@ -1 +0,0 @@ -- Add `marginal_tax_rate_wrt_employer_cost`, a person-level variable that reports the share of a marginal increase in the employer's cost of employment (gross pay plus employer NI, employer pension contributions and statutory sick/maternity/paternity pay) that does not flow to household net income. Computed via the same simulation-branching pattern as `marginal_tax_rate`. Useful for analysing reforms that shift incidence between worker-side and employer-side charges. diff --git a/changelog.d/451.md b/changelog.d/451.md deleted file mode 100644 index 2b5367f866..0000000000 --- a/changelog.d/451.md +++ /dev/null @@ -1 +0,0 @@ -- Change `hours_worked` variable label to "Annual hours worked" for clarity. diff --git a/changelog.d/580.md b/changelog.d/580.md deleted file mode 100644 index 6270c9cd74..0000000000 --- a/changelog.d/580.md +++ /dev/null @@ -1 +0,0 @@ -- Replace the carbon intensity chart in the carbon tax doc with a horizontal bar ranking categories, with the axis relabelled to tonnes CO2 per £1,000 (the previous chart had a nonsensical "pence/tonne" tick suffix). diff --git a/changelog.d/587-format.md b/changelog.d/587-format.md deleted file mode 100644 index 7016708d93..0000000000 --- a/changelog.d/587-format.md +++ /dev/null @@ -1 +0,0 @@ -- Apply ruff formatting to the validation notebook (blank line after the warnings import) so the repo-wide `ruff format --check` lint passes on main. diff --git a/changelog.d/587.md b/changelog.d/587.md deleted file mode 100644 index a6579c2d73..0000000000 --- a/changelog.d/587.md +++ /dev/null @@ -1 +0,0 @@ -- Suppress Python warnings at the top of the validation notebook so re-executions don't surface pandas/plotly deprecation noise in the docs build. diff --git a/changelog.d/609.md b/changelog.d/609.md deleted file mode 100644 index da1ab126d7..0000000000 --- a/changelog.d/609.md +++ /dev/null @@ -1 +0,0 @@ -- Add the 2023-24 Cost-of-Living Payments to the `cost_of_living_support_payment` parameters — £900 means-tested (paid in three instalments), £300 pensioner, £150 disability — announced at Autumn Statement 2022 and previously left at zero; values reset to 0 from 2024-01-01. diff --git a/changelog.d/623-takeup-start-date.md b/changelog.d/623-takeup-start-date.md deleted file mode 100644 index b0247225ee..0000000000 --- a/changelog.d/623-takeup-start-date.md +++ /dev/null @@ -1 +0,0 @@ -- Date the Marriage Allowance take-up rate parameter from 2015-04-06 (when the allowance came into force) rather than 2000-01-01, document that 0.5 is the post-2019 steady-state value, and link the HMRC Marriage Allowance statistics page. diff --git a/changelog.d/623.md b/changelog.d/623.md deleted file mode 100644 index cd6eb0e2ba..0000000000 --- a/changelog.d/623.md +++ /dev/null @@ -1 +0,0 @@ -- Set Marriage Allowance take-up rate parameter to 0.5 (HMRC outturn ~2.1m claimants of ~4.2m eligible couples) instead of the placeholder 1.0, with a House of Commons Library citation. The parallel rate in `policyengine-uk-data` is the load-bearing one for microsimulation outputs and must be updated separately. diff --git a/changelog.d/634.md b/changelog.d/634.md deleted file mode 100644 index 6e2e307fd2..0000000000 --- a/changelog.d/634.md +++ /dev/null @@ -1 +0,0 @@ -- Fix incorrect description on non-residential SDLT rate parameter (was labelled "residential") diff --git a/changelog.d/649.md b/changelog.d/649.md deleted file mode 100644 index 39f0ce1975..0000000000 --- a/changelog.d/649.md +++ /dev/null @@ -1 +0,0 @@ -- Add README and `index.yaml` metadata for the DCMS parameters and variables folders. diff --git a/changelog.d/651.md b/changelog.d/651.md deleted file mode 100644 index a9df05f4b3..0000000000 --- a/changelog.d/651.md +++ /dev/null @@ -1 +0,0 @@ -- Clarify variable descriptions for self-employment income and corporate wealth diff --git a/changelog.d/665.md b/changelog.d/665.md deleted file mode 100644 index 6389977306..0000000000 --- a/changelog.d/665.md +++ /dev/null @@ -1 +0,0 @@ -- Remove redundant `name` metadata tag from parameter YAML files diff --git a/changelog.d/676.md b/changelog.d/676.md deleted file mode 100644 index 70293922e0..0000000000 --- a/changelog.d/676.md +++ /dev/null @@ -1 +0,0 @@ -Add the Rural Fuel Duty Relief Scheme. diff --git a/changelog.d/721.md b/changelog.d/721.md deleted file mode 100644 index d5a1d15cf2..0000000000 --- a/changelog.d/721.md +++ /dev/null @@ -1 +0,0 @@ -- Document the relationship between PolicyEngine UK's annual-NI computation and HMRC's "annual cumulative" basis for company directors in a new docs page; the current model is functionally correct for annual-income inputs and no code changes are required short-term, with a future `is_company_director` input flag sketched for any household-calculator follow-up. diff --git a/changelog.d/737.md b/changelog.d/737.md deleted file mode 100644 index 775223a1e7..0000000000 --- a/changelog.d/737.md +++ /dev/null @@ -1 +0,0 @@ -- Add unit tests for the two CPS marriage tax reforms (`expanded_ma_reform` and `marriage_neutral_it_reform`) and expose module-level Reform instances for them so they can be referenced from YAML tests by dotted import path. Tests cover: Marriage Allowance denied to a single higher-rate earner under the expanded-MA reform, Marriage Allowance granted to a higher-rate earner with a low-earning spouse under the expanded-MA reform, and adjusted net income unchanged for a single person under the marriage-neutral IT reform. diff --git a/changelog.d/745.md b/changelog.d/745.md deleted file mode 100644 index 2802f9d8f5..0000000000 --- a/changelog.d/745.md +++ /dev/null @@ -1 +0,0 @@ -- Expand the Universal Credit documentation page: add a "How PolicyEngine computes Universal Credit" section walking through `uc_maximum_amount` -> means test -> benefit cap, add a references block (Welfare Reform Act 2012, SI 2013/376, DWP rate uprating, official statistics), tidy the elements rates dataframe to drop the pd.concat-in-loop pattern, fix the £/month axis label, and remove an empty trailing cell. diff --git a/changelog.d/746.md b/changelog.d/746.md deleted file mode 100644 index 1d14c41904..0000000000 --- a/changelog.d/746.md +++ /dev/null @@ -1 +0,0 @@ -- Expand the National Insurance documentation page with a per-class computation section, add Class 3 (voluntary) to the modelling table, link the underlying variables, and add a references section; also fix the boolean-chain warning in the rates chart. diff --git a/changelog.d/747.md b/changelog.d/747.md deleted file mode 100644 index 94ab29b117..0000000000 --- a/changelog.d/747.md +++ /dev/null @@ -1 +0,0 @@ -- Flesh out the fuel duty documentation page with the household-level computation formula, a section on Rural Fuel Duty Relief, and statutory references; fix the chart title typo (`giesel` -> `diesel`). diff --git a/changelog.d/771-cost-of-living-docs.md b/changelog.d/771-cost-of-living-docs.md deleted file mode 100644 index 54842c7f88..0000000000 --- a/changelog.d/771-cost-of-living-docs.md +++ /dev/null @@ -1 +0,0 @@ -- Add a documentation page covering the Cost-of-Living Payments (2022-23 and 2023-24) and the 2022 Energy Bills Rebate; ticks off two more entries in the #771 docs-pages todo list. diff --git a/changelog.d/771-disability-legacy-docs.md b/changelog.d/771-disability-legacy-docs.md deleted file mode 100644 index cec6278999..0000000000 --- a/changelog.d/771-disability-legacy-docs.md +++ /dev/null @@ -1 +0,0 @@ -- Add a documentation page covering Disability Living Allowance (mobility + self-care components, both DLA bands taken from dataset categoricals) and Severe Disablement Allowance (binary read from `sda_reported`, maximum age-related addition assumed); ticks off two more #771 docs-pages entries. diff --git a/changelog.d/771-housing-benefit.md b/changelog.d/771-housing-benefit.md deleted file mode 100644 index 4748ca0bcf..0000000000 --- a/changelog.d/771-housing-benefit.md +++ /dev/null @@ -1 +0,0 @@ -- Add a Housing Benefit documentation page covering eligibility (legacy-only working-age claimants, pension-age and supported-housing tenants), the four-stage entitlement computation, a personal-allowances chart, and statutory references; checks off one of the entries in the #771 docs-pages todo list. diff --git a/changelog.d/771-tax-credits-docs.md b/changelog.d/771-tax-credits-docs.md deleted file mode 100644 index 24985c4d2c..0000000000 --- a/changelog.d/771-tax-credits-docs.md +++ /dev/null @@ -1 +0,0 @@ -- Add a documentation page covering Working Tax Credit and Child Tax Credit — both elements/hours/two-child-limit mechanics and the shared means test — with an explicit note that the scheme ended on 5 April 2025 and PolicyEngine gates the entire computation behind `gov.dwp.tax_credits.active`; ticks off the WTC and CTC entries in the #771 docs-pages todo list. diff --git a/changelog.d/778.md b/changelog.d/778.md deleted file mode 100644 index 66418daf94..0000000000 --- a/changelog.d/778.md +++ /dev/null @@ -1 +0,0 @@ -- Add three worked benefit scenarios at `policyengine_uk/tests/policy/integration/entitledto_scenarios.yaml` as a first batch of EntitledTo-style regression tests (single low-earner private renter, couple with two children in social housing, pensioner couple on the Pension Credit guarantee), seeding the ~20-scenario coverage suggested in #778. diff --git a/changelog.d/853.md b/changelog.d/853.md deleted file mode 100644 index 7666bb54a5..0000000000 --- a/changelog.d/853.md +++ /dev/null @@ -1 +0,0 @@ -- Rename `labor` to `labour` in LSR parameter and variable paths, labels, and descriptions to use British English spelling. The two consumed paths (`gov.simulation.labor_supply_responses.income_elasticity` and `.substitution_elasticity`) remain available as deprecation aliases for one release so downstream consumers — particularly the legacy `policyengine-app` PolicyRightSidebar — keep working until they migrate. diff --git a/changelog.d/866.md b/changelog.d/866.md deleted file mode 100644 index 92256fac43..0000000000 --- a/changelog.d/866.md +++ /dev/null @@ -1 +0,0 @@ -Add a disability basic income reform option. diff --git a/changelog.d/920.md b/changelog.d/920.md deleted file mode 100644 index 56bd0c27ca..0000000000 --- a/changelog.d/920.md +++ /dev/null @@ -1 +0,0 @@ -- Drop dev dependencies that are already brought in transitively by `policyengine-core` as runtime deps (`pytest`, `wheel`), shrinking the install footprint. `coverage`, `pytest-cov`, `ruff` and `towncrier` are kept because they live in `policyengine-core`'s `dev` extras and pip does not install extras of dependencies transitively. diff --git a/changelog.d/930.md b/changelog.d/930.md deleted file mode 100644 index 8bdd0ba397..0000000000 --- a/changelog.d/930.md +++ /dev/null @@ -1 +0,0 @@ -- Add test suite verifying that key parametric reforms produce non-zero impacts diff --git a/changelog.d/950.md b/changelog.d/950.md deleted file mode 100644 index 2bc9f9a538..0000000000 --- a/changelog.d/950.md +++ /dev/null @@ -1 +0,0 @@ -- Add Scottish Carer Support Payment, replacing Carer's Allowance for Scottish residents diff --git a/changelog.d/953.md b/changelog.d/953.md deleted file mode 100644 index 53e0b1c81f..0000000000 --- a/changelog.d/953.md +++ /dev/null @@ -1 +0,0 @@ -- Align state pension triple lock with published outturn rates so basic and new state pension amounts match DWP / OBR figures for 2025-26 and 2026-27 instead of being ~1% off. diff --git a/changelog.d/capital-gains-realisation-response.md b/changelog.d/capital-gains-realisation-response.md deleted file mode 100644 index 4e05998c3f..0000000000 --- a/changelog.d/capital-gains-realisation-response.md +++ /dev/null @@ -1,4 +0,0 @@ -- Fixed the capital gains realisation response, which measured the baseline marginal rate on a branch of the reform simulation and so reported no rate change for any reform, leaving the elasticity with no effect on any costing. -- Defined the capital gains elasticity against the retention rate rather than the tax rate, matching how published estimates are reported, and added `relative_capital_gains_retention_rate_change`. -- Hardened the measurement against a pre-created branch of the same name, which shared the simulation's tax-benefit system and let the neutralised response variable replace the live one for every later recalculation. -- Raised the policyengine-core floor to 3.30.1, whose nested-simulation cache fix lets a second household gainer's response compute; a symmetry regression test pins it. diff --git a/changelog.d/cgt-rate-effective-dates.md b/changelog.d/cgt-rate-effective-dates.md deleted file mode 100644 index e3c8eb1f6e..0000000000 --- a/changelog.d/cgt-rate-effective-dates.md +++ /dev/null @@ -1,3 +0,0 @@ -- Corrected the effective date of the capital gains tax rate rise to 30 October 2024, as Finance Act 2025 s.7 provides, from 6 April 2025. -- Added opt-in day-weighted annualisation for parameters that change part-way through a fiscal year, and applied it to the capital gains rates, so 2024-25 reflects both rate schedules rather than only the one in force at the start of the year. -- Annual Scenario parameter changes now cover the UK fiscal year they name, so an override applies at full strength instead of blending with prior law over its final five months. diff --git a/changelog.d/class4-maximum-steps.md b/changelog.d/class4-maximum-steps.md deleted file mode 100644 index bbb140a6bf..0000000000 --- a/changelog.d/class4-maximum-steps.md +++ /dev/null @@ -1 +0,0 @@ -- Apply the Class 4 National Insurance annual maximum's Step Six and Step Nine calculations from Regulation 100. diff --git a/changelog.d/council-tax-reduction-kingston-upon-thames.md b/changelog.d/council-tax-reduction-kingston-upon-thames.md deleted file mode 100644 index 4086466978..0000000000 --- a/changelog.d/council-tax-reduction-kingston-upon-thames.md +++ /dev/null @@ -1 +0,0 @@ -- Add working-age Council Tax Reduction for Kingston upon Thames. diff --git a/changelog.d/council-tax-reduction-merton.md b/changelog.d/council-tax-reduction-merton.md deleted file mode 100644 index e501e94f6d..0000000000 --- a/changelog.d/council-tax-reduction-merton.md +++ /dev/null @@ -1 +0,0 @@ -Add Merton working-age Council Tax Reduction. diff --git a/changelog.d/council-tax-reduction-national.md b/changelog.d/council-tax-reduction-national.md deleted file mode 100644 index da961bdf5e..0000000000 --- a/changelog.d/council-tax-reduction-national.md +++ /dev/null @@ -1 +0,0 @@ -- Add national Council Tax Reduction support for Scotland, Wales, and English pensioner households, while preserving reported CTB/CTR for unsupported English working-age local schemes. diff --git a/changelog.d/council-tax-reduction-newham.md b/changelog.d/council-tax-reduction-newham.md deleted file mode 100644 index 1cc7b89cae..0000000000 --- a/changelog.d/council-tax-reduction-newham.md +++ /dev/null @@ -1 +0,0 @@ -- Add working-age Council Tax Reduction for Newham. diff --git a/changelog.d/council-tax-reduction-oxford.md b/changelog.d/council-tax-reduction-oxford.md deleted file mode 100644 index b91c97da8f..0000000000 --- a/changelog.d/council-tax-reduction-oxford.md +++ /dev/null @@ -1 +0,0 @@ -Add Oxford working-age Council Tax Reduction. diff --git a/changelog.d/council-tax-reduction-westminster.md b/changelog.d/council-tax-reduction-westminster.md deleted file mode 100644 index c016a023d0..0000000000 --- a/changelog.d/council-tax-reduction-westminster.md +++ /dev/null @@ -1 +0,0 @@ -- Add working-age Council Tax Support for Westminster. diff --git a/changelog.d/fuel-duty-litre-rate.md b/changelog.d/fuel-duty-litre-rate.md deleted file mode 100644 index bfa6160665..0000000000 --- a/changelog.d/fuel-duty-litre-rate.md +++ /dev/null @@ -1 +0,0 @@ -- Compute fuel duty directly as calibrated petrol and diesel litres multiplied by the statutory duty rate, with fuel-spending proxies uprated by road-fuel litres and pump prices. diff --git a/changelog.d/fuel-volume-uprating.md b/changelog.d/fuel-volume-uprating.md deleted file mode 100644 index 45b40278e5..0000000000 --- a/changelog.d/fuel-volume-uprating.md +++ /dev/null @@ -1 +0,0 @@ -- Uprate petrol and diesel spending with HMRC/OBR road-fuel volume growth rather than CPI when projecting single-year datasets. diff --git a/changelog.d/legal-child-qyp-rules.md b/changelog.d/legal-child-qyp-rules.md deleted file mode 100644 index 3e60a51d65..0000000000 --- a/changelog.d/legal-child-qyp-rules.md +++ /dev/null @@ -1,4 +0,0 @@ -- Removed generic child or qualifying young person predicates and replaced them - with lowercase programme-specific child, qualifying young person, and - eligibility age-limit variables and parameters, including explicit - education or training entry and Universal Credit terminal-date conditions. diff --git a/changelog.d/local-dataset-filepaths.md b/changelog.d/local-dataset-filepaths.md deleted file mode 100644 index e2de04cd7d..0000000000 --- a/changelog.d/local-dataset-filepaths.md +++ /dev/null @@ -1 +0,0 @@ -- Allow UK simulations to load local dataset file paths, including materialized H5 files passed by upstream dataset resolvers. diff --git a/changelog.d/tv-licence-fee-2026.md b/changelog.d/tv-licence-fee-2026.md deleted file mode 100644 index 1932291904..0000000000 --- a/changelog.d/tv-licence-fee-2026.md +++ /dev/null @@ -1 +0,0 @@ -- Update TV licence fee parameters for 2024-25, 2025-26, and 2026-27. diff --git a/changelog.d/uc-childcare-work-condition.md b/changelog.d/uc-childcare-work-condition.md deleted file mode 100644 index a4c4cee36f..0000000000 --- a/changelog.d/uc-childcare-work-condition.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed Universal Credit childcare work-condition eligibility so couples require all adults to be in work. diff --git a/changelog.d/uprate-electricity-and-gas-consumption.md b/changelog.d/uprate-electricity-and-gas-consumption.md deleted file mode 100644 index b02ad01fdc..0000000000 --- a/changelog.d/uprate-electricity-and-gas-consumption.md +++ /dev/null @@ -1 +0,0 @@ -- Uprate `electricity_consumption` and `gas_consumption`. Both variables declare CPI uprating, but were absent from `uprating_indices.yaml`, so they returned identical values in every year while every other consumption variable on the same index rose. This also restores the documented identity `domestic_energy_consumption == electricity_consumption + gas_consumption`, which held in the base year but diverged thereafter because only the combined variable was uprated. From 041d9c6853ed195fe83aa945e62a9ddc30120eff Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:59:48 -0400 Subject: [PATCH 3/7] Test the changelog check and version bump against towncrier itself Property and differential tests: bump_version reads the same config and names as towncrier (parse_toml, parse_newfragment_basename, find_fragments) across six configurations; every name the check accepts towncrier consumes with that type; the real towncrier build renders exactly the fragments the check accepts; the check on a real git repository fails exactly the files towncrier would drop or that would stop the build; and the bump depends only on typed fragments. Also catch two fragments that share towncrier's key, which stops towncrier build and the release. Refs #2314 Co-Authored-By: Claude Opus 5.5 --- .github/bump_version.py | 43 +- .github/check_changelog_fragments.py | 60 +- .../code_health/test_changelog_fragments.py | 625 ++++++++++++++++++ 3 files changed, 705 insertions(+), 23 deletions(-) create mode 100644 policyengine_uk/tests/code_health/test_changelog_fragments.py diff --git a/.github/bump_version.py b/.github/bump_version.py index ee78a5730d..2012fd673f 100644 --- a/.github/bump_version.py +++ b/.github/bump_version.py @@ -32,6 +32,9 @@ "readme.rst", ) +# A template given as "package:resource" (towncrier._settings.load). +TOWNCRIER_RESOURCE_TEMPLATE = re.compile(r"[-\w.]+:[-\w.]+$") + MAJOR_TYPES = {"breaking"} MINOR_TYPES = {"added", "removed"} @@ -43,6 +46,7 @@ class TowncrierConfig: directory: str types: tuple[str, ...] ignore: tuple[str, ...] + orphan_prefix: str = "+" def parse_towncrier_config(pyproject_text: str) -> TowncrierConfig: @@ -65,12 +69,15 @@ def parse_towncrier_config(pyproject_text: str) -> TowncrierConfig: ) ignore = [name.lower() for name in TOWNCRIER_IGNORED_NAMES] ignore += [pattern.lower() for pattern in config.get("ignore") or []] - template = config.get("template") - if isinstance(template, str) and not template.startswith("towncrier:"): - # towncrier does not lower-case this one. + template = config.get("template", "towncrier:default") + if not TOWNCRIER_RESOURCE_TEMPLATE.match(template): + # A template file in the repository; towncrier does not lower-case it. ignore.append(Path(template).name) return TowncrierConfig( - directory=Path(directory).as_posix(), types=types, ignore=tuple(ignore) + directory=Path(directory).as_posix(), + types=types, + ignore=tuple(ignore), + orphan_prefix=config.get("orphan_prefix", "+"), ) @@ -83,19 +90,33 @@ def is_ignored(name: str, config: TowncrierConfig) -> bool: return any(fnmatch.fnmatch(name.lower(), pattern) for pattern in config.ignore) -def towncrier_fragment_type(name: str, types) -> str | None: - """The type towncrier reads from a fragment's name, or None if it has none. +def parse_fragment_name(name: str, types) -> tuple[str, str, int] | None: + """(issue, type, counter) as towncrier reads a fragment's name, or None. - Mirrors towncrier._builder.parse_newfragment_basename: the last - dot-separated part after the first that is a configured type. + Mirrors towncrier._builder.parse_newfragment_basename: the type is the + last dot-separated part, after the first, that is a configured type; the + parts before it are the issue (an all-digit issue loses leading zeros); + a number right after it is the counter. """ parts = name.split(".") - for part in reversed(parts[1:]): - if part in types: - return part + for i in reversed(range(1, len(parts))): + if parts[i] in types: + issue = ".".join(parts[:i]).strip() + if issue.isdigit(): + issue = str(int(issue)) + counter = 0 + if len(parts) > i + 1 and parts[i + 1].isdigit(): + counter = int(parts[i + 1]) + return issue, parts[i], counter return None +def towncrier_fragment_type(name: str, types) -> str | None: + """The type towncrier reads from a fragment's name, or None if it has none.""" + parsed = parse_fragment_name(name, types) + return parsed[1] if parsed else None + + def classify_fragments( changelog_dir: Path, config: TowncrierConfig ) -> tuple[list[tuple[Path, str]], list[Path]]: diff --git a/.github/check_changelog_fragments.py b/.github/check_changelog_fragments.py index f4d8dff00e..dac94328ed 100644 --- a/.github/check_changelog_fragments.py +++ b/.github/check_changelog_fragments.py @@ -17,7 +17,10 @@ - be named ``..md``, with a type configured in pyproject.toml at ``--head``, and a slug of letters, digits, ``.``, ``_`` and ``-`` (optionally after towncrier's orphan prefix ``+``); -- be UTF-8 text that is not blank. +- be UTF-8 text that is not blank; +- not share towncrier's (issue, type, counter) key with another fragment, + such as ``7.fixed.md`` and ``007.fixed.md``, which stops ``towncrier + build`` and so the release. Files towncrier ignores (.gitkeep, README and the configured ``ignore``) are not fragments and pass. Every name this accepts is one towncrier consumes, @@ -42,11 +45,11 @@ from bump_version import ( # noqa: E402 TowncrierConfig, is_ignored, + parse_fragment_name, parse_towncrier_config, - towncrier_fragment_type, ) -SLUG = re.compile(r"\+?[A-Za-z0-9][A-Za-z0-9._-]*") +SLUG = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") LIST_MARKER = re.compile(r"[-*+]\s") @@ -56,16 +59,18 @@ class Report: warnings: list[tuple[str | None, str]] = field(default_factory=list) -def name_problem(name: str, types) -> str | None: +def name_problem(name: str, config: TowncrierConfig) -> str | None: """Why a fragment's name is not ``..md``, or None if it is.""" if not name.endswith(".md"): return "does not end in .md" slug, dot, fragment_type = name.removesuffix(".md").rpartition(".") - if not dot or fragment_type not in types: + if not dot or fragment_type not in config.types: return ( "has no towncrier type, so towncrier never writes it to CHANGELOG.md " "and never deletes it" ) + if config.orphan_prefix: + slug = slug.removeprefix(config.orphan_prefix) if not SLUG.fullmatch(slug): return ( f"has the slug {slug!r}; use letters, digits, '.', '_' and '-' " @@ -103,7 +108,7 @@ def check_fragment( ) if is_ignored(name, config): return None, None - problem = name_problem(name, config.types) + problem = name_problem(name, config) if problem: types = ", ".join(config.types) return f"{problem}. Name it ..md with a type from {types}.", None @@ -143,30 +148,61 @@ def changed_files(repo: Path, base: str, head: str, directory: str) -> list[str] return paths +def fragment_key(name: str, config: TowncrierConfig) -> tuple[str, str, int] | None: + """The key towncrier files a fragment under; None for an orphan or no type. + + towncrier stops with "multiple files for ..." when two fragments share one. + """ + parsed = parse_fragment_name(name, config.types) + if parsed is None or ( + config.orphan_prefix and parsed[0].startswith(config.orphan_prefix) + ): + return None + return parsed + + def check(repo: Path, base: str, head: str) -> Report: config = parse_towncrier_config( git(repo, "show", f"{head}:pyproject.toml").decode() ) report = Report() changed = changed_files(repo, base, head, config.directory) + tree = git(repo, "ls-tree", "--name-only", "-z", head, "--", f"{config.directory}/") + names = [ + path.rpartition("/")[2] for path in filter(None, tree.decode().split("\0")) + ] + keys = {} + for name in names: + if not is_ignored(name, config): + keys.setdefault(fragment_key(name, config), []).append(name) added_fragment = False for path in changed: error, warning = check_fragment( path, git(repo, "show", f"{head}:{path}"), config ) + name = path.rpartition("/")[2] + key = fragment_key(name, config) + if not error and key is not None and len(keys.get(key, [])) > 1: + others = ", ".join(other for other in keys[key] if other != name) + error = ( + f"has the same towncrier key as {others} ({key[0]}.{key[1]}), so " + "towncrier build stops with 'multiple files' and the release fails. " + "Use a different slug." + ) if error: report.errors.append((path, error)) elif warning: report.warnings.append((path, warning)) - name = path.rpartition("/")[2] if not error and not is_ignored(name, config): added_fragment = True - tree = git(repo, "ls-tree", "--name-only", "-z", head, "--", f"{config.directory}/") - for path in filter(None, tree.decode().split("\0")): - name = path.rpartition("/")[2] - if path in changed or is_ignored(name, config): + for name in names: + path = f"{config.directory}/{name}" + touched = any( + other == path or other.startswith(f"{path}/") for other in changed + ) + if touched or is_ignored(name, config): continue - if towncrier_fragment_type(name, config.types) is None: + if parse_fragment_name(name, config.types) is None: report.warnings.append( ( path, diff --git a/policyengine_uk/tests/code_health/test_changelog_fragments.py b/policyengine_uk/tests/code_health/test_changelog_fragments.py new file mode 100644 index 0000000000..049672b97f --- /dev/null +++ b/policyengine_uk/tests/code_health/test_changelog_fragments.py @@ -0,0 +1,625 @@ +"""What the changelog check accepts is what towncrier writes to CHANGELOG.md. + +towncrier writes a fragment in ``changelog.d`` to CHANGELOG.md only when its +name carries a type configured in pyproject.toml; it never reads or deletes +any other file there, and leaves an empty fragment out. +``.github/bump_version.py`` counts fragments for the version bump and +``.github/check_changelog_fragments.py`` fails pull requests that add ones +towncrier would drop. These tests hold them to: + +1. Config: the fragment types, directory and orphan prefix bump_version reads + from pyproject.toml equal towncrier's own, for this repository and for + each way towncrier can be configured (differential). +2. Names: for any file name, bump_version's (issue, type, counter) equals + towncrier's ``parse_newfragment_basename`` (differential). +3. Directories: the files bump_version counts are exactly the fragments + towncrier's ``find_fragments`` consumes, with the same types, and it calls + a file untyped exactly when towncrier's strict mode rejects the directory + (differential, every configuration in 1). +4. The check never accepts a name towncrier drops, and every name it accepts + towncrier consumes with the type the name shows. +5. Rendering, with the real ``towncrier build``: each fragment the check + accepts appears once, with one bullet; each it rejects is missing from + the output; a fragment it warns about starts with ``- - ``. +6. On a real git repository the check fails exactly the files a pull request + adds or changes that towncrier would drop or that would stop the build, + passes deletions and ignored names, and only warns about files it does not + change and a pull request without a fragment. +7. The version bump depends only on typed fragments (breaking is major, added + or removed minor, any other type patch). Untyped files never change it. + With no typed fragment the script exits 1 and leaves the version alone. +""" + +import contextlib +import importlib.util +import io +import os +import shutil +import string +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path + +import pytest +from click import ClickException +from hypothesis import assume, given, settings +from hypothesis import strategies as st +from towncrier._builder import ( + FragmentsPath, + find_fragments, + parse_newfragment_basename, +) +from towncrier._settings.load import load_config_from_options, parse_toml + +REPO_ROOT = Path(__file__).resolve().parents[3] +GITHUB_DIR = REPO_ROOT / ".github" +BUMP_SCRIPT = GITHUB_DIR / "bump_version.py" +CHECK_SCRIPT = GITHUB_DIR / "check_changelog_fragments.py" + +if not CHECK_SCRIPT.exists(): + pytest.skip( + "the .github folder is not part of an installed package", + allow_module_level=True, + ) + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +bump = load("bump_version", BUMP_SCRIPT) +check = load("check_changelog_fragments", CHECK_SCRIPT) + +PYPROJECT = (REPO_ROOT / "pyproject.toml").read_text() +CONFIG = bump.parse_towncrier_config(PYPROJECT) + +# The repository's configuration, and one of each other way towncrier reads +# fragment types, directories, ignore lists, templates and orphan prefixes. +CONFIGS = { + "repository": PYPROJECT, + "default types": '[tool.towncrier]\ndirectory = "news"\n', + "default directory": '[tool.towncrier]\npackage = "pkg"\n', + "type array": ( + '[tool.towncrier]\ndirectory = "news"\n' + '[[tool.towncrier.type]]\nname = "Security"\n' + '[[tool.towncrier.type]]\nname = "Features"\ndirectory = "feature"\n' + ), + "fragment tables and ignore": ( + '[tool.towncrier]\ndirectory = "news"\nignore = ["NOTES.txt", "*.tmp"]\n' + '[tool.towncrier.fragment.chore]\n[tool.towncrier.fragment.fix]\nname = "Fixes"\n' + ), + "template file and orphan prefix": ( + '[tool.towncrier]\ndirectory = "news"\ntemplate = "news/template.md"\n' + 'orphan_prefix = "~"\n' + ), +} + +NAME_PART = st.one_of( + st.sampled_from( + [ + *CONFIG.types, + *bump.TOWNCRIER_DEFAULT_TYPES, + "security", + "chore", + "fix", + "md", + "rst", + "Fixed", + "1", + "007", + "2314", + "+x", + "~y", + " ", + "", + "readme", + "README", + "gitkeep", + "tmp", + "NOTES", + "txt", + "Template", + ] + ), + st.text(alphabet=string.ascii_letters + string.digits + "-_+~ ", max_size=5), +) +NAMES = st.lists(NAME_PART, min_size=1, max_size=5).map(".".join) +FILE_NAMES = NAMES.filter(lambda name: name not in ("", ".", "..")) +SLUGS = st.from_regex(r"\+?[A-Za-z0-9][A-Za-z0-9._-]{0,20}", fullmatch=True) + + +def towncrier_parse(name, types): + parsed = parse_newfragment_basename(name, types) + return None if parsed == (None, None, None) else parsed + + +# 1. Config + + +@pytest.mark.parametrize("text", CONFIGS.values(), ids=CONFIGS.keys()) +def test_config_matches_towncrier(text, tmp_path): + (tmp_path / "news").mkdir() + (tmp_path / "news" / "template.md").write_text("") + theirs = parse_toml(str(tmp_path), tomllib.loads(text)) + ours = bump.parse_towncrier_config(text) + + assert ours.types == tuple(theirs.types) + assert ours.orphan_prefix == theirs.orphan_prefix + assert (tmp_path / ours.directory).resolve() == Path( + FragmentsPath(str(tmp_path), theirs)("") + ).resolve() + + +def test_repository_config(): + assert CONFIG.directory == "changelog.d" + assert CONFIG.types == ("breaking", "added", "changed", "fixed", "removed") + + +def test_a_pyproject_without_towncrier_is_an_error(): + with pytest.raises(ValueError, match="tool.towncrier"): + bump.parse_towncrier_config('[project]\nname = "x"\n') + + +# 2. Names + + +@settings(max_examples=500, deadline=None) +@given(NAMES) +def test_name_parsing_matches_towncrier(name): + for types in ( + CONFIG.types, + bump.TOWNCRIER_DEFAULT_TYPES, + bump.parse_towncrier_config(CONFIGS["fragment tables and ignore"]).types, + ): + assert bump.parse_fragment_name(name, types) == towncrier_parse(name, types) + + +# 3. Directories + + +@pytest.mark.parametrize("text", CONFIGS.values(), ids=CONFIGS.keys()) +@settings(max_examples=60, deadline=None) +@given(names=st.lists(FILE_NAMES, max_size=8, unique_by=str.lower)) +def test_counted_fragments_are_the_ones_towncrier_consumes(text, names): + config = bump.parse_towncrier_config(text) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "pyproject.toml").write_text(text) + directory = root / config.directory + directory.mkdir(parents=True) + if "template" in text: + (root / "news" / "template.md").write_text("") + for name in names: + if not (directory / name).exists(): + (directory / name).write_text("A note.\n") + base, theirs = load_config_from_options(str(root), None) + try: + consumed = find_fragments(base, theirs, strict=False)[1] + except ValueError: + # Two fragments share a key; towncrier refuses to build at all. + assume(False) + typed, untyped = bump.classify_fragments(directory, config) + + assert sorted((path.name, kind) for path, kind in typed) == sorted( + (Path(path).name, kind) for path, kind in consumed + ) + try: + find_fragments(base, theirs, strict=True) + towncrier_rejects = False + except ClickException: + towncrier_rejects = True + assert towncrier_rejects == bool(untyped) + + +# 4. The check's names + + +@settings(max_examples=500, deadline=None) +@given(FILE_NAMES) +def test_the_check_never_accepts_a_name_towncrier_drops(name): + parsed = towncrier_parse(name, CONFIG.types) + if check.name_problem(name, CONFIG) is None: + assert parsed is not None + assert parsed[1] == name.removesuffix(".md").rpartition(".")[2] + if parsed is None: + assert check.name_problem(name, CONFIG) is not None + + +@settings(max_examples=300, deadline=None) +@given(SLUGS, st.sampled_from(CONFIG.types)) +def test_slug_type_md_names_pass(slug, fragment_type): + name = f"{slug}.{fragment_type}.md" + + assert check.name_problem(name, CONFIG) is None + assert not bump.is_ignored(name, CONFIG) + assert towncrier_parse(name, CONFIG.types)[1] == fragment_type + + +@pytest.mark.parametrize( + "name", + [ + "1005.md", + "1044-explicit-leave.md", + "fixed.md", + ".fixed.md", + "x.Fixed.md", + "x.feature.md", + "x.fixed", + "x.fixed.txt", + "x.fixed.2.md", + "with space.fixed.md", + "-x.fixed.md", + ], +) +def test_names_the_check_rejects(name): + assert check.name_problem(name, CONFIG) is not None + + +@pytest.mark.parametrize( + "data, error, warning", + [ + (b"Fixed a thing.\n", False, False), + (b"Fixed:\n\n- one\n- two\n", False, False), + (b"", True, False), + (b" \n\t\n", True, False), + (b"\xff\xfe", True, False), + (b"- Fixed a thing.\n", False, True), + (b"\n* Fixed a thing.\n", False, True), + (b"+ Fixed a thing.\n", False, True), + ], +) +def test_fragment_content(data, error, warning): + found_error, found_warning = check.content_problems(data) + + assert bool(found_error) == error + assert bool(found_warning) == warning + + +# 5. Rendering + + +def test_rendering_agrees_with_the_check(tmp_path): + fragments = { + "a.fixed.md": b"Fixed a.\n", + "b.added.md": b"Added b.\n", + "2314.removed.md": b"Removed c.\n", + "+orphan.changed.md": b"Changed d.\n", + "untyped.md": b"Untyped note.\n", + "empty.fixed.md": b"", + "blank.added.md": b" \n", + "bullet.changed.md": b"- Bulleted note.\n", + } + (tmp_path / "pyproject.toml").write_text(PYPROJECT) + (tmp_path / "CHANGELOG.md").write_text("# Changelog\n") + (tmp_path / "changelog.d").mkdir() + for name, data in fragments.items(): + (tmp_path / "changelog.d" / name).write_bytes(data) + + draft = subprocess.run( + [ + sys.executable, + "-m", + "towncrier", + "build", + "--draft", + "--name", + "policyengine-uk", + "--version", + "9.9.9", + "--date", + "2026-01-01", + ], + cwd=tmp_path, + capture_output=True, + text=True, + check=True, + ).stdout + bullets = [line for line in draft.splitlines() if line.startswith("- ")] + + for name, data in fragments.items(): + error, warning = check.check_fragment(f"changelog.d/{name}", data, CONFIG) + text = data.decode().strip() + if error: + # Dropped: an untyped name, or nothing to write. + assert not text or text not in draft, name + else: + assert bullets.count(f"- {text}") == 1, name + assert text.startswith("- ") == bool(warning), name + # Only accepted fragments, and the warned one, produce bullets. + accepted = [ + name + for name, data in fragments.items() + if not check.check_fragment(f"changelog.d/{name}", data, CONFIG)[0] + ] + assert len(bullets) == len(accepted) + + +# 6. The check on a real git repository + +GIT_ENV = { + **os.environ, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.com", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.com", +} + + +def git(repo, *args): + return subprocess.run( + ["git", *args], + cwd=repo, + env=GIT_ENV, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +@pytest.fixture +def repo(tmp_path): + """A repository whose base commit holds two untyped files and a fragment.""" + git(tmp_path, "init", "-q", "-b", "main") + (tmp_path / "pyproject.toml").write_text(PYPROJECT) + (tmp_path / "changelog.d").mkdir() + (tmp_path / "changelog.d" / ".gitkeep").write_text("") + (tmp_path / "changelog.d" / "stale.md").write_text("Stale note.\n") + (tmp_path / "changelog.d" / "old.md").write_text("Old note.\n") + (tmp_path / "changelog.d" / "kept.fixed.md").write_text("Kept.\n") + (tmp_path / "code.py").write_text("x = 1\n") + git(tmp_path, "add", "-A") + git(tmp_path, "commit", "-qm", "base") + return tmp_path + + +def commit(repo, files=(), delete=(), moves=()): + base = git(repo, "rev-parse", "HEAD") + for path, data in dict(files).items(): + target = repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data if isinstance(data, bytes) else data.encode()) + for path in delete: + git(repo, "rm", "-q", path) + for source, target in moves: + git(repo, "mv", source, target) + git(repo, "add", "-A") + git(repo, "commit", "-qm", "change", "--allow-empty") + return base, git(repo, "rev-parse", "HEAD") + + +def run_check(repo, base, head): + result = subprocess.run( + [ + sys.executable, + str(CHECK_SCRIPT), + "--repo", + str(repo), + "--base", + base, + "--head", + head, + ], + capture_output=True, + text=True, + ) + annotations = {"error": set(), "warning": set(), "unlocated": []} + for line in result.stdout.splitlines(): + level, _, rest = line.removeprefix("::").partition(" file=") + if rest: + annotations[level].add(rest.split("::", 1)[0]) + elif line.startswith("::"): + annotations["unlocated"].append(line) + return result.returncode, annotations + + +def test_the_check_fails_exactly_the_fragments_towncrier_would_drop(repo): + base, head = commit( + repo, + files={ + "changelog.d/good.fixed.md": "Fixed a thing.\n", + "changelog.d/1234.md": "Untyped.\n", + "changelog.d/x.feature.md": "Unknown type.\n", + "changelog.d/empty.added.md": "", + "changelog.d/binary.changed.md": b"\xff\xfe", + "changelog.d/with space.fixed.md": "Space.\n", + "changelog.d/sub/nested.fixed.md": "Nested.\n", + "changelog.d/7.removed.md": "Seven.\n", + "changelog.d/007.removed.md": "Seven again.\n", + "changelog.d/bullet.changed.md": "- Changed.\n", + "changelog.d/README.md": "About this folder.\n", + "changelog.d/old.md": "Old note, edited.\n", + }, + delete=["changelog.d/kept.fixed.md"], + ) + + code, found = run_check(repo, base, head) + + assert code == 1 + assert found["error"] == { + "changelog.d/1234.md", + "changelog.d/x.feature.md", + "changelog.d/empty.added.md", + "changelog.d/binary.changed.md", + "changelog.d/with space.fixed.md", + "changelog.d/sub/nested.fixed.md", + "changelog.d/7.removed.md", + "changelog.d/007.removed.md", + "changelog.d/old.md", + } + assert found["warning"] == {"changelog.d/bullet.changed.md", "changelog.d/stale.md"} + assert found["unlocated"] == [] + + +def test_deleting_untyped_files_and_adding_a_fragment_passes(repo): + base, head = commit( + repo, + files={"changelog.d/2314.fixed.md": "Fixed the changelog.\n"}, + delete=["changelog.d/stale.md", "changelog.d/old.md"], + ) + + code, found = run_check(repo, base, head) + + assert code == 0 + assert found == {"error": set(), "warning": set(), "unlocated": []} + + +def test_renaming_a_fragment_to_an_untyped_name_fails(repo): + base, head = commit( + repo, moves=[("changelog.d/kept.fixed.md", "changelog.d/kept.md")] + ) + + code, found = run_check(repo, base, head) + + assert code == 1 + assert found["error"] == {"changelog.d/kept.md"} + + +def test_a_pull_request_without_a_fragment_only_warns(repo): + base, head = commit(repo, files={"code.py": "x = 2\n"}) + + code, found = run_check(repo, base, head) + + assert code == 0 + assert found["error"] == set() + assert found["warning"] == {"changelog.d/stale.md", "changelog.d/old.md"} + assert len(found["unlocated"]) == 1 + assert "adds no changelog fragment" in found["unlocated"][0] + + +def test_a_type_added_in_the_same_pull_request_counts(repo): + pyproject = PYPROJECT.replace( + '[[tool.towncrier.type]]\ndirectory = "removed"', + '[[tool.towncrier.type]]\ndirectory = "security"\nname = "Security"\n' + 'showcontent = true\n\n[[tool.towncrier.type]]\ndirectory = "removed"', + ) + assert pyproject != PYPROJECT + base, head = commit( + repo, + files={ + "pyproject.toml": pyproject, + "changelog.d/patch.security.md": "Patched.\n", + }, + ) + + code, found = run_check(repo, base, head) + + assert code == 0 + assert found["error"] == set() + + +def test_a_missing_towncrier_config_is_a_usage_error(repo): + base, head = commit(repo, files={"pyproject.toml": '[project]\nname = "x"\n'}) + + code, found = run_check(repo, base, head) + + assert code == 2 + + +def test_annotations_escape_github_command_characters(): + assert check.annotation("error", "a,b:c.md", "is 50%\nwrong") == ( + "::error file=a%2Cb%3Ac.md::a,b:c.md is 50%25%0Awrong" + ) + + +# 7. The version bump + +UNTYPED_NAMES = FILE_NAMES.filter( + lambda name: ( + towncrier_parse(name, CONFIG.types) is None + and not bump.is_ignored(name, CONFIG) + ) +) + + +def expected_bump(types): + types = set(types) + if "breaking" in types: + return "major" + if {"added", "removed"} & types: + return "minor" + return "patch" + + +@settings(max_examples=150, deadline=None) +@given( + typed=st.lists(st.tuples(SLUGS, st.sampled_from(CONFIG.types)), max_size=6), + untyped=st.lists(UNTYPED_NAMES, max_size=4), +) +def test_the_bump_depends_only_on_typed_fragments(typed, untyped): + # File names that differ only in case are one file on macOS; keep the first. + seen, kinds, untyped_files = set(), [], [] + for slug, kind in typed: + name = f"{slug}.{kind}.md" + if name.lower() not in seen: + seen.add(name.lower()) + kinds.append((name, kind)) + for name in untyped: + if name.lower() not in seen: + seen.add(name.lower()) + untyped_files.append(name) + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + for name in [name for name, _ in kinds] + untyped_files: + (directory / name).write_text("A note.\n") + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()): + if kinds: + result = bump.infer_bump(directory, CONFIG) + else: + with pytest.raises(SystemExit) as exit_info: + bump.infer_bump(directory, CONFIG) + if kinds: + assert result == expected_bump(kind for _, kind in kinds) + else: + assert exit_info.value.code == 1 + assert out.getvalue().count("::warning file=") == len(untyped_files) + + +def run_bump_script(tmp_path, fragments): + (tmp_path / ".github").mkdir() + shutil.copy(BUMP_SCRIPT, tmp_path / ".github" / "bump_version.py") + (tmp_path / "pyproject.toml").write_text(PYPROJECT) + (tmp_path / "changelog.d").mkdir() + (tmp_path / "changelog.d" / ".gitkeep").write_text("") + for name, text in fragments.items(): + (tmp_path / "changelog.d" / name).write_text(text) + before = tomllib.loads(PYPROJECT)["project"]["version"] + result = subprocess.run( + [sys.executable, ".github/bump_version.py"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + after = tomllib.loads((tmp_path / "pyproject.toml").read_text())["project"][ + "version" + ] + return result, before, after + + +def test_only_untyped_files_is_nothing_to_release(tmp_path): + result, before, after = run_bump_script( + tmp_path, {"1005.md": "Untyped.\n", "notes.md": "Untyped.\n"} + ) + + assert result.returncode == 1 + assert after == before + assert result.stdout.count("::warning file=changelog.d/") == 2 + assert "No changelog fragments with a towncrier type" in result.stderr + + +def test_untyped_files_do_not_change_the_bump(tmp_path): + result, before, after = run_bump_script( + tmp_path, {"1005.md": "Untyped.\n", "feature.added.md": "Added it.\n"} + ) + + assert result.returncode == 0, result.stderr + major, minor, _ = (int(part) for part in before.split(".")) + assert after == f"{major}.{minor + 1}.0" + assert "::warning file=changelog.d/1005.md::" in result.stdout From f3d389fe2630e1d3264e80f23db9cff02d8ebcbc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:02:36 -0400 Subject: [PATCH 4/7] Run the changelog check for merge queue groups too A required check must also report on merge_group, or the queue waits on it; diff a group against its base, as pr_code_changes.yaml does in #2306. Refs #2314 Co-Authored-By: Claude Opus 5.5 --- .github/check_changelog_fragments.py | 4 +++- .github/workflows/pr_changelog.yaml | 22 ++++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/check_changelog_fragments.py b/.github/check_changelog_fragments.py index dac94328ed..65bbb1e8e7 100644 --- a/.github/check_changelog_fragments.py +++ b/.github/check_changelog_fragments.py @@ -7,10 +7,12 @@ files, and every one of their notes was missing from CHANGELOG.md. The Changelog workflow (.github/workflows/pr_changelog.yaml) runs this on the -pull request's merge commit: +pull request's merge commit, whose first parent is main: python .github/check_changelog_fragments.py --base HEAD^1 --head HEAD +and on a merge queue group against the group's base. + Each file the pull request adds or changes in the fragment directory must: - sit directly in the directory; diff --git a/.github/workflows/pr_changelog.yaml b/.github/workflows/pr_changelog.yaml index 8dddee6724..c6ba990840 100644 --- a/.github/workflows/pr_changelog.yaml +++ b/.github/workflows/pr_changelog.yaml @@ -5,18 +5,21 @@ # or deletes any other file in changelog.d. This fails a pull request that # adds or changes such a file, or an empty fragment # (.github/check_changelog_fragments.py). It has no path filter, so it -# reports on every pull request and can be a required check. +# reports on every pull request and can be a required check. It also runs for +# merge queue groups, so a queue never waits on it. name: Changelog on: pull_request: branches: - main + merge_group: -# A new push to a pull request supersedes the run on its previous head. +# A new push to a pull request supersedes the run on its previous head, so +# cancel that run. Merge groups are never cancelled. concurrency: group: pr-changelog-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -37,4 +40,15 @@ jobs: with: python-version: '3.13' - name: Check changelog fragments - run: python .github/check_changelog_fragments.py --base HEAD^1 --head HEAD + env: + EVENT: ${{ github.event_name }} + MERGE_GROUP_BASE: ${{ github.event.merge_group.base_sha }} + run: | + # A pull request's checkout is its merge into main, whose first + # parent is main. A merge group's base can be several commits back. + base=HEAD^1 + if [ "$EVENT" = merge_group ]; then + git fetch --no-tags --depth=1 origin "$MERGE_GROUP_BASE" + base=$MERGE_GROUP_BASE + fi + python .github/check_changelog_fragments.py --base "$base" --head HEAD From 15dfe3f7251e055a8c3e9ef3870078f5e01588fc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:02:56 -0400 Subject: [PATCH 5/7] Say exactly what the changelog check fails in the PR guide Refs #2314 Co-Authored-By: Claude Opus 5.5 --- docs/engineering/skills/github-prs.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/engineering/skills/github-prs.md b/docs/engineering/skills/github-prs.md index 234af85a60..e901ff8c60 100644 --- a/docs/engineering/skills/github-prs.md +++ b/docs/engineering/skills/github-prs.md @@ -40,14 +40,17 @@ changelog.d/1748.added.md changelog.d/1748.fixed.md ``` -towncrier writes only these names to `CHANGELOG.md`. It never reads, and never -deletes, a file without a configured type, such as `changelog.d/1748.md`, and -it leaves an empty fragment out. `.github/bump_version.py` counts only the -fragments towncrier consumes. The **Changelog fragments** check +towncrier writes a fragment to `CHANGELOG.md` only when its name carries one of +these types. It never reads, and never deletes, a file without one, such as +`changelog.d/1748.md`, and it leaves an empty fragment out. +`.github/bump_version.py` counts only the fragments towncrier consumes, so such +a file never triggers a release. The **Changelog fragments** check (`.github/workflows/pr_changelog.yaml`, which runs -`.github/check_changelog_fragments.py`) fails a PR that adds or changes such a -file, or one outside `changelog.d/` itself. It warns when a PR adds no fragment -or a fragment starts with a list marker. +`.github/check_changelog_fragments.py`) fails a PR that adds or changes a file +in `changelog.d/` that is not named `..md`, is empty, sits in a +subdirectory, or shares towncrier's key with another fragment (such as +`7.fixed.md` and `007.fixed.md`, which stops the release). It warns when a PR +adds no fragment or a fragment starts with a list marker. Write the fragment as a short sentence describing the user-visible or maintainer-visible change. towncrier adds the bullet itself, so do not start From e9462760010b061aa39ebf072fadde6a7958eddc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:09:48 -0400 Subject: [PATCH 6/7] Copy uv.lock into the bump script's test repository #2129 makes bump_version.py move uv.lock's own version too and stop when the lock is missing; the end-to-end bump tests then still pass. Refs #2314 Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/code_health/test_changelog_fragments.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/policyengine_uk/tests/code_health/test_changelog_fragments.py b/policyengine_uk/tests/code_health/test_changelog_fragments.py index 049672b97f..a0f5110d3d 100644 --- a/policyengine_uk/tests/code_health/test_changelog_fragments.py +++ b/policyengine_uk/tests/code_health/test_changelog_fragments.py @@ -586,6 +586,9 @@ def run_bump_script(tmp_path, fragments): (tmp_path / ".github").mkdir() shutil.copy(BUMP_SCRIPT, tmp_path / ".github" / "bump_version.py") (tmp_path / "pyproject.toml").write_text(PYPROJECT) + if (REPO_ROOT / "uv.lock").exists(): + # A release also moves the lock's own version when the script does. + shutil.copy(REPO_ROOT / "uv.lock", tmp_path / "uv.lock") (tmp_path / "changelog.d").mkdir() (tmp_path / "changelog.d" / ".gitkeep").write_text("") for name, text in fragments.items(): From d95d020f3d882c3f32ccc19af2952d8cb7e9e80f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 19:40:27 -0400 Subject: [PATCH 7/7] Address review: accurate messages, symlinks, strict config, release trigger Sol's review of #2324 (~/reviews/uk-changelog-fragment-check-2026-10-11): - the test file tripped the cached-array scanner (.partition on a call result); parse annotations with a regex instead; - say a name has no towncrier type only when towncrier reads none; other forms it consumes (x.fixed.1.md, x.fixed) are named as such; - a non-UTF-8 fragment is written with replacement characters, not dropped; - reject symbolic links, submodule entries and non-UTF-8 file names; - a type called md no longer breaks 'accepted means consumed as shown'; - bump_version refuses towncrier.toml, sections and issue_pattern, follows the project-name package fallback, and stops where towncrier build is strict (ignore configured); its annotations are escaped; - fail a PR that starts the release workflow with no typed fragment left; - tests for each, and for the five mutants that survived. Refs #2314 Co-Authored-By: Claude Opus 5.5 --- .github/bump_version.py | 72 ++- .github/check_changelog_fragments.py | 252 ++++++--- docs/engineering/skills/github-prs.md | 11 +- .../code_health/test_changelog_fragments.py | 485 +++++++++++++++--- 4 files changed, 654 insertions(+), 166 deletions(-) diff --git a/.github/bump_version.py b/.github/bump_version.py index 2012fd673f..d74ca38a41 100644 --- a/.github/bump_version.py +++ b/.github/bump_version.py @@ -8,6 +8,12 @@ ``.github/check_changelog_fragments.py`` uses the same rules to reject them in pull requests. +It reads the towncrier options that decide which files are fragments: +``directory`` (or ``package`` and ``package_dir``), the fragment types, +``ignore``, ``template`` and ``orphan_prefix``. It refuses a configuration it +would read differently from towncrier: a ``towncrier.toml`` file, sections, +or an ``issue_pattern``. + Standard library only. """ @@ -47,13 +53,26 @@ class TowncrierConfig: types: tuple[str, ...] ignore: tuple[str, ...] orphan_prefix: str = "+" + # towncrier build stops at a file without a type, instead of skipping it, + # whenever `ignore` is configured. + strict: bool = False def parse_towncrier_config(pyproject_text: str) -> TowncrierConfig: """Read [tool.towncrier] from pyproject.toml text the way towncrier does.""" - config = tomllib.loads(pyproject_text).get("tool", {}).get("towncrier") + pyproject = tomllib.loads(pyproject_text) + config = pyproject.get("tool", {}).get("towncrier") if config is None: raise ValueError("pyproject.toml has no [tool.towncrier] table") + for option, effect in ( + ("section", "towncrier would read a fragment directory per section"), + ("issue_pattern", "towncrier would stop at fragments whose issue differs"), + ): + if config.get(option): + raise ValueError( + f"[tool.towncrier] sets `{option}`, which .github/bump_version.py " + f"does not read: {effect}" + ) if config.get("fragment"): types = tuple(sorted(config["fragment"])) elif config.get("type"): @@ -64,8 +83,10 @@ def parse_towncrier_config(pyproject_text: str) -> TowncrierConfig: types = TOWNCRIER_DEFAULT_TYPES directory = config.get("directory") if directory is None: + # towncrier falls back to the project's name for the package. + package = config.get("package") or pyproject.get("project", {}).get("name", "") directory = str( - Path(config.get("package_dir", ".")) / config["package"] / "newsfragments" + Path(config.get("package_dir", ".")) / package / "newsfragments" ) ignore = [name.lower() for name in TOWNCRIER_IGNORED_NAMES] ignore += [pattern.lower() for pattern in config.get("ignore") or []] @@ -78,10 +99,16 @@ def parse_towncrier_config(pyproject_text: str) -> TowncrierConfig: types=types, ignore=tuple(ignore), orphan_prefix=config.get("orphan_prefix", "+"), + strict=config.get("ignore") is not None, ) def load_towncrier_config(pyproject_path: Path) -> TowncrierConfig: + if (pyproject_path.parent / "towncrier.toml").exists(): + raise ValueError( + "towncrier.toml exists, and towncrier reads it instead of " + "pyproject.toml; .github/bump_version.py reads only pyproject.toml" + ) return parse_towncrier_config(pyproject_path.read_text()) @@ -146,6 +173,21 @@ def bump_for_types(fragment_types) -> str: return "patch" +def escape_data(text: str) -> str: + return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def escape_property(text: str) -> str: + return escape_data(text).replace(":", "%3A").replace(",", "%2C") + + +def annotation(level: str, path: str | None, message: str) -> str: + """A GitHub Actions workflow command that annotates the file.""" + location = f" file={escape_property(path)}" if path else "" + text = f"{path} {message}" if path else message + return f"::{level}{location}::{escape_data(text)}" + + def get_current_version(pyproject_path: Path) -> str: text = pyproject_path.read_text() match = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', text, re.MULTILINE) @@ -160,13 +202,29 @@ def get_current_version(pyproject_path: Path) -> str: def infer_bump(changelog_dir: Path, config: TowncrierConfig) -> str: typed, untyped = classify_fragments(changelog_dir, config) + names = ", ".join(config.types) + if untyped and config.strict: + # towncrier build would stop at these; say so before changing anything. + for path in untyped: + print( + annotation( + "error", + f"{config.directory}/{path.name}", + "has no towncrier type, and towncrier build stops at it " + "because [tool.towncrier] sets `ignore`. Fragments are " + f"named ..md with a type from {names}.", + ) + ) + sys.exit(1) for path in untyped: - relative = f"{config.directory}/{path.name}" print( - f"::warning file={relative}::{relative} has no towncrier type, so " - "towncrier leaves it out of CHANGELOG.md and this release ignores it. " - f"Fragments are named ..md with a type from " - f"{', '.join(config.types)}." + annotation( + "warning", + f"{config.directory}/{path.name}", + "has no towncrier type, so towncrier leaves it out of " + "CHANGELOG.md and this release ignores it. Fragments are named " + f"..md with a type from {names}.", + ) ) if not typed: print( diff --git a/.github/check_changelog_fragments.py b/.github/check_changelog_fragments.py index 65bbb1e8e7..a5826109e6 100644 --- a/.github/check_changelog_fragments.py +++ b/.github/check_changelog_fragments.py @@ -1,4 +1,4 @@ -"""Fail a pull request that adds a changelog fragment towncrier would drop. +"""Fail a pull request whose changelog fragments towncrier would mishandle. towncrier writes a fragment to CHANGELOG.md only when its name carries a fragment type configured in pyproject.toml. It never reads, and never @@ -15,25 +15,35 @@ Each file the pull request adds or changes in the fragment directory must: -- sit directly in the directory; +- sit directly in the directory, as a regular file (towncrier follows a + symbolic link when it builds, and a missing target stops the release); - be named ``..md``, with a type configured in pyproject.toml at ``--head``, and a slug of letters, digits, ``.``, ``_`` and ``-`` - (optionally after towncrier's orphan prefix ``+``); -- be UTF-8 text that is not blank; + (optionally after towncrier's orphan prefix). towncrier also consumes + other forms, such as ``.`` and ``...md``; this + repository does not use them; +- be UTF-8 text that is not blank (towncrier writes other bytes as + replacement characters, and leaves a blank fragment out); - not share towncrier's (issue, type, counter) key with another fragment, such as ``7.fixed.md`` and ``007.fixed.md``, which stops ``towncrier build`` and so the release. Files towncrier ignores (.gitkeep, README and the configured ``ignore``) are not fragments and pass. Every name this accepts is one towncrier consumes, -with the same type. Deleting a fragment always passes. +with the type the name shows. Deleting a fragment always passes. + +A pull request that changes a file the release workflow runs on +(pyproject.toml or the fragment directory; ``on.push.paths`` in +.github/workflows/versioning.yaml) also fails when its merge would leave no +typed fragment: .github/bump_version.py then has nothing to release and +exits 1. It warns, without failing, when the pull request adds no fragment, when a fragment starts with a list marker (towncrier adds the bullet, so it renders as ``- - ``), and when the directory already holds files towncrier drops. -Exit status: 0 if every changed fragment passes, 1 if any fails, 2 on a usage -or git error. Standard library only. +Exit status: 0 if the pull request passes, 1 if it fails, 2 on a usage, +configuration or git error. Standard library only. """ import argparse @@ -46,6 +56,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from bump_version import ( # noqa: E402 TowncrierConfig, + annotation, is_ignored, parse_fragment_name, parse_towncrier_config, @@ -53,24 +64,38 @@ SLUG = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") LIST_MARKER = re.compile(r"[-*+]\s") +REGULAR_FILE_MODES = ("100644", "100755") +SYMLINK_MODE = "120000" +# Files, besides the fragment directory, whose changes start the release +# workflow: on.push.paths in .github/workflows/versioning.yaml. +RELEASE_TRIGGER_FILES = ("pyproject.toml",) @dataclass class Report: - errors: list[tuple[str, str]] = field(default_factory=list) + errors: list[tuple[str | None, str]] = field(default_factory=list) warnings: list[tuple[str | None, str]] = field(default_factory=list) def name_problem(name: str, config: TowncrierConfig) -> str | None: """Why a fragment's name is not ``..md``, or None if it is.""" - if not name.endswith(".md"): - return "does not end in .md" - slug, dot, fragment_type = name.removesuffix(".md").rpartition(".") - if not dot or fragment_type not in config.types: + parsed = parse_fragment_name(name, config.types) + if parsed is None: + if config.strict: + return ( + "has no towncrier type, and towncrier build stops at it because " + "[tool.towncrier] sets `ignore`" + ) return ( "has no towncrier type, so towncrier never writes it to CHANGELOG.md " "and never deletes it" ) + slug, dot, shown = name.removesuffix(".md").rpartition(".") + if not name.endswith(".md") or not dot or shown != parsed[1]: + return ( + f"is a name towncrier reads as a `{parsed[1]}` fragment, but it is " + "not in this repository's form" + ) if config.orphan_prefix: slug = slug.removeprefix(config.orphan_prefix) if not SLUG.fullmatch(slug): @@ -86,7 +111,11 @@ def content_problems(data: bytes) -> tuple[str | None, str | None]: try: text = data.decode("utf-8") except UnicodeDecodeError: - return "is not UTF-8 text, so towncrier cannot read it", None + return ( + "is not UTF-8 text; towncrier would write it to CHANGELOG.md with " + "replacement characters", + None, + ) if not text.strip(): return "is empty, so towncrier leaves it out of CHANGELOG.md", None first_line = next(line for line in text.splitlines() if line.strip()) @@ -99,9 +128,13 @@ def content_problems(data: bytes) -> tuple[str | None, str | None]: def check_fragment( - path: str, data: bytes, config: TowncrierConfig + path: str, data: bytes, config: TowncrierConfig, mode: str = "100644" ) -> tuple[str | None, str | None]: - """(error, warning) for a file the pull request adds or changes.""" + """(error, warning) for a file the pull request adds or changes. + + ``mode`` is the file's git mode; ``data`` is not read unless it is a + regular file. + """ directory, _, name = path.rpartition("/") if directory != config.directory: return ( @@ -110,6 +143,18 @@ def check_fragment( ) if is_ignored(name, config): return None, None + if mode == SYMLINK_MODE: + return ( + "is a symbolic link. towncrier follows it when it builds, so a " + "missing target stops the release; commit the fragment as a file", + None, + ) + if mode not in REGULAR_FILE_MODES: + return "is not a regular file, so towncrier cannot read it", None + try: + name.encode("utf-8") + except UnicodeEncodeError: + return "has a file name that is not UTF-8", None problem = name_problem(name, config) if problem: types = ", ".join(config.types) @@ -117,72 +162,115 @@ def check_fragment( return content_problems(data) +def fragment_key(name: str, config: TowncrierConfig) -> tuple[str, str, int] | None: + """The key towncrier files a fragment under; None for an orphan or no type. + + towncrier stops with "multiple files for ..." when two fragments share one. + """ + parsed = parse_fragment_name(name, config.types) + if parsed is None or ( + config.orphan_prefix and parsed[0].startswith(config.orphan_prefix) + ): + return None + return parsed + + def git(repo: Path, *args: str) -> bytes: result = subprocess.run(["git", *args], cwd=repo, capture_output=True) if result.returncode != 0: raise RuntimeError( - f"git {' '.join(args)} failed: {result.stderr.decode().strip()}" + f"git {' '.join(args)} failed: " + f"{result.stderr.decode(errors='replace').strip()}" ) return result.stdout -def changed_files(repo: Path, base: str, head: str, directory: str) -> list[str]: - """Paths under ``directory`` that ``head`` adds or changes relative to ``base``. +def exists(repo: Path, head: str, path: str) -> bool: + result = subprocess.run( + ["git", "cat-file", "-e", f"{head}:{path}"], cwd=repo, capture_output=True + ) + return result.returncode == 0 + + +def decode_path(raw: bytes) -> str: + """A git path as text; bytes that are not UTF-8 survive as surrogates.""" + return raw.decode("utf-8", "surrogateescape") + + +def display(path: str | None) -> str | None: + """A path that is safe to print.""" + if path is None: + return None + return path.encode("utf-8", "surrogateescape").decode("utf-8", "replace") + + +def changes( + repo: Path, base: str, head: str, *paths: str +) -> list[tuple[str, str, str]]: + """(path, status, mode at head) of each change under ``paths``. Renames count as a deletion and an addition. """ out = git( repo, "diff", - "--name-status", - "--no-renames", + "--raw", "-z", + "--no-renames", + "--no-abbrev", base, head, "--", - f"{directory}/", - ).decode() - fields = out.split("\0") - paths = [] - for status, path in zip(fields[::2], fields[1::2]): - if status != "D": - paths.append(path) - return paths + *paths, + ) + fields = out.split(b"\0") + found = [] + for meta, path in zip(fields[::2], fields[1::2]): + _, mode, _, _, status = meta.decode().split() + found.append((decode_path(path), status, mode)) + return found -def fragment_key(name: str, config: TowncrierConfig) -> tuple[str, str, int] | None: - """The key towncrier files a fragment under; None for an orphan or no type. - - towncrier stops with "multiple files for ..." when two fragments share one. - """ - parsed = parse_fragment_name(name, config.types) - if parsed is None or ( - config.orphan_prefix and parsed[0].startswith(config.orphan_prefix) - ): - return None - return parsed +def tree_entries(repo: Path, head: str, directory: str) -> list[tuple[str, str]]: + """(name, git object type) of each entry directly in ``directory``.""" + out = git(repo, "ls-tree", "-z", head, "--", f"{directory}/") + entries = [] + for line in filter(None, out.split(b"\0")): + meta, _, path = line.partition(b"\t") + entries.append((decode_path(path).rpartition("/")[2], meta.split()[1].decode())) + return entries def check(repo: Path, base: str, head: str) -> Report: + if exists(repo, head, "towncrier.toml"): + raise ValueError( + "towncrier.toml exists, and towncrier reads it instead of " + "pyproject.toml; this check reads only pyproject.toml" + ) config = parse_towncrier_config( git(repo, "show", f"{head}:pyproject.toml").decode() ) report = Report() - changed = changed_files(repo, base, head, config.directory) - tree = git(repo, "ls-tree", "--name-only", "-z", head, "--", f"{config.directory}/") - names = [ - path.rpartition("/")[2] for path in filter(None, tree.decode().split("\0")) - ] + directory = config.directory + changed = changes(repo, base, head, f"{directory}/", *RELEASE_TRIGGER_FILES) + in_directory = { + path: mode + for path, status, mode in changed + if status != "D" and path.startswith(f"{directory}/") + } + entries = tree_entries(repo, head, directory) keys = {} - for name in names: + for name, _ in entries: if not is_ignored(name, config): keys.setdefault(fragment_key(name, config), []).append(name) - added_fragment = False - for path in changed: - error, warning = check_fragment( - path, git(repo, "show", f"{head}:{path}"), config - ) + + touched_fragment = False + for path, mode in in_directory.items(): name = path.rpartition("/")[2] + data = ( + git(repo, "show", f"{head}:{path}") if mode in REGULAR_FILE_MODES else b"" + ) + error, warning = check_fragment(path, data, config, mode) key = fragment_key(name, config) if not error and key is not None and len(keys.get(key, [])) > 1: others = ", ".join(other for other in keys[key] if other != name) @@ -195,12 +283,13 @@ def check(repo: Path, base: str, head: str) -> Report: report.errors.append((path, error)) elif warning: report.warnings.append((path, warning)) - if not error and not is_ignored(name, config): - added_fragment = True - for name in names: - path = f"{config.directory}/{name}" + if path.rpartition("/")[0] != directory or not is_ignored(name, config): + touched_fragment = True + + for name, _ in entries: + path = f"{directory}/{name}" touched = any( - other == path or other.startswith(f"{path}/") for other in changed + other == path or other.startswith(f"{path}/") for other in in_directory ) if touched or is_ignored(name, config): continue @@ -213,32 +302,41 @@ def check(repo: Path, base: str, head: str) -> Report: "or delete it separately.", ) ) - if not added_fragment: + + has_typed_fragment = any( + kind == "blob" + and not is_ignored(name, config) + and parse_fragment_name(name, config.types) is not None + for name, kind in entries + ) + if changed and not has_typed_fragment and not report.errors: + triggers = sorted( + { + path if path in RELEASE_TRIGGER_FILES else f"{directory}/" + for path, _, _ in changed + } + ) + report.errors.append( + ( + None, + f"This pull request changes {' and '.join(triggers)}, so the " + "release workflow (.github/workflows/versioning.yaml) runs when " + "it merges, but the merged tree has no changelog fragment with " + "a towncrier type. .github/bump_version.py then has nothing to " + f"release and exits 1. Add {directory}/..md.", + ) + ) + elif not touched_fragment: report.warnings.append( ( None, - f"This pull request adds no changelog fragment to {config.directory}/. " + f"This pull request adds no changelog fragment to {directory}/. " "Every pull request needs one (docs/engineering/skills/github-prs.md).", ) ) return report -def escape_data(text: str) -> str: - return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") - - -def escape_property(text: str) -> str: - return escape_data(text).replace(":", "%3A").replace(",", "%2C") - - -def annotation(level: str, path: str | None, message: str) -> str: - """A GitHub Actions workflow command that annotates the file.""" - location = f" file={escape_property(path)}" if path else "" - text = f"{path} {message}" if path else message - return f"::{level}{location}::{escape_data(text)}" - - def main(argv=None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--repo", type=Path, default=Path(".")) @@ -248,16 +346,16 @@ def main(argv=None) -> int: try: report = check(args.repo, args.base, args.head) except (KeyError, RuntimeError, ValueError) as error: - print(f"::error::{escape_data(str(error))}") + print(annotation("error", None, str(error))) return 2 for path, message in report.warnings: - print(annotation("warning", path, message)) + print(annotation("warning", display(path), message)) for path, message in report.errors: - print(annotation("error", path, message)) + print(annotation("error", display(path), message)) if report.errors: print( - f"{len(report.errors)} changelog fragment(s) would be left out of " - "CHANGELOG.md. See docs/engineering/skills/github-prs.md.", + f"{len(report.errors)} changelog problem(s). " + "See docs/engineering/skills/github-prs.md.", file=sys.stderr, ) return 1 diff --git a/docs/engineering/skills/github-prs.md b/docs/engineering/skills/github-prs.md index e901ff8c60..f0f78fa42a 100644 --- a/docs/engineering/skills/github-prs.md +++ b/docs/engineering/skills/github-prs.md @@ -47,10 +47,13 @@ these types. It never reads, and never deletes, a file without one, such as a file never triggers a release. The **Changelog fragments** check (`.github/workflows/pr_changelog.yaml`, which runs `.github/check_changelog_fragments.py`) fails a PR that adds or changes a file -in `changelog.d/` that is not named `..md`, is empty, sits in a -subdirectory, or shares towncrier's key with another fragment (such as -`7.fixed.md` and `007.fixed.md`, which stops the release). It warns when a PR -adds no fragment or a fragment starts with a list marker. +in `changelog.d/` that is not named `..md`, is empty, is not UTF-8, +is a symbolic link, sits in a subdirectory, or shares towncrier's key with +another fragment (such as `7.fixed.md` and `007.fixed.md`, which stops the +release). It also fails a PR that changes `pyproject.toml` or `changelog.d/` +when its merge would leave no typed fragment: those changes start the release +workflow, which would have nothing to release. It warns when a PR adds no +fragment or a fragment starts with a list marker. Write the fragment as a short sentence describing the user-visible or maintainer-visible change. towncrier adds the bullet itself, so do not start diff --git a/policyengine_uk/tests/code_health/test_changelog_fragments.py b/policyengine_uk/tests/code_health/test_changelog_fragments.py index a0f5110d3d..422fe65e5c 100644 --- a/policyengine_uk/tests/code_health/test_changelog_fragments.py +++ b/policyengine_uk/tests/code_health/test_changelog_fragments.py @@ -4,27 +4,34 @@ name carries a type configured in pyproject.toml; it never reads or deletes any other file there, and leaves an empty fragment out. ``.github/bump_version.py`` counts fragments for the version bump and -``.github/check_changelog_fragments.py`` fails pull requests that add ones -towncrier would drop. These tests hold them to: +``.github/check_changelog_fragments.py`` fails pull requests whose fragments +towncrier would mishandle. These tests hold them to: -1. Config: the fragment types, directory and orphan prefix bump_version reads - from pyproject.toml equal towncrier's own, for this repository and for - each way towncrier can be configured (differential). +1. Config: the fragment types, directory, orphan prefix and strictness + bump_version reads from pyproject.toml equal towncrier's own, for this + repository and for each way towncrier can be configured (differential). + It refuses the configurations it would read differently. 2. Names: for any file name, bump_version's (issue, type, counter) equals towncrier's ``parse_newfragment_basename`` (differential). 3. Directories: the files bump_version counts are exactly the fragments - towncrier's ``find_fragments`` consumes, with the same types, and it calls - a file untyped exactly when towncrier's strict mode rejects the directory - (differential, every configuration in 1). + towncrier's ``find_fragments`` consumes, with the same types; it calls a + file untyped exactly when towncrier's strict mode rejects the directory; + and ``infer_bump`` stops exactly when ``towncrier build`` would stop or + has nothing to write (differential, every configuration in 1). 4. The check never accepts a name towncrier drops, and every name it accepts - towncrier consumes with the type the name shows. + towncrier consumes with the type the name shows and no counter, also when + a type is called ``md``. It says a name has no type only when towncrier + reads none. 5. Rendering, with the real ``towncrier build``: each fragment the check - accepts appears once, with one bullet; each it rejects is missing from - the output; a fragment it warns about starts with ``- - ``. + accepts appears once, with one bullet; a fragment it warns about starts + with ``- - ``; each one it rejects is left out, or written with + replacement characters. 6. On a real git repository the check fails exactly the files a pull request - adds or changes that towncrier would drop or that would stop the build, - passes deletions and ignored names, and only warns about files it does not - change and a pull request without a fragment. + adds or changes that towncrier would mishandle or that would stop the + build, including a clash with a fragment already on main and a symbolic + link; passes deletions and ignored names; only warns about files it does + not change and a pull request without a fragment; and fails a pull request + that starts the release workflow with no typed fragment to release. 7. The version bump depends only on typed fragments (breaking is major, added or removed minor, any other type patch). Untyped files never change it. With no typed fragment the script exits 1 and leaves the version alone. @@ -34,6 +41,7 @@ import importlib.util import io import os +import re import shutil import string import subprocess @@ -51,7 +59,7 @@ find_fragments, parse_newfragment_basename, ) -from towncrier._settings.load import load_config_from_options, parse_toml +from towncrier._settings.load import load_config_from_options REPO_ROOT = Path(__file__).resolve().parents[3] GITHUB_DIR = REPO_ROOT / ".github" @@ -97,6 +105,11 @@ def load(name, path): '[tool.towncrier]\ndirectory = "news"\ntemplate = "news/template.md"\n' 'orphan_prefix = "~"\n' ), + "package from the project name": '[project]\nname = "demo"\n\n[tool.towncrier]\n', + "a type called md": ( + '[tool.towncrier]\ndirectory = "news"\n' + "[tool.towncrier.fragment.fixed]\n[tool.towncrier.fragment.md]\n" + ), } NAME_PART = st.one_of( @@ -120,6 +133,8 @@ def load(name, path): "readme", "README", "gitkeep", + "gitignore", + "keep", "tmp", "NOTES", "txt", @@ -130,6 +145,29 @@ def load(name, path): ) NAMES = st.lists(NAME_PART, min_size=1, max_size=5).map(".".join) FILE_NAMES = NAMES.filter(lambda name: name not in ("", ".", "..")) +# Whole names towncrier skips, or nearly: drawn often enough to tell a wrong +# ignore list apart. +DIRECTORY_NAMES = st.one_of( + FILE_NAMES, + st.sampled_from( + [ + ".gitignore", + ".gitkeep", + ".keep", + "README", + "readme.md", + "README.rst", + "Readme.MD", + "readme.txt", + "NOTES.txt", + "notes.txt", + "x.tmp", + "template.md", + "keep", + "gitignore", + ] + ), +) SLUGS = st.from_regex(r"\+?[A-Za-z0-9][A-Za-z0-9._-]{0,20}", fullmatch=True) @@ -141,17 +179,25 @@ def towncrier_parse(name, types): # 1. Config +def write_project(root, text): + """A project directory towncrier can load ``text`` from.""" + (root / "pyproject.toml").write_text(text) + if "template" in text: + (root / "news").mkdir(exist_ok=True) + (root / "news" / "template.md").write_text("") + return load_config_from_options(str(root), None) + + @pytest.mark.parametrize("text", CONFIGS.values(), ids=CONFIGS.keys()) def test_config_matches_towncrier(text, tmp_path): - (tmp_path / "news").mkdir() - (tmp_path / "news" / "template.md").write_text("") - theirs = parse_toml(str(tmp_path), tomllib.loads(text)) - ours = bump.parse_towncrier_config(text) + base, theirs = write_project(tmp_path, text) + ours = bump.load_towncrier_config(tmp_path / "pyproject.toml") assert ours.types == tuple(theirs.types) assert ours.orphan_prefix == theirs.orphan_prefix + assert ours.strict == (theirs.ignore is not None) assert (tmp_path / ours.directory).resolve() == Path( - FragmentsPath(str(tmp_path), theirs)("") + FragmentsPath(base, theirs)("") ).resolve() @@ -165,6 +211,33 @@ def test_a_pyproject_without_towncrier_is_an_error(): bump.parse_towncrier_config('[project]\nname = "x"\n') +@pytest.mark.parametrize( + "text, option", + [ + ( + '[tool.towncrier]\ndirectory = "news"\n' + '[[tool.towncrier.section]]\nname = "Web"\npath = "web"\n', + "section", + ), + ( + '[tool.towncrier]\ndirectory = "news"\nissue_pattern = "[0-9]+"\n', + "issue_pattern", + ), + ], +) +def test_config_bump_version_would_misread_is_refused(text, option): + with pytest.raises(ValueError, match=option): + bump.parse_towncrier_config(text) + + +def test_a_towncrier_toml_is_refused(tmp_path): + (tmp_path / "pyproject.toml").write_text(PYPROJECT) + (tmp_path / "towncrier.toml").write_text('[tool.towncrier]\ndirectory = "news"\n') + + with pytest.raises(ValueError, match="towncrier.toml"): + bump.load_towncrier_config(tmp_path / "pyproject.toml") + + # 2. Names @@ -175,6 +248,7 @@ def test_name_parsing_matches_towncrier(name): CONFIG.types, bump.TOWNCRIER_DEFAULT_TYPES, bump.parse_towncrier_config(CONFIGS["fragment tables and ignore"]).types, + bump.parse_towncrier_config(CONFIGS["a type called md"]).types, ): assert bump.parse_fragment_name(name, types) == towncrier_parse(name, types) @@ -182,22 +256,30 @@ def test_name_parsing_matches_towncrier(name): # 3. Directories +def quiet_bump(directory, config): + """(infer_bump's result or exit code, what it printed).""" + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()): + try: + result = bump.infer_bump(directory, config) + except SystemExit as stop: + result = stop.code + return result, out.getvalue() + + @pytest.mark.parametrize("text", CONFIGS.values(), ids=CONFIGS.keys()) @settings(max_examples=60, deadline=None) -@given(names=st.lists(FILE_NAMES, max_size=8, unique_by=str.lower)) +@given(names=st.lists(DIRECTORY_NAMES, max_size=8, unique_by=str.lower)) def test_counted_fragments_are_the_ones_towncrier_consumes(text, names): - config = bump.parse_towncrier_config(text) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - (root / "pyproject.toml").write_text(text) + base, theirs = write_project(root, text) + config = bump.load_towncrier_config(root / "pyproject.toml") directory = root / config.directory - directory.mkdir(parents=True) - if "template" in text: - (root / "news" / "template.md").write_text("") + directory.mkdir(parents=True, exist_ok=True) for name in names: if not (directory / name).exists(): (directory / name).write_text("A note.\n") - base, theirs = load_config_from_options(str(root), None) try: consumed = find_fragments(base, theirs, strict=False)[1] except ValueError: @@ -208,26 +290,44 @@ def test_counted_fragments_are_the_ones_towncrier_consumes(text, names): assert sorted((path.name, kind) for path, kind in typed) == sorted( (Path(path).name, kind) for path, kind in consumed ) + # towncrier check, which is always strict. try: find_fragments(base, theirs, strict=True) towncrier_rejects = False except ClickException: towncrier_rejects = True assert towncrier_rejects == bool(untyped) + # towncrier build, which is strict only when `ignore` is configured. + build_stops = towncrier_rejects and theirs.ignore is not None + result, printed = quiet_bump(directory, config) + if build_stops: + assert result == 1 and printed.count("::error file=") == len(untyped) + elif not consumed: + assert result == 1 and "::error" not in printed + else: + assert result == bump.bump_for_types(kind for _, kind in consumed) # 4. The check's names +MD_CONFIG = bump.parse_towncrier_config(CONFIGS["a type called md"]) + + +@pytest.mark.parametrize("config", [CONFIG, MD_CONFIG], ids=["repository", "md type"]) @settings(max_examples=500, deadline=None) -@given(FILE_NAMES) -def test_the_check_never_accepts_a_name_towncrier_drops(name): - parsed = towncrier_parse(name, CONFIG.types) - if check.name_problem(name, CONFIG) is None: +@given(name=FILE_NAMES) +def test_the_check_never_accepts_a_name_towncrier_drops(config, name): + parsed = towncrier_parse(name, config.types) + problem = check.name_problem(name, config) + if problem is None: assert parsed is not None - assert parsed[1] == name.removesuffix(".md").rpartition(".")[2] + _, kind, counter = parsed + assert name.endswith(f".{kind}.md") and counter == 0 if parsed is None: - assert check.name_problem(name, CONFIG) is not None + assert problem is not None + # "No type" is said only of names towncrier reads no type from. + assert ("has no towncrier type" in (problem or "")) == (parsed is None) @settings(max_examples=300, deadline=None) @@ -260,6 +360,23 @@ def test_names_the_check_rejects(name): assert check.name_problem(name, CONFIG) is not None +@pytest.mark.parametrize( + "name", ["2314.fixed.1.md", "x.fixed", "x.fixed.txt", "x.fixed.md.md"] +) +def test_other_forms_towncrier_consumes_are_named_as_such(name): + # `towncrier create` writes 2314.fixed.1.md when 2314.fixed.md exists. + assert towncrier_parse(name, CONFIG.types)[1] == "fixed" + assert "towncrier reads as a `fixed` fragment" in check.name_problem(name, CONFIG) + + +def test_an_md_type_is_not_mistaken_for_the_extension(): + # With a type called md, towncrier reads x.fixed.md as an md fragment. + assert towncrier_parse("x.fixed.md", MD_CONFIG.types)[1] == "md" + assert check.name_problem("x.fixed.md", MD_CONFIG) is not None + assert check.name_problem("x.md.md", MD_CONFIG) is None + assert check.name_problem("x.fixed.fixed.md", MD_CONFIG) is not None + + @pytest.mark.parametrize( "data, error, warning", [ @@ -284,20 +401,24 @@ def test_fragment_content(data, error, warning): def test_rendering_agrees_with_the_check(tmp_path): - fragments = { - "a.fixed.md": b"Fixed a.\n", - "b.added.md": b"Added b.\n", - "2314.removed.md": b"Removed c.\n", - "+orphan.changed.md": b"Changed d.\n", - "untyped.md": b"Untyped note.\n", - "empty.fixed.md": b"", - "blank.added.md": b" \n", - "bullet.changed.md": b"- Bulleted note.\n", + # name: (content, the check's verdict, the bullet towncrier writes) + cases = { + "a.fixed.md": (b"Fixed a.\n", "ok", "- Fixed a."), + "b.added.md": (b"Added b.\n", "ok", "- Added b."), + "2314.removed.md": (b"Removed c.\n", "ok", "- Removed c."), + "+orphan.changed.md": (b"Changed d.\n", "ok", "- Changed d."), + "bullet.changed.md": (b"- Bulleted.\n", "warning", "- - Bulleted."), + # Rejected, and towncrier leaves each out without saying so. + "untyped.md": (b"Untyped note.\n", "error", None), + "empty.fixed.md": (b"", "error", None), + "blank.breaking.md": (b" \n", "error", None), + # Rejected, and towncrier writes it with replacement characters. + "binary.fixed.md": (b"Fixed \xff thing.\n", "error", "- Fixed \ufffd thing."), } (tmp_path / "pyproject.toml").write_text(PYPROJECT) (tmp_path / "CHANGELOG.md").write_text("# Changelog\n") (tmp_path / "changelog.d").mkdir() - for name, data in fragments.items(): + for name, (data, _, _) in cases.items(): (tmp_path / "changelog.d" / name).write_bytes(data) draft = subprocess.run( @@ -317,26 +438,32 @@ def test_rendering_agrees_with_the_check(tmp_path): cwd=tmp_path, capture_output=True, text=True, + encoding="utf-8", check=True, ).stdout - bullets = [line for line in draft.splitlines() if line.startswith("- ")] - - for name, data in fragments.items(): + headings = { + entry["directory"]: entry["name"] + for entry in tomllib.loads(PYPROJECT)["tool"]["towncrier"]["type"] + } + written, heading = {}, None + for line in draft.splitlines(): + if line.startswith("### "): + heading = line.removeprefix("### ") + elif line.startswith("- "): + written.setdefault(heading, []).append(line) + + expected = {} + for name, (data, verdict, bullet) in cases.items(): error, warning = check.check_fragment(f"changelog.d/{name}", data, CONFIG) - text = data.decode().strip() - if error: - # Dropped: an untyped name, or nothing to write. - assert not text or text not in draft, name - else: - assert bullets.count(f"- {text}") == 1, name - assert text.startswith("- ") == bool(warning), name - # Only accepted fragments, and the warned one, produce bullets. - accepted = [ - name - for name, data in fragments.items() - if not check.check_fragment(f"changelog.d/{name}", data, CONFIG)[0] - ] - assert len(bullets) == len(accepted) + found = "error" if error else "warning" if warning else "ok" + assert found == verdict, name + if bullet: + kind = towncrier_parse(name, CONFIG.types)[1] + expected.setdefault(headings[kind], []).append(bullet) + assert {heading: sorted(lines) for heading, lines in written.items()} == { + heading: sorted(lines) for heading, lines in expected.items() + } + assert "Untyped note." not in draft # 6. The check on a real git repository @@ -394,6 +521,9 @@ def commit(repo, files=(), delete=(), moves=()): return base, git(repo, "rev-parse", "HEAD") +ANNOTATION = re.compile(r"::(error|warning)(?: file=(.*?))?::(.*)") + + def run_check(repo, base, head): result = subprocess.run( [ @@ -409,13 +539,16 @@ def run_check(repo, base, head): capture_output=True, text=True, ) - annotations = {"error": set(), "warning": set(), "unlocated": []} + annotations = {"error": set(), "warning": set(), "unlocated": [], "text": {}} for line in result.stdout.splitlines(): - level, _, rest = line.removeprefix("::").partition(" file=") - if rest: - annotations[level].add(rest.split("::", 1)[0]) - elif line.startswith("::"): + command = ANNOTATION.match(line) + assert command, line + level, path, text = command.groups() + if path is None: annotations["unlocated"].append(line) + else: + annotations[level].add(path) + annotations["text"][path] = text return result.returncode, annotations @@ -455,6 +588,12 @@ def test_the_check_fails_exactly_the_fragments_towncrier_would_drop(repo): } assert found["warning"] == {"changelog.d/bullet.changed.md", "changelog.d/stale.md"} assert found["unlocated"] == [] + text = found["text"] + assert "has no towncrier type" in text["changelog.d/1234.md"] + assert "is empty" in text["changelog.d/empty.added.md"] + assert "replacement characters" in text["changelog.d/binary.changed.md"] + assert "not directly in" in text["changelog.d/sub/nested.fixed.md"] + assert "same towncrier key as 7.removed.md" in text["changelog.d/007.removed.md"] def test_deleting_untyped_files_and_adding_a_fragment_passes(repo): @@ -467,7 +606,7 @@ def test_deleting_untyped_files_and_adding_a_fragment_passes(repo): code, found = run_check(repo, base, head) assert code == 0 - assert found == {"error": set(), "warning": set(), "unlocated": []} + assert (found["error"], found["warning"], found["unlocated"]) == (set(), set(), []) def test_renaming_a_fragment_to_an_untyped_name_fails(repo): @@ -481,6 +620,165 @@ def test_renaming_a_fragment_to_an_untyped_name_fails(repo): assert found["error"] == {"changelog.d/kept.md"} +def test_a_clash_with_a_fragment_already_on_main_fails(repo): + commit(repo, files={"changelog.d/7.removed.md": "Seven.\n"}) + base, head = commit(repo, files={"changelog.d/007.removed.md": "Again.\n"}) + + code, found = run_check(repo, base, head) + + assert code == 1 + assert found["error"] == {"changelog.d/007.removed.md"} + + +def test_an_ignored_name_in_a_subdirectory_fails(repo): + base, head = commit(repo, files={"changelog.d/sub/README.md": "About.\n"}) + + code, found = run_check(repo, base, head) + + assert code == 1 + assert found["error"] == {"changelog.d/sub/README.md"} + + +def test_a_pull_request_whose_only_fragment_fails_gets_one_error(repo): + base, head = commit(repo, files={"changelog.d/1234.md": "Untyped.\n"}) + + code, found = run_check(repo, base, head) + + assert code == 1 + assert found["error"] == {"changelog.d/1234.md"} + assert found["unlocated"] == [] + + +def test_an_ignored_file_is_not_a_fragment(repo): + base, head = commit(repo, files={"changelog.d/README.md": "About.\n"}) + + code, found = run_check(repo, base, head) + + assert code == 0 + assert found["error"] == set() + assert len(found["unlocated"]) == 1 + assert "adds no changelog fragment" in found["unlocated"][0] + + +@pytest.mark.parametrize( + "target", ["../missing-note.md", "stale.md"], ids=["dangling", "resolving"] +) +def test_a_symbolic_link_fails(repo, target): + # towncrier follows the link when it builds; a missing target stops it. + (repo / "changelog.d" / "note.fixed.md").symlink_to(target) + base, head = commit(repo) + + code, found = run_check(repo, base, head) + + assert code == 1 + assert found["error"] == {"changelog.d/note.fixed.md"} + assert "symbolic link" in found["text"]["changelog.d/note.fixed.md"] + + +def test_turning_a_fragment_into_a_symbolic_link_fails(repo): + (repo / "changelog.d" / "kept.fixed.md").unlink() + (repo / "changelog.d" / "kept.fixed.md").symlink_to("stale.md") + base, head = commit(repo) + + code, found = run_check(repo, base, head) + + assert code == 1 + assert found["error"] == {"changelog.d/kept.fixed.md"} + assert "symbolic link" in found["text"]["changelog.d/kept.fixed.md"] + + +def test_a_submodule_entry_fails(repo): + base = git(repo, "rev-parse", "HEAD") + entry = f"160000,{base},changelog.d/vendor.fixed.md" + git(repo, "update-index", "--add", "--cacheinfo", entry) + git(repo, "commit", "-qm", "a submodule entry") + + code, found = run_check(repo, base, git(repo, "rev-parse", "HEAD")) + + assert code == 1 + assert found["error"] == {"changelog.d/vendor.fixed.md"} + assert "not a regular file" in found["text"]["changelog.d/vendor.fixed.md"] + + +def test_a_file_name_that_is_not_utf8_fails(repo): + # Written straight to the index: macOS will not create such a file. + base = git(repo, "rev-parse", "HEAD") + blob = subprocess.run( + ["git", "hash-object", "-w", "--stdin"], + cwd=repo, + env=GIT_ENV, + input=b"Fixed.\n", + capture_output=True, + check=True, + ).stdout.strip() + entry = b"100644," + blob + b",changelog.d/caf\xe9.fixed.md" + subprocess.run( + [b"git", b"update-index", b"--add", b"--cacheinfo", entry], + cwd=repo, + env=GIT_ENV, + check=True, + ) + git(repo, "commit", "-qm", "a latin-1 file name") + + code, found = run_check(repo, base, git(repo, "rev-parse", "HEAD")) + + assert code == 1 + [path] = found["error"] + assert path.startswith("changelog.d/caf") and path.endswith(".fixed.md") + + +def test_starting_a_release_with_nothing_to_release_fails(repo): + base, head = commit( + repo, + files={"pyproject.toml": PYPROJECT + "\n# A dependency change.\n"}, + delete=["changelog.d/kept.fixed.md"], + ) + + code, found = run_check(repo, base, head) + + assert code == 1 + assert found["error"] == set() + [error] = [line for line in found["unlocated"] if line.startswith("::error")] + assert "pyproject.toml" in error and "changelog.d/" in error + + +def test_changing_pyproject_with_a_fragment_waiting_only_warns(repo): + base, head = commit( + repo, files={"pyproject.toml": PYPROJECT + "\n# A dependency change.\n"} + ) + + code, found = run_check(repo, base, head) + + assert code == 0 + assert len(found["unlocated"]) == 1 + assert "adds no changelog fragment" in found["unlocated"][0] + + +def test_a_change_that_starts_no_release_only_warns(repo): + commit(repo, delete=["changelog.d/kept.fixed.md"]) + base, head = commit(repo, files={"code.py": "x = 3\n"}) + + code, found = run_check(repo, base, head) + + assert code == 0 + assert len(found["unlocated"]) == 1 + assert "adds no changelog fragment" in found["unlocated"][0] + + +def test_the_release_triggers_are_the_ones_the_check_knows(): + import yaml + + workflow = yaml.safe_load( + (GITHUB_DIR / "workflows" / "versioning.yaml").read_text(encoding="utf-8") + ) + # PyYAML reads the key "on" as True. + triggers = workflow.get("on", workflow.get(True)) + + assert sorted(triggers["push"]["paths"]) == sorted( + [f"{CONFIG.directory}/**", *check.RELEASE_TRIGGER_FILES] + ) + + def test_a_pull_request_without_a_fragment_only_warns(repo): base, head = commit(repo, files={"code.py": "x = 2\n"}) @@ -522,6 +820,15 @@ def test_a_missing_towncrier_config_is_a_usage_error(repo): assert code == 2 +def test_a_towncrier_toml_is_a_usage_error(repo): + base, head = commit(repo, files={"towncrier.toml": "[tool.towncrier]\n"}) + + code, found = run_check(repo, base, head) + + assert code == 2 + assert "towncrier.toml" in found["unlocated"][0] + + def test_annotations_escape_github_command_characters(): assert check.annotation("error", "a,b:c.md", "is 50%\nwrong") == ( "::error file=a%2Cb%3Ac.md::a,b:c.md is 50%25%0Awrong" @@ -568,18 +875,31 @@ def test_the_bump_depends_only_on_typed_fragments(typed, untyped): directory = Path(tmp) for name in [name for name, _ in kinds] + untyped_files: (directory / name).write_text("A note.\n") - out = io.StringIO() - with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()): - if kinds: - result = bump.infer_bump(directory, CONFIG) - else: - with pytest.raises(SystemExit) as exit_info: - bump.infer_bump(directory, CONFIG) - if kinds: - assert result == expected_bump(kind for _, kind in kinds) - else: - assert exit_info.value.code == 1 - assert out.getvalue().count("::warning file=") == len(untyped_files) + result, printed = quiet_bump(directory, CONFIG) + + assert result == (expected_bump(kind for _, kind in kinds) if kinds else 1) + assert printed.count("::warning file=") == len(untyped_files) + + +def test_bump_warnings_escape_the_file_name(tmp_path): + (tmp_path / "a,b:c.md").write_text("Untyped.\n") + (tmp_path / "x.fixed.md").write_text("Fixed.\n") + + result, printed = quiet_bump(tmp_path, CONFIG) + + assert result == "patch" + assert "::warning file=changelog.d/a%2Cb%3Ac.md::" in printed + + +def test_untyped_files_stop_the_bump_when_towncrier_build_is_strict(tmp_path): + strict = bump.parse_towncrier_config(CONFIGS["fragment tables and ignore"]) + (tmp_path / "x.fix.md").write_text("Fixed.\n") + (tmp_path / "1005.md").write_text("Untyped.\n") + + result, printed = quiet_bump(tmp_path, strict) + + assert result == 1 + assert "::error file=news/1005.md::" in printed def run_bump_script(tmp_path, fragments): @@ -626,3 +946,12 @@ def test_untyped_files_do_not_change_the_bump(tmp_path): major, minor, _ = (int(part) for part in before.split(".")) assert after == f"{major}.{minor + 1}.0" assert "::warning file=changelog.d/1005.md::" in result.stdout + + +def test_the_script_stops_at_a_towncrier_toml(tmp_path): + (tmp_path / "towncrier.toml").write_text("[tool.towncrier]\n") + + result, before, after = run_bump_script(tmp_path, {"x.fixed.md": "Fixed.\n"}) + + assert result.returncode != 0 + assert after == before