diff --git a/.github/extract-changelog.py b/.github/extract-changelog.py new file mode 100755 index 000000000..ff0bdf875 --- /dev/null +++ b/.github/extract-changelog.py @@ -0,0 +1,89 @@ +# 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. + +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. +""" + +from __future__ import annotations + +import argparse +import pathlib +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.""" + matches = list(_HEADING.finditer(text)) + for i, match in enumerate(matches): + 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 + + +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/ci.yaml b/.github/workflows/ci.yaml index ae52a84cf..0af2ccff3 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] diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 5af290fc8..d8759cf24 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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}" diff --git a/.scripts/tests/test_extract_changelog.py b/.scripts/tests/test_extract_changelog.py new file mode 100644 index 000000000..e27a34658 --- /dev/null +++ b/.scripts/tests/test_extract_changelog.py @@ -0,0 +1,147 @@ +# 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 + + +# 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'