Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ changelog release-title --version "$VERSION" < pr-description.md

* **`detect-release`** decides, from the before and after versions alone, whether a push to a release branch is a release: the version changed, and the new one has no `.devN` suffix. It prints `version=` and `prerelease=` lines when it is, and nothing when it is not, so a later step can test for the output. A post-release bump writes a `.devN` version, so it is never mistaken for a release.
* **`post-release`** works out which branch a published release was cut from, and the development version that branch goes to now: a pre-release drops its suffix (3.4.0b3 leaves `3.4.0.dev0`), a `-maintenance` branch bumps the patch (2.23.5 leaves `2.23.6.dev0`), and anything else bumps the minor (3.8.2 leaves `3.9.0.dev0`). The branch comes from which release branches contain the tag, with the default branch (`--default-branch`, `main` unless you say otherwise) winning a tie. It prints `branch=` and `version=` lines. The version is a placeholder that stops the tree claiming to be the release just published; nothing counts from it.
* **`release-body`** takes the release notes from between the `<!-- release-notes:start -->` and `<!-- release-notes:end -->` markers in a merged release pull request's description, and prints them followed by this version's section of the changelog, with its headings moved down a level. The changelog half is copied from the file rather than generated again, so the two cannot drift. Notes that are still `draft-release-notes`' placeholder go in as they stand, with a line on stderr saying so. With `--compare-url`, the changelog is followed by an `**All commits**: <url>` line. The body ends with a sentence thanking anyone the changelog section credits ("Thanks @someone for your contribution to this release!"), read back out of the section so that edits made to `CHANGES.md` in the release pull request carry through. Nobody is mentioned anywhere earlier in the process, so a contributor is notified about the release, not about the pull request that prepares it.
* **`release-body`** takes the release notes from between the `<!-- release-notes:start -->` and `<!-- release-notes:end -->` markers in a merged release pull request's description, and prints them followed by this version's section of the changelog, with its headings moved down a level. The changelog half is copied from the file rather than generated again, so the two cannot drift. Notes that are still `draft-release-notes`' placeholder go in as they stand, with a line on stderr saying so. With `--compare-url`, the changelog is followed by an `**All commits**: <url>` line. The body ends with a sentence thanking anyone the changelog section credits by handle ("Thanks @someone for your contribution to this release!"), read back out of the section so that edits made to `CHANGES.md` in the release pull request carry through. Nobody is mentioned anywhere earlier in the process, so a contributor is notified about the release, not about the pull request that prepares it. Someone credited by name isn't thanked there, because `* Warm the pot by Hazel Grouse (#103)` reads the same as `* Deselect the checks by default (#391)`.
* **`release-title`** prints the release's title, `X.Y.Z: <summary>`, taking the summary from between the `<!-- release-title:start -->` and `<!-- release-title:end -->` markers in the same description. The version is always added here (and taken off the front of the summary if someone typed it there too), so the title can't name a different version from the release. No markers, broken markers, or `draft-release-notes`' title placeholder give the bare version, with a line on stderr saying why; it never fails, because a release can go out titled with just its version and the title can still be edited on the draft.

The two that print `key=value` lines are for appending to `$GITHUB_OUTPUT`. On failure they print nothing on stdout and exit non-zero, so a failed step cannot half-write it.
Expand Down
15 changes: 7 additions & 8 deletions changelog/src/charm_tech_code/changelog/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,14 @@
#: `# 3.8.3 - 22 September 2026`.
CHANGES_SECTION_HEADING_REGEX = re.compile(r'^# (?P<version>\S+) - (?P<date>.*)$')

#: The credit on a `CHANGES.md` entry, as `format_changes` writes it: ` by
#: @handle` or ` by Name`, then the pull-request link. A name is only read
#: where the link follows it, because without the link there is nothing to
#: tell "by Name" from a summary that happens to say "by" in it. A handle is
#: unambiguous either way.
#: A handle credit on a `CHANGES.md` entry, as `format_changes` writes it:
#: ` by @handle`, then the pull-request link if there is one. A credit by
#: name (` by Name`) is not matched, because nothing tells it apart from a
#: summary that ends in "by" something: "Deselect the checks by default"
#: would thank "default".
CHANGES_CREDIT_REGEX = re.compile(
r'^\* .* by (?:(?P<handle>@[A-Za-z\d][A-Za-z\d-]*(?:\[bot\])?)'
r'(?: \(\[#\d+\]\([^)]*\)\))?'
r'|(?P<name>[^()\n]+?) \(\[#\d+\]\([^)]*\)\))[ \t]*$',
r'^\* .* by (?P<handle>@[A-Za-z\d][A-Za-z\d-]*(?:\[bot\])?)'
r'(?: \(\[#\d+\]\([^)]*\)\))?[ \t]*$',
re.MULTILINE,
)

Expand Down
19 changes: 12 additions & 7 deletions changelog/src/charm_tech_code/changelog/_release_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@
generated a second time: one source of truth, so the file and the release
cannot drift.

The body ends by thanking whoever the changelog credits. That is built here
rather than drafted with the notes, so that nobody is mentioned in the release
pull request: a contributor should be notified about the release, not about
the pull request that prepares it.
The body ends by thanking whoever the changelog credits by handle. That is
built here rather than drafted with the notes, so that nobody is mentioned in
the release pull request: a contributor should be notified about the release,
not about the pull request that prepares it.

The title's summary comes out of the same description, from between the
`release-title` markers, and the version in front of it is added here.
Expand Down Expand Up @@ -214,17 +214,22 @@ def release_body(notes: str, section: str, compare_url: str | None = None) -> st


def thanks(section: str) -> str | None:
"""Return a sentence thanking the people a changelog section credits.
"""Return a sentence thanking the people a changelog section credits by handle.

The credits are read back out of the section's entries rather than passed
in, because the section is what a human has reviewed: a credit removed
from `CHANGES.md` in the release pull request is not thanked, and one
added there is. Each person is thanked once, in the order they first
appear. A section that credits nobody gives `None`, and no sentence.
appear. A section that credits nobody by handle gives `None`, and no
sentence.

Someone credited by name keeps the credit on their entry but is not
thanked here: a name can't be told apart from a summary that ends in
"by" something, and only a handle notifies them anyway.
"""
people: list[str] = []
for match in CHANGES_CREDIT_REGEX.finditer(section):
person = (match.group('handle') or match.group('name')).strip()
person = match.group('handle')
if person not in people:
people.append(person)
if not people:
Expand Down
21 changes: 18 additions & 3 deletions changelog/tests/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,11 +482,26 @@ def test_one_contributor(self):
assert thanks(section) == 'Thanks @ducky-debugger for your contribution to this release!'

def test_several_are_each_thanked_once_in_order(self):
assert thanks(CREDITED_CHANGES) == (
'Thanks @ducky-debugger, Hazel Grouse, and @tea-cosy'
section = (
CREDITED_CHANGES + '* Pour it out by @milk-first ([#106](https://example.com/106))\n'
)
assert thanks(section) == (
'Thanks @ducky-debugger, @tea-cosy, and @milk-first'
' for your contributions to this release!'
)

def test_a_name_credit_is_not_thanked(self):
# "by Hazel Grouse" reads the same as a summary ending in "by"
# something, so only handles are thanked.
assert 'Hazel Grouse' not in (thanks(CREDITED_CHANGES) or '')

@pytest.mark.parametrize(
'summary', ['Deselect the checks by default', 'Handle errors raised by Pebble']
)
def test_by_in_a_summary_with_a_link_is_not_a_credit(self, summary: str):
section = f'# 3.8.3 - 22 September 2026\n\n## Fixes\n\n* {summary} ([#391](https://example.com/391))\n'
assert thanks(section) is None

def test_two_have_no_comma(self):
section = CREDITED_CHANGES.replace(' by Hazel Grouse', '').replace(' by @tea-cosy', '')
section += '* Steep it longer by @tea-cosy ([#105](https://example.com/105))\n'
Expand All @@ -502,7 +517,7 @@ def test_it_is_the_last_line_of_the_body(self):
url = 'https://github.com/canonical/operator/compare/3.8.2...3.8.3'
body = release_body('The notes.', CREDITED_CHANGES, url)
assert body.endswith(
f'**All commits**: {url}\n\nThanks @ducky-debugger, Hazel Grouse, and @tea-cosy'
f'**All commits**: {url}\n\nThanks @ducky-debugger and @tea-cosy'
' for your contributions to this release!\n'
)

Expand Down
Loading