diff --git a/changelog/README.md b/changelog/README.md index d5da76e..06307a7 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -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 `` and `` 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**: ` 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 `` and `` 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**: ` 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: `, taking the summary from between the `` and `` 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. diff --git a/changelog/src/charm_tech_code/changelog/_constants.py b/changelog/src/charm_tech_code/changelog/_constants.py index 33aee83..c60664d 100644 --- a/changelog/src/charm_tech_code/changelog/_constants.py +++ b/changelog/src/charm_tech_code/changelog/_constants.py @@ -300,15 +300,14 @@ #: `# 3.8.3 - 22 September 2026`. CHANGES_SECTION_HEADING_REGEX = re.compile(r'^# (?P\S+) - (?P.*)$') -#: 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@[A-Za-z\d][A-Za-z\d-]*(?:\[bot\])?)' - r'(?: \(\[#\d+\]\([^)]*\)\))?' - r'|(?P[^()\n]+?) \(\[#\d+\]\([^)]*\)\))[ \t]*$', + r'^\* .* by (?P@[A-Za-z\d][A-Za-z\d-]*(?:\[bot\])?)' + r'(?: \(\[#\d+\]\([^)]*\)\))?[ \t]*$', re.MULTILINE, ) diff --git a/changelog/src/charm_tech_code/changelog/_release_body.py b/changelog/src/charm_tech_code/changelog/_release_body.py index f9b228e..1f47bc6 100644 --- a/changelog/src/charm_tech_code/changelog/_release_body.py +++ b/changelog/src/charm_tech_code/changelog/_release_body.py @@ -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. @@ -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: diff --git a/changelog/tests/test_release.py b/changelog/tests/test_release.py index 635080b..1e8210c 100644 --- a/changelog/tests/test_release.py +++ b/changelog/tests/test_release.py @@ -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' @@ -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' )