From c9586b869b6bf59951ce67721cf6f907ba45b702 Mon Sep 17 00:00:00 2001 From: danielogen Date: Tue, 7 Jul 2026 16:45:58 -0700 Subject: [PATCH] ci: single-source version + automate GitHub Release on tag --- .github/scripts/changelog_notes.py | 40 ++++++++++++++++++++ .github/scripts/package_version.py | 21 ++++++++++ .github/workflows/publish.yml | 38 +++++++++++++++++++ CONTRIBUTING.md | 7 +++- RELEASING.md | 61 ++++++++++++++++++++++++++++++ pyproject.toml | 7 +++- 6 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/changelog_notes.py create mode 100644 .github/scripts/package_version.py create mode 100644 RELEASING.md diff --git a/.github/scripts/changelog_notes.py b/.github/scripts/changelog_notes.py new file mode 100644 index 0000000..fd22575 --- /dev/null +++ b/.github/scripts/changelog_notes.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Print the CHANGELOG.md section for a given version. + +Usage: ``python .github/scripts/changelog_notes.py 0.2.0`` + +Extracts the body under ``## [0.2.0] ...`` up to the next ``## [`` heading. +Falls back to a generic line if the version has no changelog section. +""" +import pathlib +import re +import sys + +CHANGELOG = pathlib.Path("CHANGELOG.md") + + +def notes_for(version: str, text: str) -> str: + pattern = re.compile( + r"^##\s*\[" + re.escape(version) + r"\][^\n]*\n(.*?)(?=^##\s*\[|\Z)", + re.MULTILINE | re.DOTALL, + ) + match = pattern.search(text) + if match: + body = match.group(1).strip() + if body: + return body + return f"Release {version}." + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: changelog_notes.py ", file=sys.stderr) + return 2 + version = sys.argv[1] + text = CHANGELOG.read_text(encoding="utf-8") if CHANGELOG.exists() else "" + print(notes_for(version, text)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/package_version.py b/.github/scripts/package_version.py new file mode 100644 index 0000000..179c185 --- /dev/null +++ b/.github/scripts/package_version.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Print the package version (``__version__`` from the package __init__).""" +import pathlib +import re +import sys + +INIT = pathlib.Path("src/PyReprism/__init__.py") + + +def main() -> int: + match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', + INIT.read_text(encoding="utf-8"), re.MULTILINE) + if not match: + print("could not find __version__ in", INIT, file=sys.stderr) + return 1 + print(match.group(1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 415b86a..d1e9cf7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,6 +21,16 @@ jobs: with: python-version: '3.12' + - name: Verify the tag matches the package version + run: | + PKG_VERSION="$(python .github/scripts/package_version.py)" + TAG_VERSION="${GITHUB_REF_NAME#v}" + echo "tag=$TAG_VERSION package=$PKG_VERSION" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag ($TAG_VERSION) does not match package version ($PKG_VERSION). Bump __version__ in src/PyReprism/__init__.py." + exit 1 + fi + - name: Build and validate the distribution run: | python -m pip install --upgrade pip build twine @@ -51,3 +61,31 @@ jobs: - name: Publish distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: Create GitHub Release + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write # required to create a release + steps: + - name: Check out the code + uses: actions/checkout@v4 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Extract release notes from CHANGELOG + run: python .github/scripts/changelog_notes.py "${GITHUB_REF_NAME#v}" > release-notes.md + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --notes-file release-notes.md \ + dist/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b67855..800600b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,4 +62,9 @@ When you're finished with the changes, create a pull request, also known as a PR ### Your PR is merged! Congratulations :tada: :tada:
-The PyReprism team thanks you :sparkles:. \ No newline at end of file +The PyReprism team thanks you :sparkles:. + +## Maintainers: cutting a release + +Releases are automated by pushing a `vX.Y.Z` tag. See [RELEASING.md](RELEASING.md) +for the full process. \ No newline at end of file diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..50109f2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,61 @@ +# Releasing PyReprism + +Releases are automated: **pushing a `vX.Y.Z` tag** builds the package, publishes it +to PyPI, and creates a GitHub Release. You never edit the version in more than one +place — `src/PyReprism/__init__.py` is the single source of truth and +`pyproject.toml` derives the version from it. + +## One-time setup (Trusted Publishing) + +Publishing uses PyPI [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) +(OIDC) — no API token. Configure it once: + +1. **PyPI** → the `PyReprism` project → *Publishing* → add a GitHub publisher: + - Repository: `unlv-evol/PyReprism` + - Workflow: `publish.yml` + - Environment: `pypi` +2. **GitHub** → repo *Settings → Environments* → create an environment named + `pypi` (optionally require a reviewer to approve each release). + +## Cutting a release + +1. **Bump the version** in [`src/PyReprism/__init__.py`](src/PyReprism/__init__.py): + + ```python + __version__ = "0.2.0" + ``` + +2. **Update the changelog** in [`CHANGELOG.md`](CHANGELOG.md): rename the + `## [Unreleased]` heading to `## [0.2.0] - YYYY-MM-DD`, add a fresh empty + `## [Unreleased]` above it, and update the link references at the bottom. + +3. **Commit, tag, and push** (the tag must equal the version — CI verifies it): + + ```shell + git commit -am "release: v0.2.0" + git tag -a v0.2.0 -m "v0.2.0" + git push origin main --follow-tags + ``` + +That's it. Pushing the tag triggers [`publish.yml`](.github/workflows/publish.yml), +which: + +1. checks that the tag matches `__version__`, +2. builds the sdist + wheel and runs `twine check`, +3. publishes to PyPI via Trusted Publishing, and +4. creates a GitHub Release titled `v0.2.0` with notes taken from the matching + `CHANGELOG.md` section and the built artifacts attached. + +## Versioning + +PyReprism follows [Semantic Versioning](https://semver.org): bump **patch** for +fixes, **minor** for backwards-compatible features, **major** for breaking changes. + +## Testing a release without publishing + +To validate the build locally before tagging: + +```shell +python -m build +twine check dist/* +``` diff --git a/pyproject.toml b/pyproject.toml index 09ededf..7f1cc5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "PyReprism" -version = "0.1.0" +dynamic = ["version"] authors = [ { name = "UNLV EVOL LAB", email = "ogenrwot@unlv.nevada.edu" }, ] @@ -23,6 +23,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] @@ -52,6 +53,10 @@ Homepage = "https://github.com/unlv-evol/PyReprism" Documentation = "https://pyreprism.readthedocs.io" Issues = "https://github.com/unlv-evol/PyReprism/issues" +# Single source of truth for the version: __version__ in the package __init__. +[tool.hatch.version] +path = "src/PyReprism/__init__.py" + [tool.hatch.build.targets.wheel] packages = ["src/PyReprism"]