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
28 changes: 28 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Create GitHub release

on:
push:
tags:
- 'v*.*.*'

permissions:
contents: write

jobs:
create-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Extract changelog for this version
run: |
VERSION="${{ github.ref_name }}"
python3 build_support/releases/extract_changelog.py "${VERSION#v}"

- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.ref_name }}" \
--title "Gambit ${{ github.ref_name }}" \
--notes-file release_notes.md
2 changes: 0 additions & 2 deletions ChangeLog
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# Changelog

## [16.7.0] - unreleased

### Added
Expand Down
81 changes: 81 additions & 0 deletions build_support/releases/extract_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Extract the changelog section for a given version from the ChangeLog file.

The ChangeLog at the repository root follows the Keep a Changelog format
(https://keepachangelog.com). Each release section looks like:

## [X.Y.Z] - YYYY-MM-DD

### Added
- ...

### Fixed
- ...

This script locates the section for a given version and writes it to an output
file, which is then used by the ``release.yml`` GitHub Actions workflow to
populate the GitHub release notes.

Usage
-----
Run from the repository root::

python build_support/releases/extract_changelog.py X.Y.Z

Optional arguments::

--changelog PATH Path to the ChangeLog file (default: ChangeLog)
--output PATH Path to write extracted notes (default: release_notes.md)
"""

import argparse
import pathlib
import re
import sys


def extract(version: str, changelog: pathlib.Path, output: pathlib.Path) -> None:
"""Extract the release notes for *version* from *changelog* and write to *output*.

Searches for a section header of the form ``## [X.Y.Z] - ...`` and captures
everything up to the next version header (or end of file). Exits with a
non-zero status and an error message if the version is not found.

Parameters
----------
version:
Version string without a leading ``v``, e.g. ``"16.6.0"``.
changelog:
Path to the ChangeLog file to read from.
output:
Path to the file to write the extracted notes to.
"""
text = changelog.read_text()
pattern = re.compile(
rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^## \[|\Z)",
re.MULTILINE | re.DOTALL,
)
match = pattern.search(text)
if not match:
print(f"No ChangeLog entry found for version {version}", file=sys.stderr)
sys.exit(1)
output.write_text(match.group(1).strip())
print(f"Extracted release notes for {version}")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version", help="Version string, e.g. 16.6.0")
parser.add_argument(
"--changelog",
type=pathlib.Path,
default=pathlib.Path("ChangeLog"),
help="Path to the ChangeLog file (default: ChangeLog)",
)
parser.add_argument(
"--output",
type=pathlib.Path,
default=pathlib.Path("release_notes.md"),
help="Path to write the extracted notes (default: release_notes.md)",
)
args = parser.parse_args()
extract(args.version, args.changelog, args.output)
171 changes: 171 additions & 0 deletions build_support/releases/test_extract_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""Tests for extract_changelog.py.

Covers two concerns:
1. Correctness of the extraction logic (unit tests against fixture content).
2. Format validity of the repository's actual ChangeLog file.

The format tests act as a CI guard: if a contributor adds a malformed version
header or an unrecognised section type to ChangeLog, the test suite will fail.
"""

import pathlib
import re

import pytest
from extract_changelog import extract

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------

REPO_ROOT = pathlib.Path(__file__).parent.parent.parent
CHANGELOG = REPO_ROOT / "ChangeLog"

# ---------------------------------------------------------------------------
# ChangeLog format rules (Keep a Changelog)
# ---------------------------------------------------------------------------

# Pre-release suffixes (a1, b2, rc3) follow PEP 440 conventions used in this project.
VERSION_HEADER_RE = re.compile(
r"^## \[\d+\.\d+\.\d+(?:a\d+|b\d+|rc\d+)?\] - (\d{4}-\d{2}-\d{2}|unreleased)$"
)
# 'General' is a project-specific extension used for cross-cutting changes.
SECTION_HEADER_RE = re.compile(r"^### (Added|Changed|Deprecated|Removed|Fixed|Security|General)$")

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

SAMPLE_CHANGELOG = """\
# Changelog

## [2.0.0] - 2024-01-15

### Added
- Feature A

### Fixed
- Bug B

## [1.0.0] - 2023-06-01

### Added
- Initial release
"""

UNRELEASED_CHANGELOG = (
"""\
# Changelog

## [3.0.0] - unreleased

### Added
- Upcoming feature

"""
+ SAMPLE_CHANGELOG
)


# ---------------------------------------------------------------------------
# Extraction unit tests
# ---------------------------------------------------------------------------


def test_extract_known_version(tmp_path):
changelog = tmp_path / "ChangeLog"
changelog.write_text(SAMPLE_CHANGELOG)
output = tmp_path / "notes.md"

extract("2.0.0", changelog, output)

content = output.read_text()
assert "Feature A" in content
assert "Bug B" in content


def test_extract_does_not_bleed_into_next_version(tmp_path):
changelog = tmp_path / "ChangeLog"
changelog.write_text(SAMPLE_CHANGELOG)
output = tmp_path / "notes.md"

extract("2.0.0", changelog, output)

assert "Initial release" not in output.read_text()


def test_extract_last_version_in_file(tmp_path):
changelog = tmp_path / "ChangeLog"
changelog.write_text(SAMPLE_CHANGELOG)
output = tmp_path / "notes.md"

extract("1.0.0", changelog, output)

assert "Initial release" in output.read_text()


def test_extract_unreleased_version(tmp_path):
changelog = tmp_path / "ChangeLog"
changelog.write_text(UNRELEASED_CHANGELOG)
output = tmp_path / "notes.md"

extract("3.0.0", changelog, output)

assert "Upcoming feature" in output.read_text()


def test_extract_missing_version_exits(tmp_path):
changelog = tmp_path / "ChangeLog"
changelog.write_text(SAMPLE_CHANGELOG)
output = tmp_path / "notes.md"

with pytest.raises(SystemExit) as exc_info:
extract("99.0.0", changelog, output)

assert exc_info.value.code != 0


def test_extract_output_is_stripped(tmp_path):
changelog = tmp_path / "ChangeLog"
changelog.write_text(SAMPLE_CHANGELOG)
output = tmp_path / "notes.md"

extract("2.0.0", changelog, output)

content = output.read_text()
assert content == content.strip()


# ---------------------------------------------------------------------------
# ChangeLog format validation tests
# ---------------------------------------------------------------------------


def _changelog_lines():
"""Return (line_number, line) pairs for the repository ChangeLog."""
return list(enumerate(CHANGELOG.read_text().splitlines(), start=1))


@pytest.mark.parametrize(
"lineno,line",
[(line_number, line) for line_number, line in _changelog_lines() if line.startswith("## ")],
)
def test_version_header_format(lineno, line):
"""Every '## ' line must match '## [X.Y.Z] - YYYY-MM-DD' or '## [X.Y.Z] - unreleased'."""
assert VERSION_HEADER_RE.match(line), (
f"ChangeLog line {lineno}: invalid version header: {line!r}\n"
"Expected: ## [X.Y.Z] - YYYY-MM-DD or ## [X.Y.Z] - unreleased"
)


@pytest.mark.parametrize(
"lineno,line",
[(line_number, line) for line_number, line in _changelog_lines() if line.startswith("### ")],
)
def test_section_header_type(lineno, line):
"""Every '### ' line must be one of the recognised Keep a Changelog types."""
assert SECTION_HEADER_RE.match(line), (
f"ChangeLog line {lineno}: unrecognised section header: {line!r}\n"
"Allowed: ### Added, ### Changed, ### Deprecated, "
"### Removed, ### Fixed, ### Security"
)
8 changes: 7 additions & 1 deletion doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"sphinxcontrib.tikz",
"jupyter_sphinx",
"sphinxcontrib.bibtex",
"myst_parser",
]


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

# The suffix of source filenames.
source_suffix = ".rst"
source_suffix = {".rst": "restructuredtext", ".md": "markdown"}

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

_release_url = f"https://github.com/gambitproject/gambit/releases/tag/v{_full_version}"
rst_epilog = f"""
.. |release_link| replace:: `GitHub releases page <{_release_url}>`__
"""

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
# language = None
Expand Down
20 changes: 17 additions & 3 deletions doc/developer.contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,19 @@ When making a new release of Gambit, follow these steps:
- `doc/conf.py` reads from GAMBIT_VERSION file at documentation build time
- Documentation pages reference the `|release|` substitution variable to automatically reflect the updated version number.

3. Update the `ChangeLog` file with a summary of changes
3. Update the ``ChangeLog`` file at the repository root with a summary of changes for this release.
This file is the single source of truth for release notes — it is surfaced in the documentation
(see :ref:`releases`) and used to populate the GitHub release automatically.

The ``ChangeLog`` must follow the `Keep a Changelog <https://keepachangelog.com>`__ format:
version headers of the form ``## [X.Y.Z] - YYYY-MM-DD`` and subsections from
``Added``, ``Changed``, ``Deprecated``, ``Removed``, ``Fixed``, or ``Security``.
The test suite enforces this format — any malformed entry will cause ``pytest`` to fail.

To verify the new entry will be extracted correctly before tagging, run the
extraction script from the repository root::

python build_support/releases/extract_changelog.py X.Y.Z

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. ::

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

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

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

Expand Down
1 change: 0 additions & 1 deletion doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ construction and analysis of finite extensive and strategic games.
:color: secondary
:expand:


.. grid-item-card:: 🐍 PyGambit
:columns: 3

Expand Down
Loading
Loading