Skip to content
Merged
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
154 changes: 154 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ 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

- Moved `pytest` from required dependencies to the `dev` extra.
- 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

Expand Down
43 changes: 43 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 6 additions & 1 deletion src/devol/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Devol: Diffusion Evolution Algorithm."""

from importlib.metadata import PackageNotFoundError, version

from devol.algorithm import DiffusionEvolution
from devol.config import (
DiffusionConfig,
Expand All @@ -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",
Expand Down
Loading