From 617bee622772b0fa184ed4b1ab6b200ab12360a2 Mon Sep 17 00:00:00 2001 From: Andrew Grimberg Date: Mon, 2 Nov 2026 08:49:43 -0800 Subject: [PATCH] Feat(release): Add reno release note snippets The draft release body is rebuilt from .github/release-drafter.yml on every push to main, so hand-written upgrade guidance was wiped by the next merge and operator-facing changes had nowhere to live. Adopt reno (dev dependency, in its own 'releasenotes' group so CI can install it without Home Assistant). Notes live in releasenotes/notes/ and are written in Markdown. scripts/render_release_notes.py uses reno's loader to select the notes added since the latest release tag and renders them as Markdown, because 'reno report' only emits RST. The release-drafter workflow now checks out full history, renders the notes into a generated copy of the config and loads it with the file: scheme. The downloads badge moves from 'template' to 'header' so the notes land between the badge and the changelog categories; with no notes the body is byte-for-byte what it was before. A new Release Notes PR check runs 'reno lint' when releasenotes/ changes and fails a 'breaking-change' pull request that adds no note. The first note covers the HA 2026.11 calendar trigger offset change and Marketplace readiness for v3.10.0. Closes #819 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Andrew Grimberg --- .github/release-drafter.yml | 7 +- .github/workflows/release-drafter.yaml | 28 +- .github/workflows/release-notes.yaml | 85 ++++++ .gitignore | 2 + AGENTS.md | 9 + README.md | 21 ++ REUSE.toml | 6 + pyproject.toml | 6 + releasenotes/config.yaml | 48 ++++ ...-2026-11-marketplace-ce012c585e03233b.yaml | 19 ++ scripts/__init__.py | 3 + scripts/render_release_notes.py | 244 ++++++++++++++++++ tests/unit/test_render_release_notes.py | 151 +++++++++++ uv.lock | 88 ++++++- 14 files changed, 713 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release-notes.yaml create mode 100644 releasenotes/config.yaml create mode 100644 releasenotes/notes/ha-2026-11-marketplace-ce012c585e03233b.yaml create mode 100644 scripts/__init__.py create mode 100755 scripts/render_release_notes.py create mode 100644 tests/unit/test_render_release_notes.py diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 71d1cb5..bdd3736 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -85,10 +85,15 @@ autolabeler: - label: "build" title: - '/^build(\([^)]+\))?:/i' +# The release body is header + template. The release-drafter workflow +# appends the reno notes from releasenotes/notes/ to the header (see +# scripts/render_release_notes.py), placing them between the downloads +# badge and $CHANGES. '|+' keeps the blank line that separates them. # yamllint disable rule:line-length -template: | +header: |+ [![Downloads for this release](https://img.shields.io/github/downloads/$OWNER/$REPOSITORY/v$RESOLVED_VERSION/total.svg)](https://github.com/$OWNER/$REPOSITORY/releases/v$RESOLVED_VERSION) +template: | $CHANGES ## Links diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml index 3b87336..7b7c0ca 100644 --- a/.github/workflows/release-drafter.yaml +++ b/.github/workflows/release-drafter.yaml @@ -23,7 +23,7 @@ jobs: contents: write pull-requests: read runs-on: 'ubuntu-latest' - timeout-minutes: 3 + timeout-minutes: 10 steps: # Harden the runner used by this workflow # yamllint disable-line rule:line-length @@ -31,5 +31,31 @@ jobs: with: egress-policy: 'audit' + # Full history and tags let reno find the notes added since the + # latest release tag. + # yamllint disable-line rule:line-length + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # No cache: this job holds contents: write. + # yamllint disable-line rule:line-length + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + python-version: '3.14' + enable-cache: false + + # The draft body is rebuilt on every push, so release notes live in + # releasenotes/notes/ and are rendered into the config each time. + - name: 'Render release notes into the Release Drafter config' + run: | + uv run --frozen --only-group releasenotes \ + python scripts/render_release_notes.py \ + --release-drafter-config .github/release-drafter.yml \ + --output .github/release-drafter-generated.yml + # yamllint disable-line rule:line-length - uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0 + with: + config-name: 'file:.github/release-drafter-generated.yml' diff --git a/.github/workflows/release-notes.yaml b/.github/workflows/release-notes.yaml new file mode 100644 index 0000000..01dec94 --- /dev/null +++ b/.github/workflows/release-notes.yaml @@ -0,0 +1,85 @@ +--- +# SPDX-FileCopyrightText: 2026 Andrew Grimberg +# SPDX-License-Identifier: Apache-2.0 + +name: 'Release Notes' + +# yamllint disable-line rule:truthy +on: + # 'labeled' and 'unlabeled' re-evaluate the breaking-change rule when + # the autolabeler (or a maintainer) changes the labels. + pull_request: + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled + branches: + - main + +permissions: {} + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + # Queue rather than cancel, as in autolabeler.yaml: the autolabeler + # labels a new pull request moments after it opens, and a cancelled + # run beside a passing one marks the head SHA as not successful. + cancel-in-progress: false + queue: max + +jobs: + release-notes: + name: 'Release Notes' + runs-on: 'ubuntu-latest' + permissions: + contents: read + timeout-minutes: 10 + steps: + # Harden the runner used by this workflow + # yamllint disable-line rule:line-length + - uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: 'audit' + + # reno lint scans the git history, so fetch all of it. + # yamllint disable-line rule:line-length + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: 'Find release note changes' + id: changes + run: | + # HEAD is the pull request merge commit; its first parent is + # the tip of the base branch. + added=$(git diff --name-only --diff-filter=A HEAD^1 HEAD \ + -- 'releasenotes/notes/*.yaml') + changed=$(git diff --name-only HEAD^1 HEAD -- releasenotes/) + echo "Added notes: ${added:-none}" + { + echo "added=$([ -n "$added" ] && echo true || echo false)" + echo "changed=$([ -n "$changed" ] && echo true || echo false)" + } >> "$GITHUB_OUTPUT" + + - name: 'Require a release note for breaking changes' + if: >- + contains(github.event.pull_request.labels.*.name, 'breaking-change') + && steps.changes.outputs.added != 'true' + run: | + echo "::error::Pull requests labelled 'breaking-change' must" \ + "add a release note: run 'uv run reno new ' and fill" \ + "in the upgrade section." + exit 1 + + # yamllint disable-line rule:line-length + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + if: steps.changes.outputs.changed == 'true' + with: + python-version: '3.14' + enable-cache: false + + - name: 'Lint release notes' + if: steps.changes.outputs.changed == 'true' + run: uv run --frozen --only-group releasenotes reno lint diff --git a/.gitignore b/.gitignore index e6b29cf..4350f22 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ Thumbs.db *.swp .idea/ .worktrees/ +# Written by the release-drafter workflow from .github/release-drafter.yml +.github/release-drafter-generated.yml diff --git a/AGENTS.md b/AGENTS.md index 8e3182a..fcd98d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,6 +260,15 @@ from the repository root using `uv`: - All tests must pass before pushing - New features should include appropriate test coverage +## Release Notes + +Describe user-visible changes with a [reno](https://docs.openstack.org/reno/latest/) +note in the same pull request: run `uv run reno new `, fill in +the sections that apply in Markdown, delete the rest, and check it with +`uv run reno lint`. A note is **required** for a breaking change (the +`breaking-change` label); CI fails without one. Upgrade actions belong +in `upgrade`. + ## Development Workflow Summary 1. Make changes to code diff --git a/README.md b/README.md index 6eec1f7..e0f84e5 100644 --- a/README.md +++ b/README.md @@ -923,3 +923,24 @@ All commits must pass [pre-commit](https://pre-commit.com/) checks: pre-commit install pre-commit run --all-files ``` + +### Release Notes + +Release notes live in `releasenotes/notes/` as +[reno](https://docs.openstack.org/reno/latest/) snippets, written in +Markdown. Add one in the same pull request as the change it describes: + +```bash +uv run reno new +uv run reno lint +``` + +Fill in the sections that apply (`upgrade`, `features`, `fixes`, +`deprecations` or `security`) and delete the rest. A pull request +labelled `breaking-change` must add a note. The draft GitHub release +shows every note added since the last release tag, above the list of +pull requests. To preview it: + +```bash +uv run python scripts/render_release_notes.py +``` diff --git a/REUSE.toml b/REUSE.toml index 508e4a1..f0bd6dc 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -41,3 +41,9 @@ path = ".aislop/baseline.json" precedence = "aggregate" SPDX-FileCopyrightText = "2026 Andrew Grimberg " SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = "releasenotes/notes/*.yaml" +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 Andrew Grimberg " +SPDX-License-Identifier = "Apache-2.0" diff --git a/pyproject.toml b/pyproject.toml index 7768395..f5c2fbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,12 @@ packages = ["custom_components"] [dependency-groups] dev = [ "homeassistant>=2026.10.0", + {include-group = "releasenotes"}, +] +# reno manages releasenotes/notes/; kept in its own group so CI can +# install it without Home Assistant (uv run --only-group releasenotes). +releasenotes = [ + "reno>=4.1.0", ] [tool.pytest.ini_options] diff --git a/releasenotes/config.yaml b/releasenotes/config.yaml new file mode 100644 index 0000000..bfc6e3f --- /dev/null +++ b/releasenotes/config.yaml @@ -0,0 +1,48 @@ +--- +# SPDX-FileCopyrightText: 2026 Andrew Grimberg +# SPDX-License-Identifier: Apache-2.0 + +# reno configuration. Notes are written in Markdown, not reStructuredText, +# because scripts/render_release_notes.py renders them into the GitHub +# release body. See the "Release notes" section of README.md. +default_branch: main +# Release tags are vX.Y.Z; any vX.Y.ZrcN tags fold into the final release. +collapse_pre_releases: true +# Main is the only release branch, so scan its full history. +stop_at_branch_base: false +# Section order is the order of the rendered release notes. The optional +# 'prelude' section is built into reno and always renders first. +sections: + - [upgrade, Upgrade Notes] + - [features, New Features] + - [fixes, Bug Fixes] + - [deprecations, Deprecation Notes] + - [security, Security Issues] +# Release Drafter, not reno, decides the next version number. +semver_major: [] +semver_minor: [] +semver_patch: [] +# Template used by 'reno new'. Keep only the sections that apply. +template: | + --- + # Write each item in Markdown. Delete every section you do not use, + # including this comment. + # prelude: | + # Optional release summary, rendered above all sections. + upgrade: + - | + Action an operator must take when upgrading, such as a changed + option or a minimum Home Assistant version. + features: + - | + A new capability, described from the user's point of view. + fixes: + - | + A user-visible bug that is now fixed. + deprecations: + - | + Something that still works but will be removed later, and what to + use instead. + security: + - | + A security issue that is now fixed. diff --git a/releasenotes/notes/ha-2026-11-marketplace-ce012c585e03233b.yaml b/releasenotes/notes/ha-2026-11-marketplace-ce012c585e03233b.yaml new file mode 100644 index 0000000..14dc4e0 --- /dev/null +++ b/releasenotes/notes/ha-2026-11-marketplace-ce012c585e03233b.yaml @@ -0,0 +1,19 @@ +--- +upgrade: + - | + From Home Assistant 2026.11, `calendar.event_started` and + `calendar.event_ended` triggers no longer default to + `offset_type: before`; the sign of the offset now sets the direction. + Check any YAML automation on a Rental Control calendar that uses an + offset without `offset_type`. Triggers created in the UI are not + affected. See + [Calendar Triggers in Home Assistant 2026.11]( + https://github.com/tykeal/homeassistant-rental-control#calendar-triggers-in-home-assistant-202611) + in the README. +features: + - | + Rental Control is ready for the Home Assistant Marketplace in Home + Assistant 2026.11 and later. The integration ships its own brand + images and declares its integration categories, so it installs from + **Settings** > **Marketplace** with no restart. On earlier Home + Assistant releases, install it through HACS as before. diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..75af9cb --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 Andrew Grimberg +# SPDX-License-Identifier: Apache-2.0 +"""Maintainer scripts for the repository.""" diff --git a/scripts/render_release_notes.py b/scripts/render_release_notes.py new file mode 100755 index 0000000..45b01fc --- /dev/null +++ b/scripts/render_release_notes.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Andrew Grimberg +# SPDX-License-Identifier: Apache-2.0 +"""Render reno release notes as Markdown for the GitHub release body. + +``reno report`` only emits reStructuredText, so this script uses reno's +Python API to find the notes for a release and renders them as Markdown. + +By default it renders the unreleased notes: every note added since the +most recent release tag, plus staged or modified notes in the working +copy. ``--version`` renders the notes of an existing release tag instead. + +With ``--release-drafter-config`` it writes a copy of that Release +Drafter configuration with the rendered notes appended to its +``header`` key, so they sit between the downloads badge and the +changelog categories. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from dataclasses import dataclass +from dataclasses import field +from pathlib import Path +import subprocess +import sys +import textwrap +from typing import Any +from typing import Protocol + +import yaml + +RELNOTES_DIR = "releasenotes" +WORKING_COPY = "*working-copy*" + + +class NoteLoader(Protocol): + """The subset of ``reno.loader.Loader`` this script relies on.""" + + @property + def versions(self) -> list[str]: + """Return the versions with notes, newest first.""" + + def __getitem__(self, version: str) -> list[tuple[str, str | None]]: + """Return ``(filename, sha)`` pairs for the notes of a version.""" + + def parse_note_file(self, filename: str, sha: str | None) -> dict[str, Any]: + """Return the parsed sections of one note file.""" + + +@dataclass +class RenderedNotes: + """Prelude paragraphs and section items gathered from note files.""" + + prelude: list[str] = field(default_factory=list) + sections: dict[str, list[str]] = field(default_factory=dict) + + +def select_versions( + versions: Sequence[str], tags: set[str], version: str | None +) -> list[str]: + """Pick the reno versions whose notes belong in the output. + + Without ``version`` this is every leading entry that is not a release + tag: the working copy and reno's ``-`` development + version. With ``version`` it is that version alone, if it has notes. + """ + if version is not None: + return [version] if version in versions else [] + selected = [] + for candidate in versions: + if candidate in tags: + break + selected.append(candidate) + return selected + + +def collect_notes( + loader: NoteLoader, + versions: Sequence[str], + section_names: Sequence[str], + prelude_name: str, +) -> RenderedNotes: + """Gather the prelude and section items from the selected versions.""" + notes = RenderedNotes() + for version in versions: + for filename, sha in loader[version]: + content = loader.parse_note_file(filename, sha) + prelude = content.get(prelude_name) + if isinstance(prelude, str) and prelude.strip(): + notes.prelude.append(prelude) + for name in section_names: + items = content.get(name) or [] + if isinstance(items, str): + items = [items] + notes.sections.setdefault(name, []).extend( + item for item in items if isinstance(item, str) and item.strip() + ) + return notes + + +def format_item(text: str) -> str: + """Render one note item as a Markdown bullet. + + Continuation lines are indented by two spaces so that paragraphs, + nested lists and fenced code blocks stay inside the bullet. + """ + lines = textwrap.dedent(text).strip().splitlines() + rest = [f" {line}" if line.strip() else "" for line in lines[1:]] + return "\n".join([f"- {lines[0]}", *rest]) + + +def render_markdown( + notes: RenderedNotes, sections: Sequence[tuple[str, str, int]] +) -> str: + """Render collected notes as Markdown, or ``""`` when there are none. + + ``sections`` holds ``(name, title, level)`` in output order; level 1 + sections become ``###`` headings and deeper levels nest below them. + """ + blocks = [textwrap.dedent(text).strip() for text in notes.prelude] + for name, title, level in sections: + items = notes.sections.get(name) + if not items: + continue + blocks.append(f"{'#' * (2 + level)} {title}") + blocks.append("\n".join(format_item(item) for item in items)) + return "\n\n".join(blocks) + + +def _literal_str(dumper: yaml.SafeDumper, data: str) -> yaml.ScalarNode: + """Dump multi-line strings as YAML literal blocks for readability.""" + style = "|" if "\n" in data else None + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style) + + +class _ConfigDumper(yaml.SafeDumper): + """YAML dumper that keeps multi-line strings readable.""" + + +_ConfigDumper.add_representer(str, _literal_str) + + +def build_drafter_config(base_config: str, notes_markdown: str) -> str: + """Return the Release Drafter config with notes appended to ``header``. + + The base config is returned unchanged when there are no notes. + """ + if not notes_markdown: + return base_config + config = yaml.safe_load(base_config) + header = config.get("header") or "" + if header and not header.endswith("\n\n"): + header = header.rstrip("\n") + "\n\n" + config["header"] = f"{header}{notes_markdown.rstrip()}\n\n" + dumped: str = yaml.dump( + config, + Dumper=_ConfigDumper, + sort_keys=False, + allow_unicode=True, + width=1_000_000, + ) + return ( + "---\n# Generated by scripts/render_release_notes.py; do not edit.\n" + dumped + ) + + +def git_tags(repo_root: Path) -> set[str]: + """Return the names of every tag in the repository.""" + result = subprocess.run( + ["git", "tag", "--list"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + return set(result.stdout.split()) + + +def load_release_notes(repo_root: Path, version: str | None) -> str: + """Scan the repository with reno and render the selected notes.""" + from reno import config as reno_config + from reno import loader as reno_loader + + conf = reno_config.Config(str(repo_root), RELNOTES_DIR) + sections = [(s.name, s.title, s.section_level) for s in conf.sections] + tags = git_tags(repo_root) + with reno_loader.Loader(conf) as ldr: + versions = select_versions(ldr.versions, tags, version) + notes = collect_notes( + ldr, versions, [s[0] for s in sections], conf.prelude_section_name + ) + return render_markdown(notes, sections) + + +def parse_args(argv: Sequence[str] | None) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--repo-root", + type=Path, + default=Path("."), + help="repository to scan (default: current directory)", + ) + parser.add_argument( + "--version", + help="render the notes of this release tag instead of unreleased notes", + ) + parser.add_argument( + "--release-drafter-config", + type=Path, + help="write this Release Drafter config with the notes in its header", + ) + parser.add_argument( + "--output", + type=Path, + help="file to write instead of standard output", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the renderer; return the process exit status.""" + args = parse_args(argv) + if args.version is not None and args.version not in git_tags(args.repo_root): + print(f"error: {args.version!r} is not a git tag", file=sys.stderr) + return 2 + output = load_release_notes(args.repo_root, args.version) + if args.release_drafter_config is not None: + output = build_drafter_config( + args.release_drafter_config.read_text(encoding="utf-8"), output + ) + elif output: + output += "\n" + if args.output is not None: + args.output.write_text(output, encoding="utf-8") + else: + sys.stdout.write(output) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_render_release_notes.py b/tests/unit/test_render_release_notes.py new file mode 100644 index 0000000..7daccb7 --- /dev/null +++ b/tests/unit/test_render_release_notes.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: 2026 Andrew Grimberg +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the reno-to-Markdown release note renderer (issue #819).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from scripts import render_release_notes as rrn + +SECTIONS = [ + ("upgrade", "Upgrade Notes", 1), + ("features", "New Features", 1), + ("fixes", "Bug Fixes", 1), +] +SECTION_NAMES = [name for name, _, _ in SECTIONS] +REPO_ROOT = Path(__file__).resolve().parents[2] + + +class FakeLoader: + """In-memory stand-in for ``reno.loader.Loader``.""" + + def __init__(self, notes: dict[str, dict[str, dict[str, Any]]]) -> None: + """Store ``{version: {filename: parsed content}}``.""" + self._notes = notes + + @property + def versions(self) -> list[str]: + """Return versions newest first, as reno does.""" + return list(self._notes) + + def __getitem__(self, version: str) -> list[tuple[str, str | None]]: + """Return the note files of one version.""" + return [(name, "sha") for name in self._notes[version]] + + def parse_note_file(self, filename: str, sha: str | None) -> dict[str, Any]: + """Return the parsed content of one note file.""" + for files in self._notes.values(): + if filename in files: + return files[filename] + raise KeyError(filename) + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + (None, ["*working-copy*", "v3.9.0-2"]), + ("v3.9.0", ["v3.9.0"]), + ("v3.7.0", []), + ], +) +def test_select_versions(version: str | None, expected: list[str]) -> None: + """Unreleased notes stop at the newest tag; a named tag stands alone.""" + versions = ["*working-copy*", "v3.9.0-2", "v3.9.0", "v3.8.0"] + tags = {"v3.9.0", "v3.8.0", "v3.7.0"} + + assert rrn.select_versions(versions, tags, version) == expected + + +def test_select_versions_head_on_tag_has_no_unreleased_notes() -> None: + """Right after a release, nothing is unreleased.""" + assert rrn.select_versions(["v3.9.0", "v3.8.0"], {"v3.9.0", "v3.8.0"}, None) == [] + + +def test_render_orders_sections_and_formats_multiline_items() -> None: + """Sections follow config order; continuation lines stay in the bullet.""" + loader = FakeLoader( + { + "v3.9.0-1": { + "a.yaml": { + "fixes": ["Fixed a thing.\n"], + "upgrade": [ + "First line\nsecond line.\n\n```yaml\nkey: value\n```\n" + ], + }, + "b.yaml": { + "prelude": "A summary\nparagraph.\n", + "fixes": "A single string item.\n", + }, + } + } + ) + + notes = rrn.collect_notes(loader, ["v3.9.0-1"], SECTION_NAMES, "prelude") + + assert rrn.render_markdown(notes, SECTIONS) == ( + "A summary\nparagraph.\n\n" + "### Upgrade Notes\n\n" + "- First line\n second line.\n\n ```yaml\n key: value\n ```\n\n" + "### Bug Fixes\n\n" + "- Fixed a thing.\n- A single string item." + ) + + +def test_render_without_notes_is_empty() -> None: + """Empty or blank items produce no output at all.""" + loader = FakeLoader({"v3.9.0-1": {"a.yaml": {"fixes": [" \n"]}}}) + + notes = rrn.collect_notes(loader, ["v3.9.0-1"], SECTION_NAMES, "prelude") + + assert rrn.render_markdown(notes, SECTIONS) == "" + + +def test_drafter_config_unchanged_without_notes() -> None: + """With no notes the committed config is used as is.""" + base = "---\nheader: |+\n badge\n\ntemplate: |\n $CHANGES\n" + + assert rrn.build_drafter_config(base, "") == base + + +def test_drafter_config_places_notes_between_badge_and_changes() -> None: + """Notes follow the badge in the header; everything else is preserved.""" + base_text = (REPO_ROOT / ".github" / "release-drafter.yml").read_text() + base = yaml.safe_load(base_text) + + generated = yaml.safe_load( + rrn.build_drafter_config(base_text, "### Bug Fixes\n\n- Fixed.") + ) + + body = generated["header"] + generated["template"] + assert body.startswith(base["header"] + "### Bug Fixes\n\n- Fixed.\n\n$CHANGES") + assert {k: v for k, v in generated.items() if k != "header"} == { + k: v for k, v in base.items() if k != "header" + } + + +def test_main_rejects_unknown_version( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """An explicit version must be an existing tag.""" + monkeypatch.setattr(rrn, "git_tags", lambda _root: {"v3.9.0"}) + + assert rrn.main(["--version", "v9.9.9"]) == 2 + assert "not a git tag" in capsys.readouterr().err + + +def test_main_writes_drafter_config( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The CLI merges rendered notes into the given config file.""" + monkeypatch.setattr(rrn, "load_release_notes", lambda _root, _v: "- Note.") + base = tmp_path / "base.yml" + base.write_text("header: |\n badge\ntemplate: $CHANGES\n") + out = tmp_path / "out.yml" + + assert rrn.main(["--release-drafter-config", str(base), "--output", str(out)]) == 0 + assert yaml.safe_load(out.read_text())["header"] == "badge\n\n- Note.\n\n" diff --git a/uv.lock b/uv.lock index 9c98500..053475a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14.2" [[package]] @@ -869,6 +869,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/01/894b2f6954d12c5c527232906d67272b59f0f12f386cd2d394d5e5eac7fc/dbus_fast-4.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:698fe83128150dd49aaa7deb5493523309f0a9749c3c075730a6981851607455", size = 1627660, upload-time = "2026-04-02T04:50:36.5Z" }, ] +[[package]] +name = "dulwich" +version = "1.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/92/e47acfff672d6219d1bba1c210f902c1ad4a58d8c68eb89fe0b786f00a68/dulwich-1.2.20.tar.gz", hash = "sha256:28e1f48098d2cb157e722588466070cbefdfe8093c2a141c568b01362296c0f3", size = 1443547, upload-time = "2026-10-26T21:42:03.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/54/81b54b6ea48d347e8d408ba6f522680dd3a51181eb07c8381891c1f6bea9/dulwich-1.2.20-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:cf092fc46beb13b589d81c8b555d462f4848f79aa3ac40497e312d156749aa0e", size = 1591799, upload-time = "2026-10-26T21:41:17.862Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3d/8915d557e3433af5731479c3fb676133148f3a522c320edba6e55748bc72/dulwich-1.2.20-cp314-cp314-android_24_x86_64.whl", hash = "sha256:8153780c99af33bc3c9fc46fa8381bc792d89f5b85ed90e751a7c55216d5e9c2", size = 1647161, upload-time = "2026-10-26T21:41:19.357Z" }, + { url = "https://files.pythonhosted.org/packages/a4/65/033a59758369bdf86db793373994366d8cc019652d29a96a52861b272687/dulwich-1.2.20-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c28de206253ab36de2cf64cb416a250e104f1a460476ebd6d838a112a2abc314", size = 1443388, upload-time = "2026-10-26T21:41:20.688Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e1/f136f9171727a6d9b380dbefd1f673a426cf876ee35881e3ad041aeb7b25/dulwich-1.2.20-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b281c75d6a7d2cde5206af349564e20124511895c2766a89c901a4addb4aea80", size = 1424821, upload-time = "2026-10-26T21:41:22.078Z" }, + { url = "https://files.pythonhosted.org/packages/8c/30/09492157eac271c4d3cb7101b9b2e7870699c0603ca2b2550d39a72b6cf3/dulwich-1.2.20-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a3d8092beaafe801d0d4221ae5173fed0d2d67c65f290679f456ff606a78fe51", size = 1559645, upload-time = "2026-10-26T21:41:23.448Z" }, + { url = "https://files.pythonhosted.org/packages/56/86/6e22635bdd9a361c5c274449dbf1322023ed6b622d652a6f64de37c150a6/dulwich-1.2.20-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e983fa6a8aeb5026e8b7c7086f3b60604895d218a36d56792065d6288a3839c3", size = 1535342, upload-time = "2026-10-26T21:41:24.819Z" }, + { url = "https://files.pythonhosted.org/packages/69/60/0844bdfdc5578c13f0f6ece06c3c0ebb71f4f7bd4d9b46dc9f09f0864c92/dulwich-1.2.20-cp314-cp314-win32.whl", hash = "sha256:c35060cd74cf4947cfe79469521c9b5ec1e05aed638a9e9d116266349d397a70", size = 1173400, upload-time = "2026-10-26T21:41:26.579Z" }, + { url = "https://files.pythonhosted.org/packages/24/da/0a6409210ccc3557d092a25d88995d924f94680a329a0552b476543efc21/dulwich-1.2.20-cp314-cp314-win_amd64.whl", hash = "sha256:51bc52582d5fd95315ffbd75667b12784b4e1845396a1919abe0cf84e496b5b0", size = 1130390, upload-time = "2026-10-26T21:41:28.033Z" }, + { url = "https://files.pythonhosted.org/packages/d3/63/b7d71d6ead942cdf4d01e9e1273cd01c47dc05553713e1092a4431a8c26a/dulwich-1.2.20-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:da70bf6a8bf0e693c09217b4764fcca3d067edb5bcc192c5fe3aa24ad311de08", size = 1504581, upload-time = "2026-10-26T21:41:30.034Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/67b571aab9895fc2c82d7b85354302d28842584974e5230edb553ed44c47/dulwich-1.2.20-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:16675b9502ce7fb30def56359aef8f1df450037646c1b5dd2f03976982425bab", size = 1422252, upload-time = "2026-10-26T21:41:31.824Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/2f17e2f3e80da9726a26893956e91fc6f0c8b9cca075b5dded1ebd44cf0b/dulwich-1.2.20-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:bf60e8590bd2fa466d9412617d96d61e16b3cc02b5bd5b8059297977086ee82a", size = 1556477, upload-time = "2026-10-26T21:41:33.473Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/e9859aa442a15900fff7a273ecfb490e0e990ae2d0d69621dba2eac94fee/dulwich-1.2.20-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:774e5c4e7856ca1e39b5684603a4de53f1a5d8baf70eacad7d432ac5bb1e1d95", size = 1535383, upload-time = "2026-10-26T21:41:34.899Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c1dd1d8545f2a7cba77b69e9c1f9b7fd62e299b0ab2f7e80bfc027ed6393/dulwich-1.2.20-cp314-cp314t-win32.whl", hash = "sha256:f90a99b1f1198c2757abea1ed8e359014b2931f6046892efaed4677021a5060c", size = 1113568, upload-time = "2026-10-26T21:41:36.366Z" }, + { url = "https://files.pythonhosted.org/packages/21/1a/89e4557e99af883e1add3b91c86390b0d84813cf17d6d763287a6a51dab8/dulwich-1.2.20-cp314-cp314t-win_amd64.whl", hash = "sha256:f78c50152186374124f5e9ae397148b4302028e93b0bf2fc17862f8906650ac1", size = 1130923, upload-time = "2026-10-26T21:41:37.803Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f9/6a76551f6df6ded25be23df7feb5f9b9ba9c716968888ebeb962470833a0/dulwich-1.2.20-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:9e74e9c49dd2f0c206b029e474cefefdcfa07f1162f47353d4bac529668ad6a5", size = 1593369, upload-time = "2026-10-26T21:41:39.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/4e/bd3f0b4f2e60c11c65d944a295d9d3b99d56a228ee13d01e0bb49ad6cedf/dulwich-1.2.20-cp315-cp315-android_24_x86_64.whl", hash = "sha256:83ba7f16f5eb6589ac1393a84578a38704a7c67fce73ce3c8eb16c4f02830153", size = 1648522, upload-time = "2026-10-26T21:41:40.993Z" }, + { url = "https://files.pythonhosted.org/packages/87/43/57434f696f893ae5f247c79e3f555f4cfe9dee9b3e038e3fdd036116d3b3/dulwich-1.2.20-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:4bb6d707cd8cc197df9c147058608c47ae2f4f428f7473a97f66e36bb57883b8", size = 1444916, upload-time = "2026-10-26T21:41:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/35f29728e0e0bcc3470fb51cd346565a1f7f6d0ec2bf30c33d7d4f182c8e/dulwich-1.2.20-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:fa74d4436dce73230832a6a95aeccc5c64672b2d201eb53a09aa0f2a7e2b86d3", size = 1425723, upload-time = "2026-10-26T21:41:44.152Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/40f1e9ca06b500928c75c6bbdeb52adb8d9e44f2afe81e3a22a9b19579d1/dulwich-1.2.20-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ac11231f391b6b9cf7bd1d689c36dc5827ea8426833cc51d9f3ee40c8efe9c86", size = 1560093, upload-time = "2026-10-26T21:41:45.583Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/23dd00b59286defb2ffeb54dabf4e0613e9366766092eb66a875da5ddf2e/dulwich-1.2.20-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:827d56649028ab1ca34d9d2bdec6dc5c6f108caf2074880a647474c342d37ca2", size = 1536152, upload-time = "2026-10-26T21:41:47.835Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/15d11f187435ffc5e45223f2fe04ed8c55df57904f40ba98920ea9381212/dulwich-1.2.20-cp315-cp315-win32.whl", hash = "sha256:72335dd97d552927aa3460ee3199953b28d12fa8c110e9fe383dc63f9c919cec", size = 1173853, upload-time = "2026-10-26T21:41:49.212Z" }, + { url = "https://files.pythonhosted.org/packages/77/e5/b3be3f8f8bcbd53b75cf4f9baf082b518b65699d0e1350b7abccb0ae7db4/dulwich-1.2.20-cp315-cp315-win_amd64.whl", hash = "sha256:56c4798acf8dc27bdea670fa8211d69e22c82663a21b10221c5133635a81fd92", size = 1130602, upload-time = "2026-10-26T21:41:50.555Z" }, + { url = "https://files.pythonhosted.org/packages/92/bd/15c18c882db82809e9e7c1ce6b275892cd89d24bead8fe158ccf9457f2e1/dulwich-1.2.20-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:dd1e931220d1c5b2c584d1e3b79f4eb06f46ed70bc48a7f927d79f4907a079cc", size = 1505493, upload-time = "2026-10-26T21:41:52.195Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/3fc67d5987f796091b17ecbbbc8abcd515b445e7d3533881a4cada92dbd6/dulwich-1.2.20-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b147b8066804160cdf71927826cfac19322a16b2816de979d20f36a1624af3c7", size = 1481699, upload-time = "2026-10-26T21:41:53.759Z" }, + { url = "https://files.pythonhosted.org/packages/93/88/b5259292509fcad7cbeabe70baf78f9dbee654a9ebf6716d50386215107c/dulwich-1.2.20-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:a68464a882d478f15835494cdb1b7f0bdc2c7aa4d1d4f96606467dc251841da9", size = 1557880, upload-time = "2026-10-26T21:41:55.223Z" }, + { url = "https://files.pythonhosted.org/packages/b8/26/c5373c21176fd5cb108ab94473efd5162e42471ba63516d9dc791a6ee7d7/dulwich-1.2.20-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:deeebe420db3387c38c3c18ed996a17a9a0b322fe72eaaa9e3fbb46a7e36cacb", size = 1536154, upload-time = "2026-10-26T21:41:56.871Z" }, + { url = "https://files.pythonhosted.org/packages/10/ea/2b70d9272ce8edd6e87e927e50bf7a66902530f648b57d3522898ab8eeea/dulwich-1.2.20-cp315-cp315t-win32.whl", hash = "sha256:4b03cc7be57a0ee37d1a53d09cbd99f9c8581fa04f94b3365e00498a32216b2c", size = 1113845, upload-time = "2026-10-26T21:41:58.521Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1a/7ee4452d14e2b05f14f292c1725a092067f6d0485b3b0afce45805e2b676/dulwich-1.2.20-cp315-cp315t-win_amd64.whl", hash = "sha256:28cb103caffc60369b54159e41d2799d69d3107ac78b79bceaedc407977ec47d", size = 1131638, upload-time = "2026-10-26T21:42:00.005Z" }, + { url = "https://files.pythonhosted.org/packages/08/c8/217841f8ae98909dd2045cfd9e9060791ae1dc9c0d2239672ae318b28796/dulwich-1.2.20-py3-none-any.whl", hash = "sha256:ab521391c3475e7c507f14aeacad0cdf26e97ba5050d8010a1ff34e417c9a842", size = 758582, upload-time = "2026-10-26T21:42:01.525Z" }, +] + [[package]] name = "envs" version = "1.4" @@ -1187,6 +1227,10 @@ test = [ [package.dev-dependencies] dev = [ { name = "homeassistant" }, + { name = "reno" }, +] +releasenotes = [ + { name = "reno" }, ] [package.metadata] @@ -1203,7 +1247,11 @@ requires-dist = [ provides-extras = ["dev", "test"] [package.metadata.requires-dev] -dev = [{ name = "homeassistant", specifier = ">=2026.10.0" }] +dev = [ + { name = "homeassistant", specifier = ">=2026.10.0" }, + { name = "reno", specifier = ">=4.1.0" }, +] +releasenotes = [{ name = "reno", specifier = ">=4.1.0" }] [[package]] name = "httpcore" @@ -1692,6 +1740,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, ] +[[package]] +name = "pbr" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/8d/ce438c28c7958e33184e8ac851ea2225b47a41e5e9e708fa3bddba631135/pbr-7.1.3.tar.gz", hash = "sha256:9a4a85b84e906337708009af0b5f5cdabeeb72d4dc213c9e97974da54fd9acc5", size = 140100, upload-time = "2026-10-07T10:38:15.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/a2/79a926b7ab54b247c3419bfa00cfbeb78ad495d21c6d787c978c6261623d/pbr-7.1.3-py2.py3-none-any.whl", hash = "sha256:6583e878a1d97cb135fdc509811f31b9235905cde8d4dacd3dbadf9efc45d745", size = 136717, upload-time = "2026-10-07T10:38:14.069Z" }, +] + [[package]] name = "pillow" version = "12.3.0" @@ -2491,6 +2551,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, ] +[[package]] +name = "reno" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dulwich" }, + { name = "packaging" }, + { name = "pbr" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/a0/a2526ccefaf41717f7387fb5c068a094af0f5853822eb43c792e338d9e0b/reno-4.1.0.tar.gz", hash = "sha256:f992f1fdbd16215ec9de47af08131d53a2830c9e78439eb563ce8d6a7f625370", size = 86657, upload-time = "2024-03-04T07:40:35.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/4f/e933aae43bfaea4f6646009379036bec42d78ee2231ab9a983317c3a1c4b/reno-4.1.0-py3-none-any.whl", hash = "sha256:9b6a2cb768ffb7f7c74bbd76822acff70840a1219f45bcec5080dbc108df4f96", size = 72126, upload-time = "2024-03-04T07:40:33.517Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -2580,6 +2655,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/b6/48339c109bab6f54ff608800773b9425464c6cbf7fd3f2ba01294d78be3d/sentence_stream-1.3.0-py3-none-any.whl", hash = "sha256:7448d131315b85eefdf238e5edd9caa62899acf609145d5e0e10c09812eb8a1d", size = 8707, upload-time = "2026-01-08T16:25:05.918Z" }, ] +[[package]] +name = "setuptools" +version = "84.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc", size = 1230289, upload-time = "2026-10-20T19:01:37.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2", size = 876263, upload-time = "2026-10-20T19:01:35.678Z" }, +] + [[package]] name = "six" version = "1.17.0"