diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2577d4a..f6c4fa8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,7 +24,7 @@ jobs: - '3.12' steps: - name: 'Install just' - run: 'sudo apt install just' + uses: 'extractions/setup-just@v3' - name: 'Checkout repository' uses: 'actions/checkout@v6' with: @@ -41,6 +41,9 @@ jobs: UV_PYTHON_VERSION: '${{ matrix.python-version }}' ci: name: 'CI (${{ matrix.python-version }})' + needs: + - 'docs' + - 'build-check' runs-on: 'ubuntu-latest' strategy: matrix: @@ -51,7 +54,7 @@ jobs: - '3.14' steps: - name: 'Install just' - run: 'sudo apt install just' + uses: 'extractions/setup-just@v3' - name: 'Checkout repository' uses: 'actions/checkout@v6' with: @@ -75,7 +78,7 @@ jobs: - '3.12' steps: - name: 'Install just' - run: 'sudo apt install just' + uses: 'extractions/setup-just@v3' - name: 'Checkout repository' uses: 'actions/checkout@v6' with: diff --git a/.github/workflows/gh-pages.yaml b/.github/workflows/gh-pages.yaml index fbffc6f..23fae67 100644 --- a/.github/workflows/gh-pages.yaml +++ b/.github/workflows/gh-pages.yaml @@ -5,10 +5,23 @@ name: 'GitHub Pages Documentation' push: branches: - 'main' - release: - types: - - 'published' + workflow_call: + inputs: + deploy-mode: + description: 'Deployment mode used to determine docs aliases' + required: false + default: 'push' + type: 'string' workflow_dispatch: + inputs: + deploy-mode: + description: 'Deployment mode used to determine docs aliases' + required: false + default: 'push' + type: 'choice' + options: + - 'push' + - 'release' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -29,7 +42,7 @@ jobs: contents: 'write' # Needed to push to gh-pages steps: - name: 'Install just' - run: 'sudo apt install just' + uses: 'extractions/setup-just@v3' - name: 'Checkout repository' uses: 'actions/checkout@v6' with: @@ -48,7 +61,7 @@ jobs: enable-cache: true cache-dependency-glob: 'pyproject.toml' python-version: '${{ matrix.python-version }}' - - name: 'Extract flepimop2 version' + - name: 'Extract op_engine version' id: extract-version run: | cat > version.py << EOF @@ -63,10 +76,25 @@ jobs: rm version.py - name: 'Build API Reference' run: 'just api-reference' + - name: 'Determine deploy mode' + id: determine-mode + run: | + if [ "${EVENT_NAME}" = "workflow_call" ]; then + echo "deploy-mode=${CALL_MODE}" >> "${GITHUB_OUTPUT}" + elif [ "${EVENT_NAME}" = "workflow_dispatch" ] \ + && [ -n "${DISPATCH_MODE}" ]; then + echo "deploy-mode=${DISPATCH_MODE}" >> "${GITHUB_OUTPUT}" + else + echo "deploy-mode=push" >> "${GITHUB_OUTPUT}" + fi + env: + CALL_MODE: '${{ inputs.deploy-mode }}' + DISPATCH_MODE: '${{ github.event.inputs.deploy-mode }}' + EVENT_NAME: '${{ github.event_name }}' - name: 'Deploy to GitHub Pages' run: | uv run mike set-default latest - if [ "${EVENT_NAME}" == "release" ] || [[ $VERSION == 0* ]]; then + if [ "${DEPLOY_MODE}" = "release" ] || [[ $VERSION == 0* ]]; then ALIAS=latest else ALIAS=dev @@ -75,6 +103,6 @@ jobs: --push --update-aliases \ ${VERSION} ${ALIAS} env: - EVENT_NAME: "${{ github.event_name }}" + DEPLOY_MODE: "${{ steps.determine-mode.outputs.deploy-mode }}" GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" VERSION: "${{ steps.extract-version.outputs.version }}" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..6c8d8f2 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,194 @@ +--- +name: 'Release' + +'on': + workflow_dispatch: + inputs: + publish-target: + description: 'Package publish target' + required: true + default: 'none' + type: 'choice' + options: + - 'none' + - 'testpypi' + - 'pypi' + create-github-release: + description: 'Create the GitHub release after publishing' + required: true + default: false + type: 'boolean' + deploy-docs: + description: 'Deploy versioned docs after creating the GitHub release' + required: true + default: false + type: 'boolean' + +permissions: + contents: 'read' + +jobs: + validate: + name: 'Validate release' + runs-on: 'ubuntu-latest' + outputs: + version: '${{ steps.validate-version.outputs.version }}' + docs-version: '${{ steps.validate-version.outputs.docs-version }}' + prerelease: '${{ steps.validate-version.outputs.prerelease }}' + steps: + - name: 'Install just' + uses: 'extractions/setup-just@v3' + - name: 'Checkout repository' + uses: 'actions/checkout@v6' + with: + fetch-depth: 0 + persist-credentials: false + - name: 'Setup uv with python 3.12' + uses: 'astral-sh/setup-uv@v7.2.0' + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + flepimop2-op_engine/pyproject.toml + python-version: '3.12' + - name: 'Validate shared package version' + id: validate-version + run: 'uv run python scripts/release_validate.py --github-output "${GITHUB_OUTPUT}"' + - name: 'Run release build validation' + run: 'just build-all' + env: + UV_PYTHON_VERSION: '3.12' + - name: 'Build core release artifacts' + run: | + rm -rf release-dist + mkdir -p release-dist/op_engine + uv run --with build --with twine python -m build --outdir release-dist/op_engine + uv run --with twine python -m twine check --strict release-dist/op_engine/* + - name: 'Build provider release artifacts' + run: | + mkdir -p release-dist/flepimop2-op_engine + cd flepimop2-op_engine + uv run --with build --with twine python -m build --outdir ../release-dist/flepimop2-op_engine + uv run --with twine python -m twine check --strict ../release-dist/flepimop2-op_engine/* + - name: 'Upload core package artifacts' + uses: 'actions/upload-artifact@v7' + with: + name: 'op-engine-dist' + path: 'release-dist/op_engine/*' + if-no-files-found: 'error' + - name: 'Upload provider package artifacts' + uses: 'actions/upload-artifact@v7' + with: + name: 'flepimop2-op-engine-dist' + path: 'release-dist/flepimop2-op_engine/*' + if-no-files-found: 'error' + + publish-core: + name: 'Publish op_engine' + needs: + - 'validate' + runs-on: 'ubuntu-latest' + permissions: + id-token: 'write' + steps: + - name: 'Skip core publish' + if: "${{ github.event.inputs.publish-target == 'none' }}" + run: 'echo "publish-target=none; skipping op_engine publish."' + - name: 'Download core package artifacts' + if: "${{ github.event.inputs.publish-target != 'none' }}" + uses: 'actions/download-artifact@v8' + with: + name: 'op-engine-dist' + path: 'dist' + - name: 'Publish core package to TestPyPI' + if: "${{ github.event.inputs.publish-target == 'testpypi' }}" + uses: 'pypa/gh-action-pypi-publish@release/v1' + with: + packages-dir: 'dist' + repository-url: 'https://test.pypi.org/legacy/' + - name: 'Publish core package to PyPI' + if: "${{ github.event.inputs.publish-target == 'pypi' }}" + uses: 'pypa/gh-action-pypi-publish@release/v1' + with: + packages-dir: 'dist' + + publish-provider: + name: 'Publish flepimop2-op_engine' + needs: + - 'validate' + - 'publish-core' + runs-on: 'ubuntu-latest' + permissions: + id-token: 'write' + steps: + - name: 'Skip provider publish' + if: "${{ github.event.inputs.publish-target == 'none' }}" + run: 'echo "publish-target=none; skipping flepimop2-op_engine publish."' + - name: 'Download provider package artifacts' + if: "${{ github.event.inputs.publish-target != 'none' }}" + uses: 'actions/download-artifact@v8' + with: + name: 'flepimop2-op-engine-dist' + path: 'dist' + - name: 'Publish provider package to TestPyPI' + if: "${{ github.event.inputs.publish-target == 'testpypi' }}" + uses: 'pypa/gh-action-pypi-publish@release/v1' + with: + packages-dir: 'dist' + repository-url: 'https://test.pypi.org/legacy/' + - name: 'Publish provider package to PyPI' + if: "${{ github.event.inputs.publish-target == 'pypi' }}" + uses: 'pypa/gh-action-pypi-publish@release/v1' + with: + packages-dir: 'dist' + + create-github-release: + name: 'Create GitHub release' + needs: + - 'validate' + - 'publish-provider' + if: >- + ${{ + github.event.inputs.create-github-release == 'true' && + (github.event.inputs.publish-target == 'none' || + needs.publish-provider.result == 'success') + }} + runs-on: 'ubuntu-latest' + permissions: + contents: 'write' + steps: + - name: 'Create release' + run: | + ARGS=( + release create "v${VERSION}" + --repo "${GITHUB_REPOSITORY}" + --target "${GITHUB_SHA}" + --title "v${VERSION}" + --generate-notes + ) + if [ "${PRERELEASE}" = 'true' ]; then + ARGS+=(--prerelease) + fi + gh "${ARGS[@]}" + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GITHUB_REPOSITORY: '${{ github.repository }}' + GITHUB_SHA: '${{ github.sha }}' + PRERELEASE: '${{ needs.validate.outputs.prerelease }}' + VERSION: '${{ needs.validate.outputs.version }}' + + deploy-docs: + name: 'Deploy release docs' + needs: + - 'create-github-release' + if: >- + ${{ + github.event.inputs.deploy-docs == 'true' && + needs.create-github-release.result == 'success' + }} + permissions: + contents: 'write' + uses: './.github/workflows/gh-pages.yaml' + with: + deploy-mode: 'release' + secrets: 'inherit' diff --git a/docs/development/creating-a-release.md b/docs/development/creating-a-release.md new file mode 100644 index 0000000..14303f9 --- /dev/null +++ b/docs/development/creating-a-release.md @@ -0,0 +1,129 @@ +# Creating A Release + +This guide covers the `op_engine` release process for this repository's two Python distributions: + +- `op_engine` +- `flepimop2-op_engine` + +The release workflow treats them as a single release unit. Both package versions must match, and the workflow publishes them sequentially under one GitHub release tag. + +## Prerequisites + +- You have write access to the [`ACCIDDA/op_engine`](https://github.com/ACCIDDA/op_engine) repository. +- The release version has already been updated everywhere it is declared: + - `pyproject.toml` + - `flepimop2-op_engine/pyproject.toml` +- Your local environment is synced with a supported Python version. +- You have [GitHub's `gh` CLI](https://cli.github.com/) installed and authenticated if you plan to dispatch workflows from the command line. + +## 1. Confirm The Shared Version + +The release workflow validates that all version declarations match before it builds or publishes anything. + +Today that means these two files must contain the same semantic version: + +- `pyproject.toml` +- `flepimop2-op_engine/pyproject.toml` + +If any of them differ, the `validate` job fails immediately. + +## 2. Run The Local Release Preflight + +Use the local pre-release target before dispatching the release workflow: + +```shell +just release-validate +``` + +That command does two things: + +- Validates that the release version matches in: + - `pyproject.toml` + - `flepimop2-op_engine/pyproject.toml` +- Runs `just build-all` to execute the clean-room build and install tests for both packages. + +The release workflow runs the same validation again on GitHub Actions before publishing. + +## 3. Run The Release Workflow + +Releases are created through the manual GitHub Actions workflow in `.github/workflows/release.yaml`. + +If you are testing the workflow before merging, add `--ref ` to the `gh workflow run` command. Without `--ref`, GitHub dispatches the workflow definition from the repository's default branch. + +### Dry Run + +Use this to validate shared versioning, build both packages, and upload release artifacts without publishing anything: + +```shell +gh workflow run release.yaml \ + --repo ACCIDDA/op_engine \ + --ref \ + --field publish-target=none \ + --field create-github-release=false \ + --field deploy-docs=false +``` + +This runs the `validate`, `publish-core`, and `publish-provider` jobs, but the publish jobs become no-ops when `publish-target=none`. + +### TestPyPI + +Use this to publish both packages to TestPyPI without creating the GitHub release or deploying docs: + +```shell +gh workflow run release.yaml \ + --repo ACCIDDA/op_engine \ + --ref \ + --field publish-target=testpypi \ + --field create-github-release=false \ + --field deploy-docs=false +``` + +The workflow always publishes in dependency order: + +1. `op_engine` +2. `flepimop2-op_engine` + +When testing from a branch, keep `create-github-release=false` and `deploy-docs=false`. The reusable docs workflow checks out `main`, so it is not intended for pre-merge branch testing. + +### PyPI + +Use this to publish both packages, create a GitHub release, and deploy the versioned documentation: + +```shell +gh workflow run release.yaml \ + --repo ACCIDDA/op_engine \ + --field publish-target=pypi \ + --field create-github-release=true \ + --field deploy-docs=true +``` + +When `create-github-release=true`, the workflow creates a single `vX.Y.Z` GitHub release for the shared package version and uses GitHub's generated release notes. + +## 4. Documentation Deployment + +The release workflow calls `.github/workflows/gh-pages.yaml` as a reusable workflow when `deploy-docs=true`. + +That workflow supports two deployment modes: + +- `push`: for normal `main` branch documentation updates +- `release`: for a tagged release, which updates the release alias in `mike` + +If you want to deploy docs manually without running a release, you can dispatch `gh-pages.yaml` directly and choose the deploy mode in the GitHub Actions UI. + +## 5. Trusted Publishing Setup + +The publish jobs use PyPI Trusted Publishing rather than a stored API token. + +For `publish-target=testpypi`, configure the TestPyPI trusted publisher entry for: + +- Owner: `ACCIDDA` +- Repository: `op_engine` +- Workflow file: `release.yaml` + +For `publish-target=pypi`, configure the PyPI trusted publisher entry for the same repository and workflow file. + +Because this repository publishes two distributions from one workflow, the same trusted publisher setup must be allowed to publish both package names on the selected index. + +## 6. Current Packaging Note + +The release workflow now handles the mechanics of validating, building, and sequencing both distributions together. Package-index acceptance still depends on the metadata inside each built distribution, so publishing `flepimop2-op_engine` to a public index may still require follow-up packaging changes outside the workflow itself. diff --git a/justfile b/justfile index f7f8cbd..6ad3dee 100644 --- a/justfile +++ b/justfile @@ -114,6 +114,7 @@ build-test-provider: uv pip install --python "${CLEANROOM}/venv/bin/python" -r "${CLEANROOM}/dev-requirements.txt" cp flepimop2-op_engine/pyproject.toml "${CLEANROOM}/pyproject.toml" cp flepimop2-op_engine/README.md "${CLEANROOM}/README.md" + cp flepimop2-op_engine/LICENSE "${CLEANROOM}/LICENSE" cp -R flepimop2-op_engine/src "${CLEANROOM}/src" cp -R flepimop2-op_engine/tests "${CLEANROOM}/tests" cd "${CLEANROOM}" @@ -129,6 +130,16 @@ build-all-provider: build-check-provider build-test-provider build-all: build-all-core build-all-provider +# ------------------------------------------------- +# Release validation +# ------------------------------------------------- + +release-check: + uv run python scripts/release_validate.py + +release-validate: release-check build-all + + # ------------------------------------------------- # Utilities # ------------------------------------------------- diff --git a/mkdocs.yml b/mkdocs.yml index b924070..fc9dce3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,8 @@ markdown_extensions: # Navigation structure nav: - Home: index.md + - Development: + - Creating A Release: development/creating-a-release.md - Guides: - Getting Started: guides/getting-started.md - API Reference: diff --git a/pyproject.toml b/pyproject.toml index 56fe471..fafda1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/**/*" = ["INP001", "S101"] "scripts/**/*" = [ + "N999", # Invalid module name "T201", # `print` found ] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..b4c0756 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository utility scripts.""" diff --git a/scripts/release_validate.py b/scripts/release_validate.py new file mode 100644 index 0000000..abeb54a --- /dev/null +++ b/scripts/release_validate.py @@ -0,0 +1,94 @@ +"""Validate release version consistency for both distributable packages.""" + +from __future__ import annotations + +import argparse +import os +import pathlib +import re +import tomllib +from typing import Final + +REPO_ROOT: Final[pathlib.Path] = pathlib.Path(__file__).resolve().parents[1] +SEMVER_PATTERN: Final[re.Pattern[str]] = re.compile(r"^\d+\.\d+\.\d+$") + + +def get_declared_versions() -> dict[str, str]: + """Read each release version declaration used by this repository. + + Returns: + A mapping from file path to declared release version. + """ + core_version = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text("utf-8"))[ + "project" + ]["version"] + provider_version = tomllib.loads( + (REPO_ROOT / "flepimop2-op_engine" / "pyproject.toml").read_text("utf-8") + )["project"]["version"] + + return { + "pyproject.toml": str(core_version), + "flepimop2-op_engine/pyproject.toml": str(provider_version), + } + + +def validate_release_version() -> str: + """Validate that all release version declarations agree. + + Returns: + The shared release version. + + Raises: + SystemExit: If any declared versions differ or do not follow `X.Y.Z`. + """ + versions = get_declared_versions() + distinct_versions = sorted(set(versions.values())) + if len(distinct_versions) != 1: + rendered = ", ".join(f"{path}={version}" for path, version in versions.items()) + msg = f"Release versions do not match across packages: {rendered}" + raise SystemExit(msg) + + version = distinct_versions[0] + if SEMVER_PATTERN.fullmatch(version) is None: + msg = f"Release version must be semantic X.Y.Z, got {version!r}." + raise SystemExit(msg) + + return version + + +def write_github_outputs(version: str, output_path: pathlib.Path) -> None: + """Write workflow outputs for downstream GitHub Actions jobs.""" + docs_version = ".".join(version.split(".")[:2]) + prerelease = str(version.startswith("0.")).lower() + with output_path.open("a", encoding="utf-8") as fh: + fh.write(f"version={version}\n") + fh.write(f"docs-version={docs_version}\n") + fh.write(f"prerelease={prerelease}\n") + + +def main() -> None: + """Run release validation.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "--github-output", + type=pathlib.Path, + default=None, + help="Optional path to a GitHub Actions output file.", + ) + args = parser.parse_args() + + version = validate_release_version() + print(f"Validated release version: {version}") + + output_path = args.github_output + if output_path is None: + raw_output = os.environ.get("GITHUB_OUTPUT") + if raw_output: + output_path = pathlib.Path(raw_output) + + if output_path is not None: + write_github_outputs(version, output_path) + + +if __name__ == "__main__": + main() diff --git a/src/op_engine/__init__.py b/src/op_engine/__init__.py index 13e5037..efe2691 100644 --- a/src/op_engine/__init__.py +++ b/src/op_engine/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from importlib.metadata import version as _metadata_version + from .core_solver import CoreSolver from .matrix_ops import ( DiffusionConfig, @@ -48,4 +50,4 @@ "smooth", ] -__version__ = "0.1.0" +__version__ = _metadata_version("op_engine")