From 0b698eb41d12c15f8eafed02dd67358e995e6e95 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Sun, 9 Aug 2026 15:23:17 +0000 Subject: [PATCH 1/5] Fix #427: create GitHub Releases with per-package changelog notes The Publish workflow tags releases but never creates a GitHub Release, so dependabot has no per-package release notes to render when it opens a bump PR and falls back to showing the whole monorepo diff. Add an extract-changelog.py helper that pulls a single version's section out of a package CHANGELOG.md, thread the version through the unify-publish-inputs matrix, and wire a Create GitHub Release step into the publish workflow after the tag push. --- .github/extract-changelog.py | 67 +++++++++++++++++++++++++++++++++ .github/unify-publish-inputs.py | 9 ++++- .github/workflows/publish.yaml | 20 +++++++++- 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100755 .github/extract-changelog.py diff --git a/.github/extract-changelog.py b/.github/extract-changelog.py new file mode 100755 index 000000000..25469e16d --- /dev/null +++ b/.github/extract-changelog.py @@ -0,0 +1,67 @@ +# 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. + +"""Print a single version's section from a package CHANGELOG.md. + +Package CHANGELOGs in this repo use one ``# - `` heading per +release. Given a CHANGELOG path and a version string, print the block from +that heading up to the next ``# `` heading (exclusive), so that a release +workflow can pass it to ``gh release create --notes-file`` and give +dependabot something useful to render for a single-package version bump. + +Exits non-zero if the version is not found, so the caller notices the +missing changelog entry rather than publishing an empty release body. +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + + +def extract(text: str, version: str) -> str | None: + """Return the CHANGELOG section for ``version``, or ``None`` if absent.""" + heading = re.compile(r'^# (?P\S+)\b.*$', re.MULTILINE) + matches = list(heading.finditer(text)) + for i, match in enumerate(matches): + if match.group('version') == version: + start = match.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + return text[start:end].strip() + return None + + +def main() -> int: + """Parse CLI arguments and print the requested changelog section.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('changelog', type=pathlib.Path, help='Path to CHANGELOG.md.') + parser.add_argument('version', help='Version to extract (e.g. 1.3.0.post0).') + args = parser.parse_args() + + section = extract(args.changelog.read_text(), args.version) + if section is None: + print( + f'Version {args.version!r} not found in {args.changelog}', + file=sys.stderr, + ) + return 1 + + print(section) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/unify-publish-inputs.py b/.github/unify-publish-inputs.py index c5eca140f..25e98272d 100644 --- a/.github/unify-publish-inputs.py +++ b/.github/unify-publish-inputs.py @@ -39,7 +39,10 @@ def _main() -> None: '--output=version', ] items = json.loads(subprocess.check_output(cmd, text=True)) - include = [{'package': item['path'], 'tag': _get_tag(item)} for item in items] + include = [ + {'package': item['path'], 'tag': _get_tag(item), 'version': item['version']} + for item in items + ] _output({ 'include': json.dumps(include), 'skip-juju': 'false', @@ -47,7 +50,9 @@ def _main() -> None: }) elif event_name == 'workflow_dispatch': _output({ - 'include': json.dumps([{'package': event['inputs']['package'], 'tag': ''}]), + 'include': json.dumps([ + {'package': event['inputs']['package'], 'tag': '', 'version': ''} + ]), 'skip-juju': event['inputs']['skip-juju'], 'repository-url': 'https://test.pypi.org/legacy/', }) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 341eb5bc1..900c48dbb 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -63,7 +63,7 @@ jobs: permissions: id-token: write attestations: write - contents: write # needed to push tag + contents: write # needed to push tag and create release steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -93,3 +93,21 @@ jobs: git config user.email github-actions@github.com git tag ${{ matrix.tag }} git push origin ${{ matrix.tag }} + - name: Create GitHub Release + if: ${{ matrix.tag }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ matrix.tag }} + PACKAGE: ${{ matrix.package }} + VERSION: ${{ matrix.version }} + run: | + set -xueo pipefail + # Give dependabot per-package release notes to render when it + # opens a bump PR: without a release for the tag it either shows + # nothing or the whole monorepo diff. See issue #427. + notes_file="$(mktemp)" + python3 .github/extract-changelog.py "${PACKAGE}/CHANGELOG.md" "${VERSION}" \ + > "${notes_file}" + gh release create "${TAG}" \ + --title "${TAG}" \ + --notes-file "${notes_file}" From 250e804f2b383f7f42868d03959433fc7257c4ba Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 31 Aug 2026 18:48:50 +1200 Subject: [PATCH 2/5] test: cover the changelog extraction The parsing is the fragile part -- a heading format it doesn't expect means an empty or wrong release body -- so pin the behaviour: first, middle and last sections, a missing version, exact rather than prefix matching, post releases, a heading with no date, and subheadings. --- .scripts/tests/test_extract_changelog.py | 107 +++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .scripts/tests/test_extract_changelog.py diff --git a/.scripts/tests/test_extract_changelog.py b/.scripts/tests/test_extract_changelog.py new file mode 100644 index 000000000..6b6424e1b --- /dev/null +++ b/.scripts/tests/test_extract_changelog.py @@ -0,0 +1,107 @@ +# 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. + +# ruff: noqa: D103 (function docstrings) + +"""Unit tests for the extract-changelog script. + +The script lives in `.github/` and its filename isn't a valid module name, so +it's loaded by path rather than imported. +""" + +from __future__ import annotations + +import importlib.util +import pathlib +import typing + +import pytest + +if typing.TYPE_CHECKING: + import types + +_REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +_SCRIPT = _REPO_ROOT / '.github' / 'extract-changelog.py' + + +def _load() -> types.ModuleType: + spec = importlib.util.spec_from_file_location('extract_changelog', _SCRIPT) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +extract_changelog = _load() + +CHANGELOG = """\ +# 1.3.0 - 2 June 2026 + +Widen the pattern argument. + +Second paragraph. + +# 1.2.1 - 6 February 2026 + +Only promise an `Iterator`. + +# 1.2.0 - 1 January 2026 + +Require Python 3.10. +""" + + +def test_first_section(): + section = extract_changelog.extract(CHANGELOG, '1.3.0') + assert section == 'Widen the pattern argument.\n\nSecond paragraph.' + + +def test_middle_section(): + section = extract_changelog.extract(CHANGELOG, '1.2.1') + assert section == 'Only promise an `Iterator`.' + + +def test_last_section_runs_to_end_of_file(): + section = extract_changelog.extract(CHANGELOG, '1.2.0') + assert section == 'Require Python 3.10.' + + +def test_missing_version(): + assert extract_changelog.extract(CHANGELOG, '9.9.9') is None + + +def test_version_is_matched_exactly_not_by_prefix(): + # '1.2' must not match the '1.2.1' or '1.2.0' headings. + assert extract_changelog.extract(CHANGELOG, '1.2') is None + + +def test_post_release_version(): + text = '# 1.3.0.post0 - 16 June 2026\n\nUpdate project URLs.\n' + assert extract_changelog.extract(text, '1.3.0.post0') == 'Update project URLs.' + + +def test_heading_without_a_date(): + text = '# 1.0.0\n\nFirst release.\n' + assert extract_changelog.extract(text, '1.0.0') == 'First release.' + + +def test_subheadings_are_kept(): + text = '# 2.0.0 - 1 January 2026\n\n## Fixes\n\nA fix.\n\n# 1.0.0 - 1 January 2025\n\nOld.\n' + assert extract_changelog.extract(text, '2.0.0') == '## Fixes\n\nA fix.' + + +@pytest.mark.parametrize('version', ['1.3.0', '1.2.1', '1.2.0']) +def test_every_heading_is_findable(version: str): + assert extract_changelog.extract(CHANGELOG, version) is not None From e2edf1a476cbdec8073285470030183a82a84e5d Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 7 Sep 2026 14:51:28 +1200 Subject: [PATCH 3/5] ci: check the release notes parse when a version changes The publish workflow feeds a CHANGELOG section to gh release create. If the heading doesn't match what the extractor looks for, that fails at release time, when the package has already been built. Parse it in the same job that checks the changelog was updated, so it fails on the PR instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019e4ws34Es9T8rKzwu3zW93 --- .github/workflows/ci.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 71d76a740..13911148a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -197,6 +197,17 @@ jobs: : 'CHANGELOG.md must be updated before merging :(' exit 1 fi + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - name: Ensure the release notes can be extracted from CHANGELOG.md + env: + PACKAGE: ${{ matrix.package }} + run: | + set -xueo pipefail + # The publish workflow feeds this section to `gh release create + # --notes-file`. Parse it here too, so a heading the extractor + # can't match fails the PR rather than the release. + VERSION=$(.scripts/ls.py packages --regex "^${PACKAGE}$" --output-only version --no-json) + python3 .github/extract-changelog.py "${PACKAGE}/CHANGELOG.md" "${VERSION}" changelogs-updated-if-releasing: # GitHub status checks require a static job name needs: [changelog-updated] From 2e70fcee13a53026c52233fb8535aa87b8e975fd Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 7 Sep 2026 18:20:24 +1200 Subject: [PATCH 4/5] fix: read the other two changelog conventions in the release notes The extractor only understood "# - ", which is what most packages use. The interfaces packages open with a prose "# Changelog" and put versions at H2, and otlp and sloth follow Keep a Changelog and bracket the version. Releasing any of those would have produced no notes and a non-zero exit. Match a heading at any level whose first word, brackets stripped, is the version, and end its section at the next heading of the same level or shallower, so subheadings inside a Keep a Changelog entry are kept. Every publishable package's current version now extracts, apart from snap, which is on 2.0.0.dev0 with no matching heading yet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M9XjwzMSP5qVzPxtNZiwwQ --- .github/extract-changelog.py | 44 ++++++++++++++++++------ .scripts/tests/test_extract_changelog.py | 40 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/.github/extract-changelog.py b/.github/extract-changelog.py index 25469e16d..ff0bdf875 100755 --- a/.github/extract-changelog.py +++ b/.github/extract-changelog.py @@ -14,11 +14,18 @@ """Print a single version's section from a package CHANGELOG.md. -Package CHANGELOGs in this repo use one ``# - `` heading per -release. Given a CHANGELOG path and a version string, print the block from -that heading up to the next ``# `` heading (exclusive), so that a release -workflow can pass it to ``gh release create --notes-file`` and give -dependabot something useful to render for a single-package version bump. +Given a CHANGELOG path and a version string, print the block for that +version, so that a release workflow can pass it to ``gh release create +--notes-file`` and give dependabot something useful to render for a +single-package version bump. + +CHANGELOGs in this repo do not all look the same. Most packages use one +``# - `` heading per release, but the interfaces packages +open with a prose ``# Changelog`` and put versions at H2, and a couple of +them follow Keep a Changelog and bracket the version: ``## [0.5.0] - ...``. +So a heading at any level counts if its first word, with any surrounding +brackets removed, is the version we want, and its section runs to the next +heading at the same level or shallower. Exits non-zero if the version is not found, so the caller notices the missing changelog entry rather than publishing an empty release body. @@ -31,16 +38,31 @@ import re import sys +_HEADING = re.compile(r'^(?P#{1,6})[ \t]+(?P\S.*)$', re.MULTILINE) + + +def _version_of(title: str) -> str: + """Return the version a heading names, or '' if it doesn't name one. + + Keep a Changelog brackets the version, so strip those. + """ + return title.split()[0].strip('[]') + def extract(text: str, version: str) -> str | None: """Return the CHANGELOG section for ``version``, or ``None`` if absent.""" - heading = re.compile(r'^# (?P<version>\S+)\b.*$', re.MULTILINE) - matches = list(heading.finditer(text)) + matches = list(_HEADING.finditer(text)) for i, match in enumerate(matches): - if match.group('version') == version: - start = match.end() - end = matches[i + 1].start() if i + 1 < len(matches) else len(text) - return text[start:end].strip() + if _version_of(match.group('title')) != version: + continue + level = len(match.group('hashes')) + start = match.end() + end = len(text) + for later in matches[i + 1 :]: + if len(later.group('hashes')) <= level: + end = later.start() + break + return text[start:end].strip() return None diff --git a/.scripts/tests/test_extract_changelog.py b/.scripts/tests/test_extract_changelog.py index 6b6424e1b..e27a34658 100644 --- a/.scripts/tests/test_extract_changelog.py +++ b/.scripts/tests/test_extract_changelog.py @@ -105,3 +105,43 @@ def test_subheadings_are_kept(): @pytest.mark.parametrize('version', ['1.3.0', '1.2.1', '1.2.0']) def test_every_heading_is_findable(version: str): assert extract_changelog.extract(CHANGELOG, version) is not None + + +# The interfaces packages open with a prose H1 and put versions at H2. +def test_extract_h2_versions_under_a_prose_h1(): + text = ( + '# Changelog\n\nAll notable changes are documented here.\n\n' + '## 1.1.0 - 2 June 2026\n\nsecond\n\n' + '## 1.0.0 - 1 June 2026\n\nfirst\n' + ) + assert extract_changelog.extract(text, '1.1.0') == 'second' + assert extract_changelog.extract(text, '1.0.0') == 'first' + + +# otlp and sloth follow Keep a Changelog, which brackets the version. +def test_extract_keep_a_changelog_bracketed_version(): + text = ( + '# Changelog\n\nThe format is based on Keep a Changelog.\n\n' + '## [0.5.0] - 2026-01-02\n\n### Updated\n\n- newer\n\n' + '## [0.4.0] - 2026-01-01\n\n- older\n' + ) + got = extract_changelog.extract(text, '0.5.0') + assert got is not None + assert '- newer' in got + assert '- older' not in got + + +# A subheading inside a version's section must not end it. +def test_extract_keeps_deeper_subheadings(): + text = '## [0.5.0] - 2026-01-02\n\n### Added\n\n- a\n\n### Fixed\n\n- b\n\n## [0.4.0]\n\nold\n' + got = extract_changelog.extract(text, '0.5.0') + assert got is not None + assert '### Added' in got + assert '### Fixed' in got + assert 'old' not in got + + +# The prose heading must not be picked up as part of a version's section. +def test_extract_does_not_include_the_prose_heading(): + text = '# Changelog\n\nprose\n\n## 1.0.0 - 1 June 2026\n\nreal\n' + assert extract_changelog.extract(text, '1.0.0') == 'real' From 86fe95c4b6a9769e7968653d48583f2dcf840f16 Mon Sep 17 00:00:00 2001 From: Tony Meyer <tony.meyer@canonical.com> Date: Wed, 16 Sep 2026 14:23:55 +1200 Subject: [PATCH 5/5] fix(ci): don't truncate release notes at a code fence, and make the release retryable `_HEADING` was applied to the whole changelog with `re.MULTILINE`, so a Python comment inside a fenced example counted as a heading. A `# Before` in a 2.0.0 migration snippet ended the section there: the notes lost the example and everything after it, and finished on an unterminated fence, which renders as a broken block. The new CI guard didn't catch it either, since the result is non-empty and the exit code is 0. No changelog in the tree has a fence today, which is why this was invisible - but a major version's entry is exactly where one appears. Headings are now found line by line, skipping fenced regions. The Tag step pushed the tag and the next step created the release, so a failed release could not be retried: a re-run starts from a clean checkout and dies at the tag already being on the remote, never reaching the release again. `gh release create` creates the tag itself, so the two are one step now, with `--target` pinning it to the published commit (which is what `--verify-tag` would have been protecting against, and which that flag can't do here since there is no longer a tag to verify). An existing release is left alone rather than failing the job. The script moves to `.scripts/extract_changelog.py`, where the repo's other CI-invoked helpers live and where a valid module name lets the tests import it instead of loading it by path. While there: two tests for `main`'s exit code and message, which is the path the whole "the caller notices" argument rests on; a test for the fence case; a dropped test that could only fail if the three above it failed first; and `--exclude-examples --exclude-placeholders` on the guard's `ls.py` call, matching the other one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/ci.yaml | 4 +- .github/workflows/publish.yaml | 22 ++--- .../extract_changelog.py | 62 +++++++++++--- .scripts/tests/test_extract_changelog.py | 83 ++++++++++++------- 4 files changed, 118 insertions(+), 53 deletions(-) rename .github/extract-changelog.py => .scripts/extract_changelog.py (57%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0af2ccff3..dc849b9d2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -206,8 +206,8 @@ jobs: # The publish workflow feeds this section to `gh release create # --notes-file`. Parse it here too, so a heading the extractor # can't match fails the PR rather than the release. - VERSION=$(.scripts/ls.py packages --regex "^${PACKAGE}$" --output-only version --no-json) - python3 .github/extract-changelog.py "${PACKAGE}/CHANGELOG.md" "${VERSION}" + VERSION=$(.scripts/ls.py packages --regex "^${PACKAGE}$" --exclude-examples --exclude-placeholders --output-only version --no-json) + python3 .scripts/extract_changelog.py "${PACKAGE}/CHANGELOG.md" "${VERSION}" changelogs-updated-if-releasing: # GitHub status checks require a static job name needs: [changelog-updated] diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index d8759cf24..23034b69a 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -85,29 +85,31 @@ jobs: packages-dir: ./${{ matrix.package }}/dist/ repository-url: ${{ needs.unify-inputs.outputs.repository-url }} verbose: true - - name: Tag - if: ${{ matrix.tag }} - run: | - set -xueo pipefail - git config user.name github-actions - git config user.email github-actions@github.com - git tag ${{ matrix.tag }} - git push origin ${{ matrix.tag }} - - name: Create GitHub Release + - name: Tag and create GitHub Release if: ${{ matrix.tag }} env: GH_TOKEN: ${{ github.token }} TAG: ${{ matrix.tag }} PACKAGE: ${{ matrix.package }} VERSION: ${{ matrix.version }} + SHA: ${{ github.sha }} run: | set -xueo pipefail # Give dependabot per-package release notes to render when it # opens a bump PR: without a release for the tag it either shows # nothing or the whole monorepo diff. See issue #427. notes_file="$(mktemp)" - python3 .github/extract-changelog.py "${PACKAGE}/CHANGELOG.md" "${VERSION}" \ + python3 .scripts/extract_changelog.py "${PACKAGE}/CHANGELOG.md" "${VERSION}" \ > "${notes_file}" + # `gh release create` creates the tag itself, so this is one step + # rather than a `git push` followed by a release that cannot be + # retried: re-running a failed job used to die on the tag already + # being on the remote, and never reach the release again. + if gh release view "${TAG}" >/dev/null 2>&1; then + echo "Release ${TAG} already exists; not recreating it." + exit 0 + fi gh release create "${TAG}" \ + --target "${SHA}" \ --title "${TAG}" \ --notes-file "${notes_file}" diff --git a/.github/extract-changelog.py b/.scripts/extract_changelog.py similarity index 57% rename from .github/extract-changelog.py rename to .scripts/extract_changelog.py index ff0bdf875..26d94276e 100755 --- a/.github/extract-changelog.py +++ b/.scripts/extract_changelog.py @@ -38,7 +38,43 @@ import re import sys -_HEADING = re.compile(r'^(?P<hashes>#{1,6})[ \t]+(?P<title>\S.*)$', re.MULTILINE) +_HEADING = re.compile(r'^(?P<hashes>#{1,6})[ \t]+(?P<title>\S.*)$') +_FENCE = re.compile(r'^(?P<fence>`{3,}|~{3,})') + + +def _headings(text: str) -> list[tuple[int, int, int]]: + """Return (level, start, end) for each heading outside a fenced code block. + + A changelog entry for a breaking change tends to carry a before/after + example, and a Python comment inside that example starts with a '#'. Scanned + line by line rather than with one MULTILINE regex, a '# Before' inside a + fence would end the section there: the release notes would lose the example + and everything after it, and end on an unterminated fence. + """ + headings: list[tuple[int, int, int]] = [] + fence = '' + offset = 0 + for line in text.splitlines(keepends=True): + stripped = line.lstrip() + indent = len(line) - len(stripped) + match = _FENCE.match(stripped) + if match: + candidate = match.group('fence') + if not fence: + fence = candidate + elif candidate[0] == fence[0] and len(candidate) >= len(fence): + # A closing fence is the same character and at least as long. + fence = '' + elif not fence: + heading = _HEADING.match(line) + if heading: + headings.append(( + len(heading.group('hashes')), + offset + indent, + offset + len(line.rstrip('\n')), + )) + offset += len(line) + return headings def _version_of(title: str) -> str: @@ -51,27 +87,27 @@ def _version_of(title: str) -> str: def extract(text: str, version: str) -> str | None: """Return the CHANGELOG section for ``version``, or ``None`` if absent.""" - matches = list(_HEADING.finditer(text)) - for i, match in enumerate(matches): - if _version_of(match.group('title')) != version: + headings = _headings(text) + for i, (level, start, end) in enumerate(headings): + title = _HEADING.match(text[start:end]) + assert title is not None # `_headings` only returns what `_HEADING` matched. + if _version_of(title.group('title')) != version: continue - level = len(match.group('hashes')) - start = match.end() - end = len(text) - for later in matches[i + 1 :]: - if len(later.group('hashes')) <= level: - end = later.start() + section_end = len(text) + for later_level, later_start, _ in headings[i + 1 :]: + if later_level <= level: + section_end = later_start break - return text[start:end].strip() + return text[end:section_end].strip() return None -def main() -> int: +def main(argv: list[str] | None = None) -> int: """Parse CLI arguments and print the requested changelog section.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('changelog', type=pathlib.Path, help='Path to CHANGELOG.md.') parser.add_argument('version', help='Version to extract (e.g. 1.3.0.post0).') - args = parser.parse_args() + args = parser.parse_args(argv) section = extract(args.changelog.read_text(), args.version) if section is None: diff --git a/.scripts/tests/test_extract_changelog.py b/.scripts/tests/test_extract_changelog.py index e27a34658..14218350b 100644 --- a/.scripts/tests/test_extract_changelog.py +++ b/.scripts/tests/test_extract_changelog.py @@ -14,37 +14,17 @@ # ruff: noqa: D103 (function docstrings) -"""Unit tests for the extract-changelog script. - -The script lives in `.github/` and its filename isn't a valid module name, so -it's loaded by path rather than imported. -""" +"""Unit tests for the extract_changelog script.""" from __future__ import annotations -import importlib.util -import pathlib import typing +import extract_changelog import pytest if typing.TYPE_CHECKING: - import types - -_REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent.parent -_SCRIPT = _REPO_ROOT / '.github' / 'extract-changelog.py' - - -def _load() -> types.ModuleType: - spec = importlib.util.spec_from_file_location('extract_changelog', _SCRIPT) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -extract_changelog = _load() + import pathlib CHANGELOG = """\ # 1.3.0 - 2 June 2026 @@ -102,11 +82,6 @@ def test_subheadings_are_kept(): assert extract_changelog.extract(text, '2.0.0') == '## Fixes\n\nA fix.' -@pytest.mark.parametrize('version', ['1.3.0', '1.2.1', '1.2.0']) -def test_every_heading_is_findable(version: str): - assert extract_changelog.extract(CHANGELOG, version) is not None - - # The interfaces packages open with a prose H1 and put versions at H2. def test_extract_h2_versions_under_a_prose_h1(): text = ( @@ -145,3 +120,55 @@ def test_extract_keeps_deeper_subheadings(): def test_extract_does_not_include_the_prose_heading(): text = '# Changelog\n\nprose\n\n## 1.0.0 - 1 June 2026\n\nreal\n' assert extract_changelog.extract(text, '1.0.0') == 'real' + + +# A migration example in a breaking-change entry contains Python comments, +# which start with the same character as a heading. +def test_a_fenced_code_block_does_not_end_the_section(): + text = ( + '# 2.0.0 - 31 August 2026\n\n' + 'Breaking change. Migrate like this:\n\n' + '```python\n# Before\nsnap.add("foo")\n# After\nsnap.install("foo")\n```\n\n' + 'Also fixed a leak.\n\n' + '# 1.0.0 - 1 August 2026\n\nFirst release.\n' + ) + got = extract_changelog.extract(text, '2.0.0') + assert got is not None + assert '# Before' in got + assert got.endswith('Also fixed a leak.') + # The fence is closed: notes ending mid-block render as a broken block. + assert got.count('```') == 2 + # And the next version is still bounded correctly. + assert extract_changelog.extract(text, '1.0.0') == 'First release.' + + +@pytest.mark.parametrize('fence', ['```', '~~~~']) +def test_both_fence_characters_are_honoured(fence: str): + text = f'# 1.0.0\n\n{fence}\n# not a heading\n{fence}\n\ntail\n' + got = extract_changelog.extract(text, '1.0.0') + assert got is not None + assert got.endswith('tail') + + +def test_main_prints_the_section(tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]): + changelog = tmp_path / 'CHANGELOG.md' + changelog.write_text(CHANGELOG, encoding='utf-8') + + assert extract_changelog.main([str(changelog), '1.2.1']) == 0 + + assert capsys.readouterr().out.strip() == extract_changelog.extract(CHANGELOG, '1.2.1') + + +def test_main_reports_a_missing_version( + tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] +): + """The whole safety argument is that the caller notices, so check it does.""" + changelog = tmp_path / 'CHANGELOG.md' + changelog.write_text(CHANGELOG, encoding='utf-8') + + assert extract_changelog.main([str(changelog), '9.9.9']) == 1 + + captured = capsys.readouterr() + assert captured.out == '' + assert '9.9.9' in captured.err + assert str(changelog) in captured.err