Skip to content

Commit 33f9628

Browse files
Automate creating releases from Changelog and add Changelog to docs (#954)
1 parent 25a1f43 commit 33f9628

9 files changed

Lines changed: 336 additions & 34 deletions

File tree

.github/workflows/release.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Create GitHub release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*.*.*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
create-release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v6
16+
17+
- name: Extract changelog for this version
18+
run: |
19+
VERSION="${{ github.ref_name }}"
20+
python3 build_support/releases/extract_changelog.py "${VERSION#v}"
21+
22+
- name: Create GitHub release
23+
env:
24+
GH_TOKEN: ${{ github.token }}
25+
run: |
26+
gh release create "${{ github.ref_name }}" \
27+
--title "Gambit ${{ github.ref_name }}" \
28+
--notes-file release_notes.md

ChangeLog

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# Changelog
2-
31
## [16.7.0] - unreleased
42

53
### Added
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Extract the changelog section for a given version from the ChangeLog file.
2+
3+
The ChangeLog at the repository root follows the Keep a Changelog format
4+
(https://keepachangelog.com). Each release section looks like:
5+
6+
## [X.Y.Z] - YYYY-MM-DD
7+
8+
### Added
9+
- ...
10+
11+
### Fixed
12+
- ...
13+
14+
This script locates the section for a given version and writes it to an output
15+
file, which is then used by the ``release.yml`` GitHub Actions workflow to
16+
populate the GitHub release notes.
17+
18+
Usage
19+
-----
20+
Run from the repository root::
21+
22+
python build_support/releases/extract_changelog.py X.Y.Z
23+
24+
Optional arguments::
25+
26+
--changelog PATH Path to the ChangeLog file (default: ChangeLog)
27+
--output PATH Path to write extracted notes (default: release_notes.md)
28+
"""
29+
30+
import argparse
31+
import pathlib
32+
import re
33+
import sys
34+
35+
36+
def extract(version: str, changelog: pathlib.Path, output: pathlib.Path) -> None:
37+
"""Extract the release notes for *version* from *changelog* and write to *output*.
38+
39+
Searches for a section header of the form ``## [X.Y.Z] - ...`` and captures
40+
everything up to the next version header (or end of file). Exits with a
41+
non-zero status and an error message if the version is not found.
42+
43+
Parameters
44+
----------
45+
version:
46+
Version string without a leading ``v``, e.g. ``"16.6.0"``.
47+
changelog:
48+
Path to the ChangeLog file to read from.
49+
output:
50+
Path to the file to write the extracted notes to.
51+
"""
52+
text = changelog.read_text()
53+
pattern = re.compile(
54+
rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^## \[|\Z)",
55+
re.MULTILINE | re.DOTALL,
56+
)
57+
match = pattern.search(text)
58+
if not match:
59+
print(f"No ChangeLog entry found for version {version}", file=sys.stderr)
60+
sys.exit(1)
61+
output.write_text(match.group(1).strip())
62+
print(f"Extracted release notes for {version}")
63+
64+
65+
if __name__ == "__main__":
66+
parser = argparse.ArgumentParser(description=__doc__)
67+
parser.add_argument("version", help="Version string, e.g. 16.6.0")
68+
parser.add_argument(
69+
"--changelog",
70+
type=pathlib.Path,
71+
default=pathlib.Path("ChangeLog"),
72+
help="Path to the ChangeLog file (default: ChangeLog)",
73+
)
74+
parser.add_argument(
75+
"--output",
76+
type=pathlib.Path,
77+
default=pathlib.Path("release_notes.md"),
78+
help="Path to write the extracted notes (default: release_notes.md)",
79+
)
80+
args = parser.parse_args()
81+
extract(args.version, args.changelog, args.output)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Tests for extract_changelog.py.
2+
3+
Covers two concerns:
4+
1. Correctness of the extraction logic (unit tests against fixture content).
5+
2. Format validity of the repository's actual ChangeLog file.
6+
7+
The format tests act as a CI guard: if a contributor adds a malformed version
8+
header or an unrecognised section type to ChangeLog, the test suite will fail.
9+
"""
10+
11+
import pathlib
12+
import re
13+
14+
import pytest
15+
from extract_changelog import extract
16+
17+
# ---------------------------------------------------------------------------
18+
# Paths
19+
# ---------------------------------------------------------------------------
20+
21+
REPO_ROOT = pathlib.Path(__file__).parent.parent.parent
22+
CHANGELOG = REPO_ROOT / "ChangeLog"
23+
24+
# ---------------------------------------------------------------------------
25+
# ChangeLog format rules (Keep a Changelog)
26+
# ---------------------------------------------------------------------------
27+
28+
# Pre-release suffixes (a1, b2, rc3) follow PEP 440 conventions used in this project.
29+
VERSION_HEADER_RE = re.compile(
30+
r"^## \[\d+\.\d+\.\d+(?:a\d+|b\d+|rc\d+)?\] - (\d{4}-\d{2}-\d{2}|unreleased)$"
31+
)
32+
# 'General' is a project-specific extension used for cross-cutting changes.
33+
SECTION_HEADER_RE = re.compile(r"^### (Added|Changed|Deprecated|Removed|Fixed|Security|General)$")
34+
35+
# ---------------------------------------------------------------------------
36+
# Fixtures
37+
# ---------------------------------------------------------------------------
38+
39+
SAMPLE_CHANGELOG = """\
40+
# Changelog
41+
42+
## [2.0.0] - 2024-01-15
43+
44+
### Added
45+
- Feature A
46+
47+
### Fixed
48+
- Bug B
49+
50+
## [1.0.0] - 2023-06-01
51+
52+
### Added
53+
- Initial release
54+
"""
55+
56+
UNRELEASED_CHANGELOG = (
57+
"""\
58+
# Changelog
59+
60+
## [3.0.0] - unreleased
61+
62+
### Added
63+
- Upcoming feature
64+
65+
"""
66+
+ SAMPLE_CHANGELOG
67+
)
68+
69+
70+
# ---------------------------------------------------------------------------
71+
# Extraction unit tests
72+
# ---------------------------------------------------------------------------
73+
74+
75+
def test_extract_known_version(tmp_path):
76+
changelog = tmp_path / "ChangeLog"
77+
changelog.write_text(SAMPLE_CHANGELOG)
78+
output = tmp_path / "notes.md"
79+
80+
extract("2.0.0", changelog, output)
81+
82+
content = output.read_text()
83+
assert "Feature A" in content
84+
assert "Bug B" in content
85+
86+
87+
def test_extract_does_not_bleed_into_next_version(tmp_path):
88+
changelog = tmp_path / "ChangeLog"
89+
changelog.write_text(SAMPLE_CHANGELOG)
90+
output = tmp_path / "notes.md"
91+
92+
extract("2.0.0", changelog, output)
93+
94+
assert "Initial release" not in output.read_text()
95+
96+
97+
def test_extract_last_version_in_file(tmp_path):
98+
changelog = tmp_path / "ChangeLog"
99+
changelog.write_text(SAMPLE_CHANGELOG)
100+
output = tmp_path / "notes.md"
101+
102+
extract("1.0.0", changelog, output)
103+
104+
assert "Initial release" in output.read_text()
105+
106+
107+
def test_extract_unreleased_version(tmp_path):
108+
changelog = tmp_path / "ChangeLog"
109+
changelog.write_text(UNRELEASED_CHANGELOG)
110+
output = tmp_path / "notes.md"
111+
112+
extract("3.0.0", changelog, output)
113+
114+
assert "Upcoming feature" in output.read_text()
115+
116+
117+
def test_extract_missing_version_exits(tmp_path):
118+
changelog = tmp_path / "ChangeLog"
119+
changelog.write_text(SAMPLE_CHANGELOG)
120+
output = tmp_path / "notes.md"
121+
122+
with pytest.raises(SystemExit) as exc_info:
123+
extract("99.0.0", changelog, output)
124+
125+
assert exc_info.value.code != 0
126+
127+
128+
def test_extract_output_is_stripped(tmp_path):
129+
changelog = tmp_path / "ChangeLog"
130+
changelog.write_text(SAMPLE_CHANGELOG)
131+
output = tmp_path / "notes.md"
132+
133+
extract("2.0.0", changelog, output)
134+
135+
content = output.read_text()
136+
assert content == content.strip()
137+
138+
139+
# ---------------------------------------------------------------------------
140+
# ChangeLog format validation tests
141+
# ---------------------------------------------------------------------------
142+
143+
144+
def _changelog_lines():
145+
"""Return (line_number, line) pairs for the repository ChangeLog."""
146+
return list(enumerate(CHANGELOG.read_text().splitlines(), start=1))
147+
148+
149+
@pytest.mark.parametrize(
150+
"lineno,line",
151+
[(line_number, line) for line_number, line in _changelog_lines() if line.startswith("## ")],
152+
)
153+
def test_version_header_format(lineno, line):
154+
"""Every '## ' line must match '## [X.Y.Z] - YYYY-MM-DD' or '## [X.Y.Z] - unreleased'."""
155+
assert VERSION_HEADER_RE.match(line), (
156+
f"ChangeLog line {lineno}: invalid version header: {line!r}\n"
157+
"Expected: ## [X.Y.Z] - YYYY-MM-DD or ## [X.Y.Z] - unreleased"
158+
)
159+
160+
161+
@pytest.mark.parametrize(
162+
"lineno,line",
163+
[(line_number, line) for line_number, line in _changelog_lines() if line.startswith("### ")],
164+
)
165+
def test_section_header_type(lineno, line):
166+
"""Every '### ' line must be one of the recognised Keep a Changelog types."""
167+
assert SECTION_HEADER_RE.match(line), (
168+
f"ChangeLog line {lineno}: unrecognised section header: {line!r}\n"
169+
"Allowed: ### Added, ### Changed, ### Deprecated, "
170+
"### Removed, ### Fixed, ### Security"
171+
)

doc/conf.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"sphinxcontrib.tikz",
5050
"jupyter_sphinx",
5151
"sphinxcontrib.bibtex",
52+
"myst_parser",
5253
]
5354

5455

@@ -212,7 +213,7 @@ def get_techreport_template(self, e):
212213
templates_path = ["_templates"]
213214

214215
# The suffix of source filenames.
215-
source_suffix = ".rst"
216+
source_suffix = {".rst": "restructuredtext", ".md": "markdown"}
216217

217218
# The encoding of source files.
218219
# source_encoding = 'utf-8'
@@ -240,6 +241,11 @@ def get_techreport_template(self, e):
240241
# Throw error if GAMBIT_VERSION file not found
241242
raise FileNotFoundError("GAMBIT_VERSION file not found")
242243

244+
_release_url = f"https://github.com/gambitproject/gambit/releases/tag/v{_full_version}"
245+
rst_epilog = f"""
246+
.. |release_link| replace:: `GitHub releases page <{_release_url}>`__
247+
"""
248+
243249
# The language for content autogenerated by Sphinx. Refer to documentation
244250
# for a list of supported languages.
245251
# language = None

doc/developer.contributing.rst

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,19 @@ When making a new release of Gambit, follow these steps:
280280
- `doc/conf.py` reads from GAMBIT_VERSION file at documentation build time
281281
- Documentation pages reference the `|release|` substitution variable to automatically reflect the updated version number.
282282

283-
3. Update the `ChangeLog` file with a summary of changes
283+
3. Update the ``ChangeLog`` file at the repository root with a summary of changes for this release.
284+
This file is the single source of truth for release notes — it is surfaced in the documentation
285+
(see :ref:`releases`) and used to populate the GitHub release automatically.
286+
287+
The ``ChangeLog`` must follow the `Keep a Changelog <https://keepachangelog.com>`__ format:
288+
version headers of the form ``## [X.Y.Z] - YYYY-MM-DD`` and subsections from
289+
``Added``, ``Changed``, ``Deprecated``, ``Removed``, ``Fixed``, or ``Security``.
290+
The test suite enforces this format — any malformed entry will cause ``pytest`` to fail.
291+
292+
To verify the new entry will be extracted correctly before tagging, run the
293+
extraction script from the repository root::
294+
295+
python build_support/releases/extract_changelog.py X.Y.Z
284296

285297
4. Once there are no further commits to be made for the release, create a tag for the release from the latest commit on the maintenance branch. ::
286298

@@ -291,8 +303,10 @@ When making a new release of Gambit, follow these steps:
291303
git push origin maintX_Y
292304
git push origin --tags
293305

294-
6. Create a new release on the `GitHub releases page <https://github.com/gambitproject/gambit/releases>`__, using the tag created in step 4.
295-
Include a summary of changes from the `ChangeLog` file in the release notes.
306+
6. Pushing the tag triggers the ``release.yml`` GitHub Actions workflow, which automatically
307+
reads the ``ChangeLog`` entry for the new version and creates the corresponding release on the
308+
`GitHub releases page <https://github.com/gambitproject/gambit/releases>`__ with those notes.
309+
No manual action is required.
296310

297311
7. Currently there is no automated process for pushing the new release to PyPI. This must be done manually.
298312

doc/index.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ construction and analysis of finite extensive and strategic games.
2121
:color: secondary
2222
:expand:
2323

24-
2524
.. grid-item-card:: 🐍 PyGambit
2625
:columns: 3
2726

0 commit comments

Comments
 (0)