From d473951aeaa4590be3719f0c16964bb5c4cb8ffb Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sat, 24 Oct 2026 16:25:13 +0400 Subject: [PATCH 1/2] ci: check that a breaking change carries its release notes --- .github/pull_request_template.md | 1 + .github/workflows/breaking-change-notes.yml | 37 ++++++ CONTRIBUTING.md | 10 ++ codebase_rag/tests/test_breaking_notes.py | 136 ++++++++++++++++++++ scripts/breaking_notes.py | 131 +++++++++++++++++++ 5 files changed, 315 insertions(+) create mode 100644 .github/workflows/breaking-change-notes.yml create mode 100644 codebase_rag/tests/test_breaking_notes.py create mode 100644 scripts/breaking_notes.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 489a65eca1..19dabf470e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -36,6 +36,7 @@ ## Checklist - [ ] PR title follows [Conventional Commits](https://www.conventionalcommits.org/) format +- [ ] A breaking change has `!` in the title and its release notes in `changes/breaking/.md` (see [Breaking changes](https://github.com/vitali87/code-graph-rag/blob/main/CONTRIBUTING.md#breaking-changes)) - [ ] All pre-commit checks pass (`make pre-commit`) - [ ] No hardcoded strings in non-config/non-constants files - [ ] No `# type: ignore`, `cast()`, `Any`, or `object` type hints diff --git a/.github/workflows/breaking-change-notes.yml b/.github/workflows/breaking-change-notes.yml new file mode 100644 index 0000000000..65cbae15c5 --- /dev/null +++ b/.github/workflows/breaking-change-notes.yml @@ -0,0 +1,37 @@ +name: Breaking Change Notes + +# The release job publishes changes/breaking/.md from each pull request +# labelled "breaking change" as the release's breaking-change section (issue +# #3431). This check keeps the label, a conventional-commit "!" in the title +# and that file in step. It reruns on label and title edits, because a +# maintainer's label is often the last thing to change on a pull request. +on: + pull_request: + types: [opened, edited, reopened, synchronize, labeled, unlabeled] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + breaking-change-notes: + name: Breaking Change Notes + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check the label, the title and the notes agree + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + python3 scripts/breaking_notes.py check --number "$PR_NUMBER" \ + --title "$PR_TITLE" --labels-json "$PR_LABELS" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a1e408ccb..184719dfb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,6 +88,16 @@ A pull request that changes something a user can see must show it working, in th - **How to attach it:** embed it inline as a GitHub attachment, never committed. Reference the file in the body and let `gh` upload it: `gh pr create --attach ./after.png` or `gh pr edit --attach ./after.png` (GitHub CLI 2.99 or later). A body line such as `![after](./after.png)` is rewritten to the uploaded `github.com/user-attachments` URL; without one the image is appended. By hand, drag or paste the file into the description. - **When nothing is visible:** an internal refactor, a test-only or CI-only change says so in one line instead, for example `No user-visible change: refactors the call resolver's cache key.` +### Breaking changes + +A change that breaks existing setups (a new default, a removed option, a renamed setting) is announced in its release under "⚠️ Breaking changes". The text comes from the pull request: + +- Mark the title with `!`, for example `feat(vectors)!: default to the sqlite vector store`. +- A maintainer adds the `breaking change` label. The label bumps the minor version and releases at once. +- Add the release notes as `changes/breaking/.md`: what changed, how to keep the old behaviour, what happens to existing installs, and a link to the docs. Write them for users reading the release, in Markdown with no heading. + +The **Breaking Change Notes** check fails until the title, the label and the file agree. + ### Continuous Integration All pull requests are automatically validated by our CI workflow, which runs in parallel for faster feedback: diff --git a/codebase_rag/tests/test_breaking_notes.py b/codebase_rag/tests/test_breaking_notes.py new file mode 100644 index 0000000000..42b5ec2fa9 --- /dev/null +++ b/codebase_rag/tests/test_breaking_notes.py @@ -0,0 +1,136 @@ +# A breaking release must say what breaks and how to migrate. v0.3.2 shipped +# without that section because nothing in the release job wrote one; the +# notes now live in changes/breaking/.md, and a pull +# request check keeps them in step with the label (issue #3431). + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from scripts import breaking_notes as bn + +_REPO = Path(__file__).resolve().parents[2] +_WORKFLOWS = _REPO / ".github" / "workflows" + + +def _root(tmp_path: Path, fragments: dict[str, str]) -> Path: + directory = tmp_path / "changes" / "breaking" + directory.mkdir(parents=True) + for name, text in fragments.items(): + (directory / name).write_text(text, encoding="utf-8") + return tmp_path + + +def test_a_labelled_pull_request_with_notes_passes(tmp_path: Path) -> None: + root = _root(tmp_path, {"7.md": "Qdrant is no longer the default."}) + assert bn.check_pull_request(7, "feat(x)!: y", [bn.LABEL], root) == [] + + +def test_a_pull_request_that_breaks_nothing_passes(tmp_path: Path) -> None: + # A "!" outside the type prefix is not a breaking marker. + root = _root(tmp_path, {"3.md": "notes of an earlier pull request"}) + assert bn.check_pull_request(7, "feat: support x!", ["bug"], root) == [] + + +@pytest.mark.parametrize("fragments", [{}, {"7.md": " \n"}]) +def test_a_labelled_pull_request_without_notes_fails( + tmp_path: Path, fragments: dict[str, str] +) -> None: + errors = bn.check_pull_request( + 7, "feat!: y", [bn.LABEL], _root(tmp_path, fragments) + ) + assert len(errors) == 1 + assert "changes/breaking/7.md" in errors[0] + + +def test_a_breaking_title_without_the_label_fails(tmp_path: Path) -> None: + errors = bn.check_pull_request(7, "feat(cli)!: y", [], _root(tmp_path, {})) + assert len(errors) == 1 + assert bn.LABEL in errors[0] + + +def test_notes_without_the_label_fail(tmp_path: Path) -> None: + errors = bn.check_pull_request(7, "feat: y", [], _root(tmp_path, {"7.md": "x"})) + assert len(errors) == 1 + assert "changes/breaking/7.md" in errors[0] + + +@pytest.mark.parametrize("name", ["notes.md", "7.txt", "07.md"]) +def test_a_fragment_no_release_would_publish_fails(tmp_path: Path, name: str) -> None: + errors = bn.check_pull_request(7, "fix: y", [], _root(tmp_path, {name: "x"})) + assert len(errors) == 1 + assert name in errors[0] + + +def test_notes_render_in_pull_request_order(tmp_path: Path) -> None: + root = _root(tmp_path, {"12.md": "Second.\n", "9.md": "First.\n"}) + section = bn.render_section( + ["changes/breaking/12.md", "changes/breaking/9.md"], [9, 12], root + ) + assert section == f"{bn.HEADING}\n\nFirst.\n\nSecond.\n\n" + + +@pytest.mark.parametrize("fragments", [{}, {"12.md": "\n"}]) +def test_a_labelled_pull_request_without_notes_is_still_named( + tmp_path: Path, fragments: dict[str, str] +) -> None: + added = [f"changes/breaking/{name}" for name in fragments] + section = bn.render_section(added, [12], _root(tmp_path, fragments)) + assert section == ( + f"{bn.HEADING}\n\nThis release contains a breaking change; see #12.\n\n" + ) + + +def test_a_release_with_nothing_breaking_has_no_section(tmp_path: Path) -> None: + assert bn.render_section([], [], _root(tmp_path, {})) == "" + + +@pytest.mark.parametrize( + "path", ["changes/breaking/notes.md", "changes/breaking/old/3.md", "docs/3.md"] +) +def test_a_path_that_is_not_a_fragment_is_refused(tmp_path: Path, path: str) -> None: + with pytest.raises(ValueError, match="is not a"): + bn.render_section([path], [], _root(tmp_path, {})) + + +def test_the_check_reports_its_errors_and_fails( + capsys: pytest.CaptureFixture[str], +) -> None: + argv = ["check", "--number", "999999", "--title", "feat!: y", "--labels-json"] + assert bn.main([*argv, "[]"]) == 1 + assert capsys.readouterr().out.startswith("::error::") + assert ( + bn.main( + ["check", "--number", "999999", "--title", "fix: y", "--labels-json", "[]"] + ) + == 0 + ) + + +@pytest.mark.parametrize( + "argv", + [ + ["render", "--labelled", "12 x"], + ["render", "--labelled", "-3"], + ["check", "--number", "1", "--title", "t", "--labels-json", '{"a": 1}'], + ], +) +def test_malformed_input_is_refused(argv: list[str]) -> None: + with pytest.raises(SystemExit) as raised: + bn.main(argv) + assert raised.value.code != 0 + + +def test_the_check_reruns_when_the_label_or_title_changes() -> None: + workflow = yaml.safe_load( + (_WORKFLOWS / "breaking-change-notes.yml").read_text("utf-8") + ) + # PyYAML reads the bare key `on` as True. + types = set(workflow[True]["pull_request"]["types"]) + assert {"labeled", "unlabeled", "edited", "synchronize"} <= types + for job in workflow["jobs"].values(): + for step in job["steps"]: + assert "${{" not in step.get("run", "") diff --git a/scripts/breaking_notes.py b/scripts/breaking_notes.py new file mode 100644 index 0000000000..e292eb7a80 --- /dev/null +++ b/scripts/breaking_notes.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Breaking-change notes: the pull request check and the release section. + +A pull request labelled "breaking change" carries its migration notes as +changes/breaking/.md. A file is reviewed with the code and +fixed once merged; a pull request description is neither, as its author can +edit it at any time. `check` makes the label, a conventional-commit "!" in the +title and the notes agree, so neither reaches main without the other. `render` +turns the notes a release adds into its "⚠️ Breaking changes" section and names +a labelled pull request that added none, so a breaking release is never +published without the section, as v0.3.2 was (issue #3431). Standard library +only, like update_news.py, so both workflows run it without syncing the +project environment. +""" + +# ruff: noqa: T201 +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path, PurePosixPath + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +FRAGMENT_DIR = PurePosixPath("changes/breaking") +LABEL = "breaking change" +HEADING = "## ⚠️ Breaking changes" +# The type and scope of ci.yml's PR title pattern; only the "!" before the +# colon marks a breaking change, never one in the description. +BREAKING_TITLE = re.compile(r"^[a-z]+(\([a-zA-Z0-9_-]+\))?!: ") +FRAGMENT_NAME = re.compile(r"^([1-9][0-9]*)\.md$") + + +def _has_text(path: Path) -> bool: + return path.is_file() and bool(path.read_text(encoding="utf-8").strip()) + + +def check_pull_request( + number: int, title: str, labels: list[str], root: Path +) -> list[str]: + errors: list[str] = [] + labelled = LABEL in labels + fragment = FRAGMENT_DIR / f"{number}.md" + if BREAKING_TITLE.match(title) and not labelled: + errors.append( + f'The title marks a breaking change with "!", but the pull request ' + f'is not labelled "{LABEL}". A maintainer applies the label.' + ) + if labelled and not _has_text(root / fragment): + errors.append( + f'The pull request is labelled "{LABEL}", but {fragment} is missing ' + "or empty. Add the release notes there: what changed, how to keep " + "the old behaviour, what happens to existing installs." + ) + if not labelled and (root / fragment).exists(): + errors.append( + f"{fragment} holds breaking-change notes, but the pull request is " + f'not labelled "{LABEL}", so its release would not be breaking.' + ) + directory = root / FRAGMENT_DIR + if directory.is_dir(): + errors.extend( + f"{FRAGMENT_DIR / entry.name} is not named .md, " + "so no release would publish it." + for entry in sorted(directory.iterdir()) + if not FRAGMENT_NAME.match(entry.name) + ) + return errors + + +def render_section(added: list[str], labelled: list[int], root: Path) -> str: + notes: dict[int, str] = {} + for line in added: + path = PurePosixPath(line) + match = FRAGMENT_NAME.match(path.name) + if path.parent != FRAGMENT_DIR or match is None: + raise ValueError(f"{line} is not a {FRAGMENT_DIR}/.md fragment") + if text := (root / path).read_text(encoding="utf-8").strip(): + notes[int(match.group(1))] = text + blocks = [notes[number] for number in sorted(notes)] + blocks += [ + f"This release contains a breaking change; see #{number}." + for number in sorted(set(labelled) - notes.keys()) + ] + return "\n\n".join([HEADING, *blocks]) + "\n\n" if blocks else "" + + +def _pull_requests(text: str) -> list[int]: + numbers = text.split() + if not all(re.fullmatch(r"[1-9][0-9]*", number) for number in numbers): + raise argparse.ArgumentTypeError(f"not pull request numbers: {text!r}") + return [int(number) for number in numbers] + + +def _labels(text: str) -> list[str]: + labels = json.loads(text) + if not isinstance(labels, list) or not all(isinstance(x, str) for x in labels): + raise argparse.ArgumentTypeError(f"not a JSON list of labels: {text!r}") + return labels + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Breaking-change notes: the pull request check and the " + "release section." + ) + commands = parser.add_subparsers(dest="command", required=True) + check = commands.add_parser("check", help="check one pull request") + check.add_argument("--number", type=int, required=True) + check.add_argument("--title", required=True) + check.add_argument("--labels-json", type=_labels, required=True) + render = commands.add_parser( + "render", help="write the release section from fragment paths on stdin" + ) + render.add_argument("--labelled", type=_pull_requests, default=[]) + args = parser.parse_args(argv) + if args.command == "check": + errors = check_pull_request( + args.number, args.title, args.labels_json, PROJECT_ROOT + ) + for error in errors: + print(f"::error::{error}") + return 1 if errors else 0 + added = [line.strip() for line in sys.stdin if line.strip()] + sys.stdout.write(render_section(added, args.labelled, PROJECT_ROOT)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From c5067e93ec55afd639d3033b7746312f6dbbe75a Mon Sep 17 00:00:00 2001 From: vitali87 Date: Sat, 24 Oct 2026 18:27:12 +0400 Subject: [PATCH 2/2] fix(ci): hold every breaking title and every added note to the label check --- .github/workflows/breaking-change-notes.yml | 7 +- codebase_rag/tests/test_breaking_notes.py | 125 +++++++++++++++++++- scripts/breaking_notes.py | 31 ++++- 3 files changed, 155 insertions(+), 8 deletions(-) diff --git a/.github/workflows/breaking-change-notes.yml b/.github/workflows/breaking-change-notes.yml index 65cbae15c5..e0a46dfd19 100644 --- a/.github/workflows/breaking-change-notes.yml +++ b/.github/workflows/breaking-change-notes.yml @@ -26,6 +26,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # The merge commit and its first parent, the base, so the check + # sees which notes this pull request adds. + fetch-depth: 2 - name: Check the label, the title and the notes agree env: @@ -33,5 +36,7 @@ jobs: PR_TITLE: ${{ github.event.pull_request.title }} PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} run: | + ADDED=$(git diff --name-only --no-renames --diff-filter=A HEAD^1 HEAD \ + -- changes/breaking/) python3 scripts/breaking_notes.py check --number "$PR_NUMBER" \ - --title "$PR_TITLE" --labels-json "$PR_LABELS" + --title "$PR_TITLE" --labels-json "$PR_LABELS" --added "$ADDED" diff --git a/codebase_rag/tests/test_breaking_notes.py b/codebase_rag/tests/test_breaking_notes.py index 42b5ec2fa9..83d862642a 100644 --- a/codebase_rag/tests/test_breaking_notes.py +++ b/codebase_rag/tests/test_breaking_notes.py @@ -5,11 +5,16 @@ from __future__ import annotations +import json +import subprocess +import sys from pathlib import Path import pytest import yaml +from codebase_rag import constants as cs +from codebase_rag.tests.conftest import git_env from scripts import breaking_notes as bn _REPO = Path(__file__).resolve().parents[2] @@ -26,7 +31,8 @@ def _root(tmp_path: Path, fragments: dict[str, str]) -> Path: def test_a_labelled_pull_request_with_notes_passes(tmp_path: Path) -> None: root = _root(tmp_path, {"7.md": "Qdrant is no longer the default."}) - assert bn.check_pull_request(7, "feat(x)!: y", [bn.LABEL], root) == [] + added = ["changes/breaking/7.md"] + assert bn.check_pull_request(7, "feat(x)!: y", [bn.LABEL], root, added) == [] def test_a_pull_request_that_breaks_nothing_passes(tmp_path: Path) -> None: @@ -52,6 +58,37 @@ def test_a_breaking_title_without_the_label_fails(tmp_path: Path) -> None: assert bn.LABEL in errors[0] +# This repository's scopes hold commas, slashes and dots, as in +# "refactor(rename,imports)" and "fix(mcp/server.py)". +@pytest.mark.parametrize( + "title", + ["feat(providers/base)!: y", "refactor(rename,imports)!: y", "fix(a.py)!: y"], +) +def test_a_breaking_title_with_any_scope_needs_the_label( + tmp_path: Path, title: str +) -> None: + errors = bn.check_pull_request(7, title, [], _root(tmp_path, {})) + assert len(errors) == 1 + assert bn.LABEL in errors[0] + + +def test_a_labelled_pull_request_needs_the_breaking_title(tmp_path: Path) -> None: + root = _root(tmp_path, {"7.md": "x"}) + errors = bn.check_pull_request(7, "feat(x): y", [bn.LABEL], root) + assert len(errors) == 1 + assert "title has no" in errors[0] + + +def test_notes_added_under_another_number_fail(tmp_path: Path) -> None: + # The release publishes every fragment added since the last release, so + # notes filed under another number would skip the label check. + root = _root(tmp_path, {"3.md": "x"}) + added = ["changes/breaking/3.md"] + errors = bn.check_pull_request(7, "fix: y", [], root, added) + assert len(errors) == 1 + assert "changes/breaking/3.md" in errors[0] + + def test_notes_without_the_label_fail(tmp_path: Path) -> None: errors = bn.check_pull_request(7, "feat: y", [], _root(tmp_path, {"7.md": "x"})) assert len(errors) == 1 @@ -92,8 +129,9 @@ def test_a_release_with_nothing_breaking_has_no_section(tmp_path: Path) -> None: "path", ["changes/breaking/notes.md", "changes/breaking/old/3.md", "docs/3.md"] ) def test_a_path_that_is_not_a_fragment_is_refused(tmp_path: Path, path: str) -> None: + root = _root(tmp_path, {}) with pytest.raises(ValueError, match="is not a"): - bn.render_section([path], [], _root(tmp_path, {})) + bn.render_section([path], [], root) def test_the_check_reports_its_errors_and_fails( @@ -110,6 +148,12 @@ def test_the_check_reports_its_errors_and_fails( ) +def test_the_check_reads_the_notes_the_pull_request_adds() -> None: + argv = ["check", "--number", "999999", "--title", "fix: y", "--labels-json"] + assert bn.main([*argv, "[]", "--added", "changes/breaking/3.md"]) == 1 + assert bn.main([*argv, "[]", "--added", ""]) == 0 + + @pytest.mark.parametrize( "argv", [ @@ -134,3 +178,80 @@ def test_the_check_reruns_when_the_label_or_title_changes() -> None: for job in workflow["jobs"].values(): for step in job["steps"]: assert "${{" not in step.get("run", "") + + +def _git(cwd: Path, *args: str) -> None: + subprocess.run( + ["git", *args], + cwd=cwd, + env=git_env(), + check=True, + capture_output=True, + text=True, + encoding=cs.ENCODING_UTF8, + ) + + +def _commit(repo: Path, files: dict[str, str]) -> None: + for name, text in files.items(): + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="\n") + _git(repo, "add", "-A") + _git(repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x") + + +def _run_workflow_check( + tmp_path: Path, fragment: str, title: str, labels: list[str] +) -> subprocess.CompletedProcess[str]: + if sys.platform == "win32": + pytest.skip("the step runs under the Ubuntu runner's bash") + steps = yaml.safe_load( + (_WORKFLOWS / "breaking-change-notes.yml").read_text("utf-8") + )["jobs"]["breaking-change-notes"]["steps"] + upstream = tmp_path / "upstream" + upstream.mkdir() + _git(upstream, "init", "-q") + script = (_REPO / "scripts" / "breaking_notes.py").read_text("utf-8") + _commit( + upstream, + {"scripts/breaking_notes.py": script, "changes/breaking/3.md": "Old."}, + ) + # Correcting an earlier pull request's notes is allowed; only added + # notes must carry this pull request's number. + _commit( + upstream, + {f"changes/breaking/{fragment}": "New.", "changes/breaking/3.md": "Fixed."}, + ) + # Cloned as deep as the job checks out: the pull request's merge commit + # and the base it is compared with. + depth = steps[0]["with"]["fetch-depth"] + work = tmp_path / "work" + _git(tmp_path, "clone", "-q", f"--depth={depth}", upstream.as_uri(), str(work)) + return subprocess.run( + ["bash", "--noprofile", "--norc", "-e", "-c", steps[1]["run"]], + cwd=work, + env={ + **git_env(), + "PR_NUMBER": "7", + "PR_TITLE": title, + "PR_LABELS": json.dumps(labels), + }, + capture_output=True, + text=True, + encoding=cs.ENCODING_UTF8, + check=False, + ) + + +def test_the_workflow_passes_the_notes_the_pull_request_adds(tmp_path: Path) -> None: + result = _run_workflow_check(tmp_path, "7.md", "feat!: y", [bn.LABEL]) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_the_workflow_rejects_notes_added_under_another_number( + tmp_path: Path, +) -> None: + result = _run_workflow_check(tmp_path, "5.md", "fix: y", []) + assert result.returncode == 1, result.stdout + result.stderr + assert result.stdout.startswith("::error::changes/breaking/5.md") diff --git a/scripts/breaking_notes.py b/scripts/breaking_notes.py index e292eb7a80..eb34b7ba27 100644 --- a/scripts/breaking_notes.py +++ b/scripts/breaking_notes.py @@ -20,15 +20,18 @@ import json import re import sys +from collections.abc import Iterable from pathlib import Path, PurePosixPath PROJECT_ROOT = Path(__file__).resolve().parent.parent FRAGMENT_DIR = PurePosixPath("changes/breaking") LABEL = "breaking change" HEADING = "## ⚠️ Breaking changes" -# The type and scope of ci.yml's PR title pattern; only the "!" before the -# colon marks a breaking change, never one in the description. -BREAKING_TITLE = re.compile(r"^[a-z]+(\([a-zA-Z0-9_-]+\))?!: ") +# A conventional-commit header, as ci.yml's title check parses it: a scope is +# whatever sits in the parentheses, "rename,imports" and "mcp/server.py" among +# them. Only the "!" before the colon marks a breaking change, never one in the +# description. +BREAKING_TITLE = re.compile(r"^[a-z]+(\([^()]+\))?!: ") FRAGMENT_NAME = re.compile(r"^([1-9][0-9]*)\.md$") @@ -37,7 +40,7 @@ def _has_text(path: Path) -> bool: def check_pull_request( - number: int, title: str, labels: list[str], root: Path + number: int, title: str, labels: list[str], root: Path, added: Iterable[str] = () ) -> list[str]: errors: list[str] = [] labelled = LABEL in labels @@ -47,6 +50,11 @@ def check_pull_request( f'The title marks a breaking change with "!", but the pull request ' f'is not labelled "{LABEL}". A maintainer applies the label.' ) + if labelled and not BREAKING_TITLE.match(title): + errors.append( + f'The pull request is labelled "{LABEL}", but its title has no "!" ' + 'before the colon. Mark it, as in "feat(scope)!: summary".' + ) if labelled and not _has_text(root / fragment): errors.append( f'The pull request is labelled "{LABEL}", but {fragment} is missing ' @@ -58,6 +66,13 @@ def check_pull_request( f"{fragment} holds breaking-change notes, but the pull request is " f'not labelled "{LABEL}", so its release would not be breaking.' ) + # The release publishes every fragment added since the last release, so + # notes filed under another number would never meet the label check. + errors.extend( + f"{path} is added by this pull request, which may add notes only as {fragment}." + for path in added + if PurePosixPath(path) != fragment + ) directory = root / FRAGMENT_DIR if directory.is_dir(): errors.extend( @@ -110,6 +125,12 @@ def main(argv: list[str] | None = None) -> int: check.add_argument("--number", type=int, required=True) check.add_argument("--title", required=True) check.add_argument("--labels-json", type=_labels, required=True) + check.add_argument( + "--added", + type=str.splitlines, + default=[], + help="the paths under changes/breaking/ the pull request adds, one per line", + ) render = commands.add_parser( "render", help="write the release section from fragment paths on stdin" ) @@ -117,7 +138,7 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) if args.command == "check": errors = check_pull_request( - args.number, args.title, args.labels_json, PROJECT_ROOT + args.number, args.title, args.labels_json, PROJECT_ROOT, args.added ) for error in errors: print(f"::error::{error}")