From 158debef456b94743e3e98af8c73b16d2473ecb5 Mon Sep 17 00:00:00 2001 From: dariocazzani Date: Sun, 3 May 2026 16:15:26 -0400 Subject: [PATCH] Add tag-driven release workflow with PyPI Trusted Publishers - Add .github/workflows/release.yml triggered by v*.*.* tags. - Route tag shape to destination: final tags (v0.1.0) publish to PyPI; prerelease tags (rc/a/b) publish to TestPyPI, enabling dry runs without burning a real version. - Verify tag matches pyproject.toml version before building. - Publish via OIDC (pypa/gh-action-pypi-publish). No stored credentials. Requires PyPI-side Trusted Publisher + GitHub environments (pypi, testpypi) with approval gates. - Create GitHub Release with built artifacts and the matching CHANGELOG section as release notes on final tags only. - Read devol.__version__ dynamically from package metadata so pyproject.toml is the single source of truth. - Document the release flow in CONTRIBUTING.md. --- .github/workflows/release.yml | 154 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 3 + CONTRIBUTING.md | 43 ++++++++++ src/devol/__init__.py | 7 +- 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8e386b7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,154 @@ +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" # v0.1.0 -> PyPI + - "v[0-9]+.[0-9]+.[0-9]+a[0-9]+" # v0.1.0a1 -> TestPyPI + - "v[0-9]+.[0-9]+.[0-9]+b[0-9]+" # v0.1.0b1 -> TestPyPI + - "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+" # v0.1.0rc1 -> TestPyPI + +jobs: + validate: + name: Validate tag and version + runs-on: ubuntu-latest + outputs: + is_prerelease: ${{ steps.classify.outputs.is_prerelease }} + version: ${{ steps.classify.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install 3.13 + + - name: Classify tag and verify version + id: classify + run: | + set -euo pipefail + tag="${GITHUB_REF#refs/tags/}" + version="${tag#v}" + echo "Tag: $tag" + echo "Version from tag: $version" + + pyproject_version=$(uv run --no-sync python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + echo "Version in pyproject.toml: $pyproject_version" + + if [ "$version" != "$pyproject_version" ]; then + echo "::error::Tag version ($version) does not match pyproject.toml version ($pyproject_version)" + exit 1 + fi + + if [[ "$version" =~ (a|b|rc)[0-9]+$ ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + build: + name: Build distributions + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install 3.13 + + - name: Build sdist and wheel + run: uv build + + - name: Check artifacts + run: uv run --with twine twine check dist/* + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-testpypi: + name: Publish to TestPyPI + needs: [validate, build] + if: needs.validate.outputs.is_prerelease == 'true' + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/project/devol/ + permissions: + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + name: Publish to PyPI + needs: [validate, build] + if: needs.validate.outputs.is_prerelease == 'false' + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/devol/ + permissions: + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: Create GitHub Release + needs: [validate, build, publish-pypi] + if: needs.validate.outputs.is_prerelease == 'false' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Extract changelog section + id: changelog + run: | + set -euo pipefail + version="${{ needs.validate.outputs.version }}" + awk -v v="$version" ' + /^## \[/{ if(found) exit; if($0 ~ "\\[" v "\\]") { found=1; next } } + found { print } + ' CHANGELOG.md > release_notes.md || true + if [ ! -s release_notes.md ]; then + echo "No changelog section found for $version; using generic message." + echo "Release $version" > release_notes.md + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + body_path: release_notes.md + tag_name: ${{ github.ref_name }} + name: ${{ needs.validate.outputs.version }} + draft: false + prerelease: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 037878e..df3c088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ the set of symbols exported from `devol.__all__`; anything else is internal. - `src/devol` is now strictly typed end-to-end (`mypy --strict` clean). - Installation section in the README explaining the new extras. - README hero visual: 4-panel static figure and animated GIF showing diffusion evolution collapsing noise onto the Rastrigin fitness landscape. Reproducible via `scripts/generate_readme_figure.py`. +- Automated release pipeline (`.github/workflows/release.yml`): tag-driven publishing via PyPI Trusted Publishers (OIDC), no stored credentials. Prerelease tags (`rc`, `a`, `b`) publish to TestPyPI; final tags publish to PyPI. Both paths require manual approval via GitHub Environments, build a GitHub Release with artifacts attached, and validate that the tag matches `pyproject.toml`. +- `CONTRIBUTING.md` now documents the release flow. ### Changed @@ -29,6 +31,7 @@ the set of symbols exported from `devol.__all__`; anything else is internal. - Moved `torch`, `torchvision`, `gymnasium`, and `matplotlib` out of required dependencies. Installing `devol` now pulls only `numpy`, `pydantic`, and `pydantic-yaml`; the heavy deps live under the `examples` extra. - Dropped unused `pydantic-settings` from dependencies. - Wheel contents narrowed to `src/devol` only — `examples/` and `benchmark/` stay in the repo but are no longer shipped. +- `devol.__version__` is now read dynamically from the installed package metadata (`importlib.metadata.version`), making `pyproject.toml` the single source of truth for the version string. ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99f57d3..5038c98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,3 +35,46 @@ uv run mypy src/ ## Questions Open an issue with the `question` label, or start a discussion. No wrong questions. + +## Releasing (maintainers only) + +Releases are driven by git tags and published automatically via GitHub Actions using PyPI Trusted Publishers (OIDC). No API tokens are stored anywhere. + +Tag shape drives the destination: + +| Tag example | Destination | +|-------------|-------------| +| `v0.1.0` | **PyPI** (production) | +| `v0.1.0rc1`, `v0.1.0a1`, `v0.1.0b1` | **TestPyPI** (dry run) | + +Each destination has its own GitHub Environment (`pypi` / `testpypi`) with a required reviewer, so the publish step pauses for manual approval before uploading. + +### Standard release flow + +1. Bump `version` in `pyproject.toml`. +2. In `CHANGELOG.md`, move items from `[Unreleased]` into a new `[x.y.z]` section dated today. +3. Commit and push to `main`. +4. Tag and push: + + ```bash + git tag v0.1.0 + git push origin v0.1.0 + ``` + +5. Approve the publish step in the GitHub Actions run when prompted. The workflow builds, validates, publishes, and creates a GitHub Release with the built artifacts attached. + +### Dry-running against TestPyPI first + +For a never-published version or when the release workflow itself has changed, tag a prerelease first: + +```bash +git tag v0.2.0rc1 +git push origin v0.2.0rc1 +``` + +This runs the full pipeline against TestPyPI. Verify the upload at `https://test.pypi.org/project/devol/` before cutting the real tag. + +### If a release fails mid-flight + +- Pre-publish (validate/build): just fix the problem, delete the tag, retag. +- Post-publish: PyPI versions are immutable. Bump to the next patch and release again — do not attempt to overwrite. diff --git a/src/devol/__init__.py b/src/devol/__init__.py index 2d8e2b6..ddb8c0d 100644 --- a/src/devol/__init__.py +++ b/src/devol/__init__.py @@ -1,5 +1,7 @@ """Devol: Diffusion Evolution Algorithm.""" +from importlib.metadata import PackageNotFoundError, version + from devol.algorithm import DiffusionEvolution from devol.config import ( DiffusionConfig, @@ -11,7 +13,10 @@ ScheduleType, ) -__version__ = "0.1.0" +try: + __version__ = version("devol") +except PackageNotFoundError: # running from a source checkout without install + __version__ = "0.0.0+unknown" __all__ = [ "DiffusionEvolution",