diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..b4d4ef171 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/ChangeLog b/ChangeLog index 08a0ee71a..58888e2a1 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,3 @@ -# Changelog - ## [16.7.0] - unreleased ### Added diff --git a/build_support/releases/extract_changelog.py b/build_support/releases/extract_changelog.py new file mode 100644 index 000000000..d8db79a5c --- /dev/null +++ b/build_support/releases/extract_changelog.py @@ -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) diff --git a/build_support/releases/test_extract_changelog.py b/build_support/releases/test_extract_changelog.py new file mode 100644 index 000000000..bebffa526 --- /dev/null +++ b/build_support/releases/test_extract_changelog.py @@ -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" + ) diff --git a/doc/conf.py b/doc/conf.py index 18796ca96..45175ea3f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -49,6 +49,7 @@ "sphinxcontrib.tikz", "jupyter_sphinx", "sphinxcontrib.bibtex", + "myst_parser", ] @@ -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' @@ -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 diff --git a/doc/developer.contributing.rst b/doc/developer.contributing.rst index 72b35612e..ed7e13826 100644 --- a/doc/developer.contributing.rst +++ b/doc/developer.contributing.rst @@ -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 `__ 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. :: @@ -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 `__, 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 `__ 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. diff --git a/doc/index.rst b/doc/index.rst index 4d9cd59b0..c4fa8119d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -21,7 +21,6 @@ construction and analysis of finite extensive and strategic games. :color: secondary :expand: - .. grid-item-card:: 🐍 PyGambit :columns: 3 diff --git a/doc/install.rst b/doc/install.rst index 8bb9de061..14be76591 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -2,7 +2,7 @@ Install -======= +=================== Users installing Gambit have several options depending on their needs and their operating system. We recommended most new users install the PyGambit package and read the :ref:`PyGambit documentation `. @@ -16,35 +16,52 @@ PyGambit is available on `PyPI `_. We recomm pip install pygambit +To install a specific older version:: -Older releases can be installed by specifying the version number. -Visit the `Gambit releases page on GitHub `_ for information on older versions. + pip install pygambit==X.Y.Z -.. _install-cli-gui: +.. _releases: + +Releases +-------- + +The current stable release of Gambit is |release|. + +.. dropdown:: Install on Windows + :class-container: sd-border-0 + + 1. **Download the installer:** -Installing Gambit GUI & CLI tools ---------------------------------- + Download the `.msi` for Gambit |release| from the |release_link|. -To install the Gambit :ref:`GUI ` and :ref:`CLI tools `, visit the `Gambit releases page on GitHub `_ and download the appropriate installer or package for your operating system. -Each release includes pre-built binaries for Windows, macOS, and Linux distributions, accessible under the "Assets" section of each release. + 2. **Run the installer:** + + Double-click the downloaded `.msi` file and follow the on-screen instructions to complete the installation. + +.. _install-cli-gui: .. dropdown:: Install on macOS with disk image :class-container: sd-border-0 1. **Download the .dmg installer:** - Visit the `Gambit releases page on GitHub `_ and download the `.dmg` file for the version of Gambit you wish to install. + Download the `.dmg` for Gambit |release| from the |release_link|. 2. **Install the application:** - Double click the `.dmg` file to mount it, then drag the Gambit application to your Applications folder. + Double-click the `.dmg` file to mount it, then drag the Gambit application to your Applications folder. .. warning:: - You may need to adjust your macOS security settings to allow the installation of applications from unidentified developers. + Gambit's macOS application is **not signed or notarized** by Apple. + Gatekeeper will block opening it by default. - This can be done in ``System Preferences > Security & Privacy`` (see `Apple's documentation `_ for more details). + To open it anyway, right-click (or Control-click) the Gambit application and choose + **Open** from the context menu, then confirm in the dialog that appears. + Alternatively, go to **System Settings → Privacy & Security** and click **Open Anyway** + after the first blocked launch attempt. - If your administration privileges prevent this, try the Homebrew installation method below, or build from source as described in the :ref:`developer build instructions `. + If your administrator privileges prevent this, use the Homebrew installation + or build from source (see the :ref:`developer build instructions `). .. dropdown:: Install on macOS via Homebrew :class-container: sd-border-0 @@ -65,7 +82,7 @@ Each release includes pre-built binaries for Windows, macOS, and Linux distribut 1. **Download the source tarball:** - Visit the `Gambit releases page on GitHub `_ and download the source tarball for the version of Gambit you wish to install. + Download the source tarball for Gambit |release| from the |release_link|. 2. **Extract the tarball:** @@ -73,7 +90,7 @@ Each release includes pre-built binaries for Windows, macOS, and Linux distribut .. code-block:: bash - tar -xzf gambit-*.tar.gz + tar -xzf gambit-|release|.tar.gz 3. **Build and install Gambit:** @@ -101,13 +118,12 @@ Each release includes pre-built binaries for Windows, macOS, and Linux distribut equilibria. It is strongly recommended that you install the Gambit executables to a directory in your path! -.. dropdown:: Install on Windows with installer - :class-container: sd-border-0 +All past releases are available on the +`GitHub releases page `__. - 1. **Download the installer:** - - Visit the `Gambit releases page on GitHub `_ and download the `.msi`. - 2. **Run the installer:** +Changelog +--------- - Double click the downloaded `.msi` file and follow the on-screen instructions to complete the installation. +.. include:: ../ChangeLog + :parser: myst_parser.sphinx_ diff --git a/pyproject.toml b/pyproject.toml index 4fe92d3f5..1b36d50ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ doc = [ "jupyter_sphinx", "pyyaml", "sphinxcontrib-bibtex", + "myst-parser", "gtdraw", ] @@ -96,7 +97,7 @@ max-line-length = 99 [tool.pytest.ini_options] addopts = "--strict-markers" -pythonpath = ["build_support/catalog"] +pythonpath = ["build_support/catalog", "build_support/releases"] markers = [ "nash_enumpure_strategy: tests of enumpure_solve in pure strategies", "nash_enumpure_agent: tests of enumpure_solve in pure behaviors",