From 33b75421d5c8896c80661fea27a9296b44529d2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 14:09:20 +0000 Subject: [PATCH 1/4] feat: add a changelog package Lifts `parse_release_notes`, `format_release_notes`, `format_changes` and the commit-type-to-category mapping out of `canonical/operator`'s `release.py`, which is being replaced by three workflows. The formatting is the part that isn't specific to one repository; the version arithmetic, the file rewriting and the GitHub calls stay behind in operator. The one behaviour change while lifting is that `format_changes` takes the date as an argument rather than calling `datetime.datetime.now()` itself, which is what made it impossible to assert on. The package does no I/O at all now, so a conftest fixture blocks the clock to keep it that way. The tests use two real operator releases as fixtures, reconstructed from the repository's own history: 3.8.2 for the ordinary case (eleven of its twenty-three pull requests are `chore`, and none of them reach the changelog) and a trimmed 3.8.0 for the one breaking change merged into a 3.x release. There's no console script, because what a command line should look like depends on the workflow that calls it, and that workflow hasn't been written yet. Refs canonical/operator#2224 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DC6P7uEu7Qw7kh2tDBuJyB --- .github/workflows/ci.yaml | 2 +- README.md | 1 + changelog/README.md | 46 ++ changelog/pyproject.toml | 34 ++ .../src/charm_tech_code/changelog/__init__.py | 43 ++ .../charm_tech_code/changelog/_constants.py | 98 ++++ .../src/charm_tech_code/changelog/_format.py | 104 ++++ .../src/charm_tech_code/changelog/_parse.py | 74 +++ changelog/tests/conftest.py | 53 ++ changelog/tests/test_changelog.py | 492 ++++++++++++++++++ changelog/uv.lock | 156 ++++++ 11 files changed, 1102 insertions(+), 1 deletion(-) create mode 100644 changelog/README.md create mode 100644 changelog/pyproject.toml create mode 100644 changelog/src/charm_tech_code/changelog/__init__.py create mode 100644 changelog/src/charm_tech_code/changelog/_constants.py create mode 100644 changelog/src/charm_tech_code/changelog/_format.py create mode 100644 changelog/src/charm_tech_code/changelog/_parse.py create mode 100644 changelog/tests/conftest.py create mode 100644 changelog/tests/test_changelog.py create mode 100644 changelog/uv.lock diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 01451d9..e751df8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: fail-fast: false matrix: # One entry per tool. Add a directory here when you add a package. - package: [ai-failure-notifier] + package: [ai-failure-notifier, changelog] python-version: ['3.10', '3.12', '3.14'] defaults: run: diff --git a/README.md b/README.md index 91ba797..ba0de99 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Each tool is its own package in its own top-level directory, with its own `pypro | directory | what it does | |---|---| | [`ai-failure-notifier`](ai-failure-notifier) | Triages and enriches the issue opened when a scheduled workflow fails. | +| [`changelog`](changelog) | Turns GitHub's generated release notes into our changelog format. | Code here is consumed by workflow YAML in the repository that runs it, pinned by commit SHA: diff --git a/changelog/README.md b/changelog/README.md new file mode 100644 index 0000000..04167f2 --- /dev/null +++ b/changelog/README.md @@ -0,0 +1,46 @@ +# changelog + +Turns GitHub's generated release notes into our changelog format. + +Lifted out of `canonical/operator`'s `release.py`, which is being replaced by three workflows ([canonical/operator#2224](https://github.com/canonical/operator/issues/2224)). The formatting is the part that isn't specific to one repository, so it lives here and the version arithmetic, the file rewriting and the GitHub calls stay behind in operator. + +## Using it + +```python +import datetime +from charm_tech_code.changelog import format_changes, format_release_notes, parse_release_notes + +categories, full_changelog = parse_release_notes(notes_text) +notes = format_release_notes(categories, full_changelog) +entry = format_changes(categories, '3.8.2', datetime.date.today()) +``` + +`notes_text` is GitHub's *generated* release-notes text, not a `git log`. GitHub builds it from the titles of the pull requests merged in the range, which is why the conventional-commit types come off PR titles. A release already has that text in its body; a workflow running before any release exists can ask for a preview of it with `POST /repos/{owner}/{repo}/releases/generate-notes`. Either way, getting hold of it is the caller's job: nothing here touches the network, git, the filesystem or the clock, and `format_changes` takes the date as an argument for the same reason. + +There's no console script, because what a command line would need to look like depends on the workflow calling it, and that workflow hasn't been written yet. + +## The format + +`format_release_notes` produces the body of a GitHub release, and `format_changes` produces one `CHANGES.md` entry: + +```markdown +# 3.8.2 - 31 August 2026 + +## Fixes + +* Compare full event paths when skipping duplicate notices (#2684) +``` + +Neither shape is injectable, and neither is the map of commit type to heading. The format is common across our repositories and the set of types is enforced by a shared PR-title check, so there is no second format for an adopting repository to supply, and a template system here would exist for a caller that doesn't. + +Two things about that map are worth knowing before you decide it's wrong: + +* `chore` is a type but not a category, so `chore` commits are dropped. That's deliberate. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and in a typical operator release they're a third to a half of the commits in the range. +* `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A `!` deliberately doesn't infer a major version bump, so that calling-out is the only thing marking it. + +## Developing + +```shell +uv sync --group unit +uv run pytest +``` diff --git a/changelog/pyproject.toml b/changelog/pyproject.toml new file mode 100644 index 0000000..bc7490f --- /dev/null +++ b/changelog/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "charm-tech-code-changelog" +version = "0.1.0" +description = "Turn GitHub's generated release notes into our changelog format." +readme = "README.md" +requires-python = ">=3.10" +authors = [ + {name = "The Charm Tech team at Canonical Ltd."}, +] +license = "Apache-2.0" +# No runtime dependencies, and that is worth keeping. The package is pure +# text-to-text: it does not talk to GitHub, run git, or read the clock, so +# there is nothing for a dependency to do. `release.py`, which this is lifted +# out of, needs `pygithub`, `packaging` and `rich` -- all three belong to the +# parts that stayed behind in canonical/operator. +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/charm_tech_code"] + +[dependency-groups] +unit = ["pytest"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +# The real ruff configuration is at the root of the monorepo; extending it +# means a setting added here overrides one key rather than the whole config. +[tool.ruff] +extend = "../pyproject.toml" diff --git a/changelog/src/charm_tech_code/changelog/__init__.py b/changelog/src/charm_tech_code/changelog/__init__.py new file mode 100644 index 0000000..82a13cb --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/__init__.py @@ -0,0 +1,43 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Turn GitHub's generated release-notes text into our changelog format. + +Text in, structured data and formatted strings out. Nothing here touches the +network, git, the filesystem or the clock, so a caller supplies the notes +text and the date and decides what to do with what comes back:: + + categories, full_changelog = parse_release_notes(notes_text) + notes = format_release_notes(categories, full_changelog) + entry = format_changes(categories, '3.8.2', datetime.date.today()) + +The format is the package's own, not a parameter -- see `_constants` for +what that means and why `chore` commits do not appear in a changelog. +""" + +from __future__ import annotations + +from ._constants import CATEGORIES, CATEGORY_HEADINGS +from ._format import commit_type_to_category, format_changes, format_release_notes +from ._parse import parse_release_notes + +__all__ = [ + 'CATEGORIES', + 'CATEGORY_HEADINGS', + 'commit_type_to_category', + 'format_changes', + 'format_release_notes', + 'parse_release_notes', +] diff --git a/changelog/src/charm_tech_code/changelog/_constants.py b/changelog/src/charm_tech_code/changelog/_constants.py new file mode 100644 index 0000000..dff0083 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_constants.py @@ -0,0 +1,98 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""The changelog format, as constants. + +None of this is injectable, and that is the point. The format is the same +across the Charm Tech repositories, and the set of conventional-commit types +is enforced by a shared CI check, so there is no second format for an +adopting repository to supply. A hook or a template system here would exist +for a caller that does not exist. +""" + +from __future__ import annotations + +import re + +#: The bullet format of GitHub's generated release notes: +#: ``* type!: summary by @user in https://github.com/owner/repo/pull/123``. +#: The ``!`` is optional and marks a breaking change. +CHANGE_LINE_REGEX = re.compile( + r'^\* (?P\w+)(?P!?): (?P.*) by [^ ]+ in (?P.*)' +) + +#: The PR link in a bullet, from which the ``(#123)`` in a ``CHANGES.md`` +#: entry is taken. +PR_LINK_REGEX = re.compile(r'https?://[^ ]+/pull/(\d+)') + +#: GitHub appends a section of first-time contributors to its generated +#: notes. It is not part of the changelog, so it is stripped before parsing. +NEW_CONTRIBUTORS_REGEX = re.compile(r'(## New Contributors.*?)(\n|$)', flags=re.DOTALL) + +#: The line GitHub ends its generated notes with, carrying a compare link. +#: It is passed through to the release notes unchanged. +FULL_CHANGELOG_PREFIX = '**Full Changelog**' + +#: The categories a changelog has, in the order they are rendered. +#: +#: This is also the filter. A conventional-commit type that is not a key here +#: is dropped from the changelog entirely, and `chore` is the type that makes +#: that matter: it is a real type, accepted by the PR-title check, but it is +#: deliberately not a category. Dependency bumps, charm-pin updates and the +#: release's own version-bump commit are all `chore`, and none of them is +#: something a reader of a changelog is looking for. In a typical operator +#: release that is a third to a half of the commits in the range. Dropping +#: them is the intended behaviour, not an oversight in the type list, so +#: please do not "fix" it by adding a `chore` key. +#: +#: `breaking` goes the other way round: it is a key here but is not a +#: conventional-commit type, so nothing ever parses into it directly. A `!` +#: after the real type moves an entry into it instead, keeping its real type +#: as a prefix (`Feat: ...`), and it renders first. +CATEGORIES: tuple[str, ...] = ( + 'breaking', + 'feat', + 'fix', + 'docs', + 'test', + 'refactor', + 'perf', + 'ci', + 'revert', +) + +#: The meta category breaking changes are collected into. +BREAKING = 'breaking' + +#: Commit type to the heading it is rendered under. A type with no entry +#: here is capitalised instead, which is what makes an unrecognised type +#: degrade to something readable rather than to a KeyError. +CATEGORY_HEADINGS = { + 'feat': 'Features', + 'fix': 'Fixes', + 'docs': 'Documentation', + 'test': 'Tests', + 'ci': 'CI', + 'perf': 'Performance', + 'refactor': 'Refactoring', + 'revert': 'Reverted', + 'breaking': 'Breaking Changes', +} + +#: The sentence under the release notes' `### Breaking Changes` heading. A +#: `!` deliberately does not infer a major version bump -- a breaking change +#: sometimes rides in a minor release -- so this calling-out is what the bent +#: rule relies on. +BREAKING_PREAMBLE = 'There are breaking changes in this release. Please review them carefully:' diff --git a/changelog/src/charm_tech_code/changelog/_format.py b/changelog/src/charm_tech_code/changelog/_format.py new file mode 100644 index 0000000..27184f8 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_format.py @@ -0,0 +1,104 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Rendering parsed categories as release notes and as a changelog entry.""" + +from __future__ import annotations + +import datetime +import logging +from collections.abc import Mapping + +from ._constants import ( + BREAKING, + BREAKING_PREAMBLE, + CATEGORY_HEADINGS, + PR_LINK_REGEX, +) + +logger = logging.getLogger(__name__) + + +def commit_type_to_category(commit_type: str) -> str: + """Map a commit type to a human-readable category heading. + + If the commit type is not recognised, it returns the capitalised commit type. + """ + return CATEGORY_HEADINGS.get(commit_type, commit_type.capitalize()) + + +def format_release_notes( + categories: Mapping[str, list[tuple[str, str]]], full_changelog: str | None +) -> str: + """Format for release notes. + + Results in a Markdown formatted string with sections for each commit type. + If `full_changelog` is provided, it is appended at the end. + + Breaking changes are rendered first, under their own heading and a + sentence asking the reader to review them. `categories` is expected to + be what `parse_release_notes` returned: every category present, in the + order they are rendered in. + """ + lines = ["## What's Changed", ''] + if categories[BREAKING]: + lines.append(f'### {commit_type_to_category(BREAKING)}') + lines.append(f'{BREAKING_PREAMBLE}\n') + for description, pr_link in categories[BREAKING]: + lines.append(f'* {description} in {pr_link}') + lines.append('') + logger.info( + 'Breaking changes detected in the release notes. ' + 'Please ensure there are sufficient instructions for users to handle them.' + ) + for commit_type, items in categories.items(): + if commit_type == BREAKING: + continue + if items: + lines.append(f'### {commit_type_to_category(commit_type)}') + for description, pr_link in items: + lines.append(f'* {description} in {pr_link}') + lines.append('') + if full_changelog: + lines.append(full_changelog) + return '\n'.join(lines) + + +def format_changes( + categories: Mapping[str, list[tuple[str, str]]], tag: str, date: datetime.date +) -> str: + """Format for CHANGES.md. + + The header is formatted as a top-level heading with the tag and date. + The content is a Markdown formatted string with sections for each commit type. + Each item is formatted as a bullet point with the description and PR number in parentheses. + + `date` is passed in rather than read from the clock. This module does no + I/O of any kind, and "what day is it" is I/O: the caller knows whether it + means the runner's today, the date on the tag, or a date under test. + """ + day = date.strftime('%d %B %Y') + lines = [f'# {tag} - {day}\n'] + for commit_type, items in categories.items(): + if items: + lines.append(f'## {commit_type_to_category(commit_type)}\n') + for description, pr_link in items: + pr_num = '?' + match = PR_LINK_REGEX.match(pr_link) + if match: + pr_num = match.group(1) + lines.append(f'* {description} (#{pr_num})') + lines.append('') + return '\n'.join(lines) + '\n' diff --git a/changelog/src/charm_tech_code/changelog/_parse.py b/changelog/src/charm_tech_code/changelog/_parse.py new file mode 100644 index 0000000..dbe5805 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_parse.py @@ -0,0 +1,74 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Parsing GitHub's generated release-notes text into categories.""" + +from __future__ import annotations + +from ._constants import ( + BREAKING, + CATEGORIES, + CHANGE_LINE_REGEX, + FULL_CHANGELOG_PREFIX, + NEW_CONTRIBUTORS_REGEX, +) + + +def parse_release_notes(release_notes: str) -> tuple[dict[str, list[tuple[str, str]]], str | None]: + """Parse auto-generated release notes into categories. + + The input is GitHub's *generated* release-notes text, not a ``git log``. + GitHub builds it from the titles of the pull requests merged in the + range, one ``* type!: summary by @user in `` bullet each, which is + why this reads conventional-commit types off PR titles rather than off + commit subjects. How a caller obtains that text is the caller's problem: + a release already has it in its body, and a workflow running before any + release exists can ask for a preview of it. Nothing here does I/O. + + The "New Contributors" section is removed. Bullets whose type is not a + changelog category -- `chore`, most of all -- are dropped; see + ``_constants.CATEGORIES`` for why that is deliberate. The full-changelog + line is returned separately rather than categorised. + + Returns: + A tuple containing: + - A dict with conventional commit types as keys and lists of tuples + (description, PR link) as values. Every category is present, even + when empty, in the order they are rendered in. + - The full changelog line if present, or ``None`` if not found. + """ + release_notes = NEW_CONTRIBUTORS_REGEX.sub(r'\2', release_notes) + categories: dict[str, list[tuple[str, str]]] = {category: [] for category in CATEGORIES} + full_changelog_line = None + + for line in release_notes.splitlines(): + if match := CHANGE_LINE_REGEX.match(line.strip()): + category = match.group('category').strip() + if category in categories: + description = match.group('summary').strip() + description = description[0].upper() + description[1:] + pr_link = match.group('pr').strip() + if match.group('breaking') == '!': + categories[BREAKING].append(( + f'{category.capitalize()}: {description}', + pr_link, + )) + else: + categories[category].append((description, pr_link)) + + elif line.startswith(FULL_CHANGELOG_PREFIX): + full_changelog_line = line + + return categories, full_changelog_line diff --git a/changelog/tests/conftest.py b/changelog/tests/conftest.py new file mode 100644 index 0000000..93d2bae --- /dev/null +++ b/changelog/tests/conftest.py @@ -0,0 +1,53 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Nothing in this package may read the clock. + +`format_changes` used to call `datetime.datetime.now()` itself, which is what +made the output of a release depend on which day CI happened to run and made +the function impossible to assert on. The date is an argument now, and this +fixture is what keeps it one: it replaces the `datetime` module as `_format` +sees it, so a reinstated `now()` or `today()` call fails the whole suite +rather than quietly passing on every day except the one that matters. +""" + +from __future__ import annotations + +import pytest + +from charm_tech_code.changelog import _format + + +class _NoClock: + """Stands in for `datetime.datetime` and `datetime.date`.""" + + @staticmethod + def now(*args: object, **kwargs: object): + raise AssertionError( + 'The clock was read by the package itself. `format_changes` takes ' + 'a date argument precisely so that it does not do this.' + ) + + today = now + utcnow = now + + +class _NoClockModule: + datetime = _NoClock + date = _NoClock + + +@pytest.fixture(autouse=True) +def no_clock(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_format, 'datetime', _NoClockModule) diff --git a/changelog/tests/test_changelog.py b/changelog/tests/test_changelog.py new file mode 100644 index 0000000..bad86d3 --- /dev/null +++ b/changelog/tests/test_changelog.py @@ -0,0 +1,492 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the changelog package. + +The specification here is what `canonical/operator`'s `release.py` does +against real input, so the two large fixtures are real operator releases +rather than invented ones. Both are reconstructed from the repository's own +history: `git log --reverse 3.8.1..3.8.2` gives the merged pull requests in +the range, in merge order, and GitHub's generated notes are one +`* by @ in ` bullet each, in that same order, with the +PR title being the squash-commit subject minus its ` (#NNNN)` suffix. +""" + +from __future__ import annotations + +import datetime +import unittest + +from charm_tech_code.changelog import ( + CATEGORIES, + CATEGORY_HEADINGS, + commit_type_to_category, + format_changes, + format_release_notes, + parse_release_notes, +) + +# canonical/operator 3.8.2, released 31 August 2026. Twenty-three merged pull +# requests, eleven of them `chore`. Ali-932's was genuinely their first +# contribution to the repository, so the "New Contributors" section is real +# too. The two bot handles (`@dependabot`, `@prints-charming-bot`) are the +# only part of this not taken straight from the commits; nothing depends on +# them, since the parser only requires a single unspaced token after `by`. +OPERATOR_3_8_2_NOTES = """\ +## What's Changed +* chore: adjust versions after release by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2670 +* docs: give each best-practice admonition a stable :name: anchor by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2524 +* ci: point DB charm CI at the moved mysql-operators repo by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2551 +* chore: bump cryptography from 48.0.1 to 50.0.0 by @dependabot in https://github.com/canonical/operator/pull/2682 +* fix: compare full event paths when skipping duplicate notices by @Ali-932 in https://github.com/canonical/operator/pull/2684 +* chore: bump the actions group across 1 directory with 8 updates by @dependabot in https://github.com/canonical/operator/pull/2674 +* chore: bump the runtime group across 1 directory with 4 updates by @dependabot in https://github.com/canonical/operator/pull/2691 +* docs: reword text that vale 3.17 flags as misspelled by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2695 +* chore: update charm pins by @prints-charming-bot in https://github.com/canonical/operator/pull/2582 +* chore: bump the dev-tooling group in /examples/httpbin-demo with 2 updates by @dependabot in https://github.com/canonical/operator/pull/2675 +* chore: bump the charm-tech group across 1 directory with 3 updates by @dependabot in https://github.com/canonical/operator/pull/2697 +* docs: stop styling page references as blockquotes by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2666 +* ci: switch example charm integration tests to Concierge `k8s` preset by @dwilding in https://github.com/canonical/operator/pull/2696 +* docs: make the custom-endpoint-name sample test actually test something by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2664 +* ci: use the upstream concierge presets again by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2699 +* docs: replace `requests` by `urllib` in K8s tutorial integration tests by @dwilding in https://github.com/canonical/operator/pull/2687 +* fix: don't pass a message when converting an unknown status by name by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2700 +* chore: bump the dev-tooling group with 4 updates by @dependabot in https://github.com/canonical/operator/pull/2676 +* chore: update charm pins by @prints-charming-bot in https://github.com/canonical/operator/pull/2701 +* chore: adopt ruff 0.16's new lint conventions by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2698 +* docs: recommend spread directly, rather than charmcraft test by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2706 +* docs: extract sections in how to write integration tests to their own howto guide by @tromai in https://github.com/canonical/operator/pull/2662 +* chore: update changelog and versions for 3.8.2 release by @dwilding in https://github.com/canonical/operator/pull/2716 + +## New Contributors +* @Ali-932 made their first contribution in https://github.com/canonical/operator/pull/2684 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2 +""" + +# The PR numbers of the eleven `chore` pull requests in that release. None of +# them may appear anywhere in either rendered output. +OPERATOR_3_8_2_CHORE_PRS = ( + '2670', + '2682', + '2674', + '2691', + '2582', + '2675', + '2697', + '2676', + '2701', + '2698', + '2716', +) + +# Four real pull requests from the 3.7.1..3.8.0 range, in merge order, one of +# them the only `!` pull request operator has merged into a 3.x release +# (#2585). Trimmed to four bullets because the full range is fifty; the point +# of this fixture is the `!`, not the volume. +OPERATOR_BREAKING_NOTES = """\ +## What's Changed +* refactor: replace jsonpatch with an inline dict-diff by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2578 +* refactor!: move the otlp-json package to be a regular ops-tracing module by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2585 +* feat: note the socket path in Pebble tracing spans by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2555 +* fix: tear down `Runtime.exec()` when the charm raises by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2581 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0 +""" + + +class RealReleaseTests(unittest.TestCase): + """The 3.8.2 fixture, end to end.""" + + def setUp(self): + self.categories, self.full_changelog = parse_release_notes(OPERATOR_3_8_2_NOTES) + + def test_categories(self): + # Twelve of the twenty-three pull requests survive, in merge order. + assert self.categories == { + 'breaking': [], + 'feat': [], + 'fix': [ + ( + 'Compare full event paths when skipping duplicate notices', + 'https://github.com/canonical/operator/pull/2684', + ), + ( + "Don't pass a message when converting an unknown status by name", + 'https://github.com/canonical/operator/pull/2700', + ), + ], + 'docs': [ + ( + 'Give each best-practice admonition a stable :name: anchor', + 'https://github.com/canonical/operator/pull/2524', + ), + ( + 'Reword text that vale 3.17 flags as misspelled', + 'https://github.com/canonical/operator/pull/2695', + ), + ( + 'Stop styling page references as blockquotes', + 'https://github.com/canonical/operator/pull/2666', + ), + ( + 'Make the custom-endpoint-name sample test actually test something', + 'https://github.com/canonical/operator/pull/2664', + ), + ( + 'Replace `requests` by `urllib` in K8s tutorial integration tests', + 'https://github.com/canonical/operator/pull/2687', + ), + ( + 'Recommend spread directly, rather than charmcraft test', + 'https://github.com/canonical/operator/pull/2706', + ), + ( + 'Extract sections in how to write integration tests to their own howto guide', + 'https://github.com/canonical/operator/pull/2662', + ), + ], + 'test': [], + 'refactor': [], + 'perf': [], + 'ci': [ + ( + 'Point DB charm CI at the moved mysql-operators repo', + 'https://github.com/canonical/operator/pull/2551', + ), + ( + 'Switch example charm integration tests to Concierge `k8s` preset', + 'https://github.com/canonical/operator/pull/2696', + ), + ( + 'Use the upstream concierge presets again', + 'https://github.com/canonical/operator/pull/2699', + ), + ], + 'revert': [], + } + + def test_full_changelog_line(self): + assert self.full_changelog == ( + '**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2' + ) + + def test_chore_is_dropped(self): + # Eleven of the twenty-three pull requests are `chore`, and none of + # them reaches the output. Deliberate: dependency bumps, charm-pin + # updates and the release's own version bump are not changelog + # material. + notes = format_release_notes(self.categories, self.full_changelog) + entry = format_changes(self.categories, '3.8.2', datetime.date(2026, 8, 31)) + assert 'chore' not in notes.lower() + assert 'chore' not in entry.lower() + for pr in OPERATOR_3_8_2_CHORE_PRS: + assert pr not in notes, f'chore PR #{pr} leaked into the release notes' + assert pr not in entry, f'chore PR #{pr} leaked into the changelog entry' + + def test_new_contributors_section_is_dropped(self): + notes = format_release_notes(self.categories, self.full_changelog) + assert 'New Contributors' not in notes + assert 'made their first contribution' not in notes + + def test_changes_entry(self): + # This is the 3.8.2 entry as it appears in operator's CHANGES.md, + # except for three summaries where the committed CHANGES.md was + # edited by hand afterwards (#2700 gained an "In `ops.testing`," + # prefix, #2524 lost its ":name:", and #2662 was reworded). The + # section order, the bullet order within each section and the + # blank-line layout are all exactly what shipped. + assert ( + format_changes(self.categories, '3.8.2', datetime.date(2026, 8, 31)) + == """\ +# 3.8.2 - 31 August 2026 + +## Fixes + +* Compare full event paths when skipping duplicate notices (#2684) +* Don't pass a message when converting an unknown status by name (#2700) + +## Documentation + +* Give each best-practice admonition a stable :name: anchor (#2524) +* Reword text that vale 3.17 flags as misspelled (#2695) +* Stop styling page references as blockquotes (#2666) +* Make the custom-endpoint-name sample test actually test something (#2664) +* Replace `requests` by `urllib` in K8s tutorial integration tests (#2687) +* Recommend spread directly, rather than charmcraft test (#2706) +* Extract sections in how to write integration tests to their own howto guide (#2662) + +## CI + +* Point DB charm CI at the moved mysql-operators repo (#2551) +* Switch example charm integration tests to Concierge `k8s` preset (#2696) +* Use the upstream concierge presets again (#2699) + +""" + ) + + def test_release_notes(self): + assert ( + format_release_notes(self.categories, self.full_changelog) + == """\ +## What's Changed + +### Fixes +* Compare full event paths when skipping duplicate notices in https://github.com/canonical/operator/pull/2684 +* Don't pass a message when converting an unknown status by name in https://github.com/canonical/operator/pull/2700 + +### Documentation +* Give each best-practice admonition a stable :name: anchor in https://github.com/canonical/operator/pull/2524 +* Reword text that vale 3.17 flags as misspelled in https://github.com/canonical/operator/pull/2695 +* Stop styling page references as blockquotes in https://github.com/canonical/operator/pull/2666 +* Make the custom-endpoint-name sample test actually test something in https://github.com/canonical/operator/pull/2664 +* Replace `requests` by `urllib` in K8s tutorial integration tests in https://github.com/canonical/operator/pull/2687 +* Recommend spread directly, rather than charmcraft test in https://github.com/canonical/operator/pull/2706 +* Extract sections in how to write integration tests to their own howto guide in https://github.com/canonical/operator/pull/2662 + +### CI +* Point DB charm CI at the moved mysql-operators repo in https://github.com/canonical/operator/pull/2551 +* Switch example charm integration tests to Concierge `k8s` preset in https://github.com/canonical/operator/pull/2696 +* Use the upstream concierge presets again in https://github.com/canonical/operator/pull/2699 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2""" + ) + + def test_the_date_is_the_one_it_is_given(self): + entry = format_changes(self.categories, '3.8.2', datetime.date(2020, 1, 2)) + assert entry.startswith('# 3.8.2 - 02 January 2020\n') + + +class BreakingChangeTests(unittest.TestCase): + """A `!` moves an entry into its own category, keeping its real type.""" + + def setUp(self): + self.categories, self.full_changelog = parse_release_notes(OPERATOR_BREAKING_NOTES) + + def test_breaking_entry_keeps_its_real_type_as_a_prefix(self): + assert self.categories['breaking'] == [ + ( + 'Refactor: Move the otlp-json package to be a regular ops-tracing module', + 'https://github.com/canonical/operator/pull/2585', + ) + ] + + def test_breaking_entry_is_not_also_in_its_own_type(self): + # The `!` moves the entry rather than copying it, so #2585 leaves + # `refactor` and #2578, which has no `!`, is all that is left there. + assert self.categories['refactor'] == [ + ( + 'Replace jsonpatch with an inline dict-diff', + 'https://github.com/canonical/operator/pull/2578', + ) + ] + + def test_release_notes_put_breaking_first_with_a_warning(self): + assert ( + format_release_notes(self.categories, self.full_changelog) + == """\ +## What's Changed + +### Breaking Changes +There are breaking changes in this release. Please review them carefully: + +* Refactor: Move the otlp-json package to be a regular ops-tracing module in https://github.com/canonical/operator/pull/2585 + +### Features +* Note the socket path in Pebble tracing spans in https://github.com/canonical/operator/pull/2555 + +### Fixes +* Tear down `Runtime.exec()` when the charm raises in https://github.com/canonical/operator/pull/2581 + +### Refactoring +* Replace jsonpatch with an inline dict-diff in https://github.com/canonical/operator/pull/2578 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0""" + ) + + def test_changes_entry_puts_breaking_first_without_the_warning(self): + # The warning sentence belongs to the release notes only. A + # `CHANGES.md` entry is a list, and gets the heading alone. + assert ( + format_changes(self.categories, '3.8.0', datetime.date(2026, 6, 30)) + == """\ +# 3.8.0 - 30 June 2026 + +## Breaking Changes + +* Refactor: Move the otlp-json package to be a regular ops-tracing module (#2585) + +## Features + +* Note the socket path in Pebble tracing spans (#2555) + +## Fixes + +* Tear down `Runtime.exec()` when the charm raises (#2581) + +## Refactoring + +* Replace jsonpatch with an inline dict-diff (#2578) + +""" + ) + + +class ParseTests(unittest.TestCase): + """The bullet format, in detail.""" + + def parse(self, *bullets: str) -> dict[str, list[tuple[str, str]]]: + categories, _ = parse_release_notes('\n'.join(bullets)) + return categories + + def test_summary_is_capitalised(self): + # PR titles are lowercase after the conventional-commit type, and + # changelog bullets are sentence case. + categories = self.parse('* fix: do the thing by @someone in https://example.com/pull/1') + assert categories['fix'] == [('Do the thing', 'https://example.com/pull/1')] + + def test_summary_that_starts_with_a_backtick_is_left_alone(self): + categories = self.parse( + '* fix: `Runtime.exec()` tears down by @someone in https://example.com/pull/1' + ) + assert categories['fix'] == [('`Runtime.exec()` tears down', 'https://example.com/pull/1')] + + def test_unrecognised_type_is_dropped(self): + # `build` and `style` are conventional-commit types the PR-title + # check accepts, but they are not changelog categories, so they go + # the same way `chore` does. + categories = self.parse( + '* build: bump the wheel by @someone in https://example.com/pull/1', + '* style: reformat by @someone in https://example.com/pull/2', + '* nonsense: whatever by @someone in https://example.com/pull/3', + ) + assert all(not items for items in categories.values()) + + def test_breaking_on_a_dropped_type_is_still_dropped(self): + # The `!` is only honoured for a type that has a category, so a + # `chore!` does not sneak into the changelog through the breaking + # bucket. + categories = self.parse( + '* chore!: drop python 3.8 by @someone in https://example.com/pull/1' + ) + assert categories['breaking'] == [] + + def test_every_category_is_present_even_when_empty(self): + # Callers index `categories['breaking']` directly, and iterate the + # dict for the rendering order, so the shape does not depend on what + # happened to be in the release. + categories = self.parse('') + assert list(categories) == list(CATEGORIES) + + def test_lines_that_are_not_bullets_are_ignored(self): + categories, full_changelog = parse_release_notes( + "## What's Changed\n" + 'Some prose about the release.\n' + '* not a conventional commit title by @someone in https://example.com/pull/1\n' + '* fix: a real one by @someone in https://example.com/pull/2\n' + ) + assert categories['fix'] == [('A real one', 'https://example.com/pull/2')] + assert full_changelog is None + + def test_indented_bullets_are_parsed(self): + assert self.parse(' * fix: indented by @someone in https://example.com/pull/1')['fix'] + + def test_new_contributors_section_is_stripped_before_parsing(self): + # It is stripped rather than skipped, because its bullets are the + # same shape and would otherwise have to be excluded by luck. + categories, _ = parse_release_notes( + '* fix: a real one by @someone in https://example.com/pull/1\n' + '\n' + '## New Contributors\n' + '* @someone made their first contribution in https://example.com/pull/1\n' + ) + assert categories['fix'] == [('A real one', 'https://example.com/pull/1')] + + +class FormatReleaseNotesTests(unittest.TestCase): + def empty(self) -> dict[str, list[tuple[str, str]]]: + return {category: [] for category in CATEGORIES} + + def test_empty_release(self): + assert format_release_notes(self.empty(), None) == "## What's Changed\n" + + def test_categories_render_in_the_declared_order(self): + categories = self.empty() + for category in ('revert', 'ci', 'feat', 'fix'): + categories[category] = [(f'A {category} change', 'https://example.com/pull/1')] + headings = [ + line + for line in format_release_notes(categories, None).splitlines() + if line.startswith('###') + ] + assert headings == ['### Features', '### Fixes', '### CI', '### Reverted'] + + def test_full_changelog_is_appended_when_given(self): + notes = format_release_notes(self.empty(), '**Full Changelog**: https://example.com/x') + assert notes.endswith('**Full Changelog**: https://example.com/x') + + +class FormatChangesTests(unittest.TestCase): + def entry(self, pr_link: str) -> str: + categories = {category: [] for category in CATEGORIES} + categories['fix'] = [('A fix', pr_link)] + return format_changes(categories, '1.2.3', datetime.date(2026, 9, 10)) + + def test_pr_number_comes_from_the_link(self): + assert '* A fix (#2684)' in self.entry('https://github.com/canonical/operator/pull/2684') + + def test_an_unrecognisable_link_gets_a_question_mark(self): + # An entry with no PR to point at still belongs in the changelog, so + # this is a placeholder rather than a failure. + assert '* A fix (#?)' in self.entry('https://github.com/canonical/operator/commit/abc123') + + def test_empty_release(self): + empty = {category: [] for category in CATEGORIES} + assert format_changes(empty, '1.2.3', datetime.date(2026, 9, 10)) == ( + '# 1.2.3 - 10 September 2026\n\n' + ) + + def test_the_tag_is_used_verbatim(self): + empty = {category: [] for category in CATEGORIES} + assert format_changes(empty, '3.4.0b1', datetime.date(2026, 9, 10)).startswith( + '# 3.4.0b1 - ' + ) + + +class CommitTypeToCategoryTests(unittest.TestCase): + def test_known_types(self): + assert commit_type_to_category('feat') == 'Features' + assert commit_type_to_category('fix') == 'Fixes' + assert commit_type_to_category('docs') == 'Documentation' + assert commit_type_to_category('test') == 'Tests' + assert commit_type_to_category('ci') == 'CI' + assert commit_type_to_category('perf') == 'Performance' + assert commit_type_to_category('refactor') == 'Refactoring' + assert commit_type_to_category('revert') == 'Reverted' + assert commit_type_to_category('breaking') == 'Breaking Changes' + + def test_unknown_type_is_capitalised(self): + assert commit_type_to_category('whatever') == 'Whatever' + + def test_chore_has_no_category(self): + # It has no heading because it is not a category. That it still + # returns something readable is a property of the fallback, not an + # invitation to render it. + assert 'chore' not in CATEGORY_HEADINGS + assert 'chore' not in CATEGORIES + + def test_every_category_has_a_heading(self): + # Otherwise a category would render under a capitalised version of + # its own key, which is only ever right by accident. + assert set(CATEGORIES) <= set(CATEGORY_HEADINGS) diff --git a/changelog/uv.lock b/changelog/uv.lock new file mode 100644 index 0000000..b6f3feb --- /dev/null +++ b/changelog/uv.lock @@ -0,0 +1,156 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "charm-tech-code-changelog" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +unit = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +unit = [{ name = "pytest" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From 8fc68e3e532ba51a8a73d64923ffa25a82912a8c Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 11 Sep 2026 10:51:54 +1200 Subject: [PATCH 2/4] Apply suggestion from @tonyandrewmeyer --- changelog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/README.md b/changelog/README.md index 04167f2..814fa59 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -2,7 +2,7 @@ Turns GitHub's generated release notes into our changelog format. -Lifted out of `canonical/operator`'s `release.py`, which is being replaced by three workflows ([canonical/operator#2224](https://github.com/canonical/operator/issues/2224)). The formatting is the part that isn't specific to one repository, so it lives here and the version arithmetic, the file rewriting and the GitHub calls stay behind in operator. +The Charm Tech repositories have different release processes, but aim for a consistent changelog style. The formatting and the version arithmetic are centralised here, the file rewriting and the GitHub calls are in each repository. ## Using it From 1240b170953ff20d37969ea48726ccbc34fc88cb Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 11 Sep 2026 10:54:51 +1200 Subject: [PATCH 3/4] Apply suggestion from @tonyandrewmeyer --- changelog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/README.md b/changelog/README.md index 814fa59..668de67 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -35,7 +35,7 @@ Neither shape is injectable, and neither is the map of commit type to heading. T Two things about that map are worth knowing before you decide it's wrong: -* `chore` is a type but not a category, so `chore` commits are dropped. That's deliberate. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and in a typical operator release they're a third to a half of the commits in the range. +* `chore` is a type but not a category, so `chore` commits are deliberately dropped. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and these sorts of changes are not interesting to our users, and they are available via `git log` if anyone does want them. * `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A `!` deliberately doesn't infer a major version bump, so that calling-out is the only thing marking it. ## Developing From e7d2f11d1f6ab71d10495fee673551e8dce71af9 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 11 Sep 2026 10:57:04 +1200 Subject: [PATCH 4/4] Apply suggestion from @tonyandrewmeyer --- changelog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/README.md b/changelog/README.md index 668de67..7b01643 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -36,7 +36,7 @@ Neither shape is injectable, and neither is the map of commit type to heading. T Two things about that map are worth knowing before you decide it's wrong: * `chore` is a type but not a category, so `chore` commits are deliberately dropped. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and these sorts of changes are not interesting to our users, and they are available via `git log` if anyone does want them. -* `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A `!` deliberately doesn't infer a major version bump, so that calling-out is the only thing marking it. +* `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A `!` should be a major version bump, but if it's appearing here then we have decided to cheat the semver rules and allow a breaking change in a minor release. This should be rare. We will have carefully checked the impact before this decision, but want to make sure the change is particularly noticeable in the changelog. ## Developing