Skip to content
Open
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
89 changes: 89 additions & 0 deletions .github/extract-changelog.py
Original file line number Diff line number Diff line change
@@ -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
``# <version> - <date>`` 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<hashes>#{1,6})[ \t]+(?P<title>\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())
9 changes: 7 additions & 2 deletions .github/unify-publish-inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,20 @@ 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',
'repository-url': 'https://upload.pypi.org/legacy/',
})
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/',
})
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
20 changes: 19 additions & 1 deletion .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}"
Comment on lines +111 to +113

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think gh release create will create and push the tag, so this step could replace the Tag step, but that probably needs to be verified in a test repo.

147 changes: 147 additions & 0 deletions .scripts/tests/test_extract_changelog.py
Original file line number Diff line number Diff line change
@@ -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'
Loading