diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba84b6d..b5c2972 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,18 +8,8 @@ on: required: true type: string default: 'develop' - bump: - description: 'Version increment relative to the latest release tag. Ignored if a version number is given below.' - required: true - type: choice - default: 'minor' - options: - - minor - - patch - - major - - dev version: - description: 'Version number to release, e.g. 0.3.0. Overrides the increment selected above.' + description: "Version number to release, e.g. 0.3.0. Defaults to version.txt with its development suffix removed." required: false type: string run_tests: @@ -86,7 +76,7 @@ jobs: if [[ -n "${{ inputs.version }}" ]]; then ver="${{ inputs.version }}" else - ver=$(uv run scripts/update_version.py --get --bump "${{ inputs.bump }}") + ver=$(uv run scripts/update_version.py --release --dry-run) fi else # release branch name is the version number, prefixed with 'v' @@ -332,8 +322,7 @@ jobs: ver="${{ needs.release.outputs.version }}" branch="post-release-$ver-reset" - next=$(uv run scripts/update_version.py --get --next-dev) - uv run scripts/update_version.py -v "$next" + next=$(uv run scripts/update_version.py --post-release) git config core.sharedRepository true git config user.name "github-actions[bot]" diff --git a/DEVELOPER.md b/DEVELOPER.md new file mode 100644 index 0000000..21aca6d --- /dev/null +++ b/DEVELOPER.md @@ -0,0 +1,177 @@ +# Developing `modflowapi` + +This document explains how to set up a development environment, run the tests, and cut a release. +Conventions used in the project are noted along the way. + + + + + +- [Requirements](#requirements) +- [Installation](#installation) +- [Linting](#linting) +- [Testing](#testing) + - [MODFLOW executables and example models](#modflow-executables-and-example-models) + - [Running the tests](#running-the-tests) +- [Releasing](#releasing) + - [1. Start the release](#1-start-the-release) + - [2. Review and approve](#2-review-and-approve) + - [3. Automatic steps](#3-automatic-steps) + - [4. conda-forge](#4-conda-forge) + - [Changelog conventions](#changelog-conventions) + + + +## Requirements + +Python 3.11+. Like the other MODFLOW Python projects, `modflowapi` aims to support recent Python +versions, loosely following [SPEC 0](https://scientific-python.org/specs/spec-0000/#support-window). + +## Installation + +Fork and clone the repository, then install the project in editable mode with the development +dependencies: + +```shell +pip install -e . --group dev +``` + +Developers who use `uv` can sync the project instead: + +```shell +uv sync --all-extras +``` + +The `dev` group includes the `test` and `lint` groups. + +## Linting + +Linting and formatting use [`ruff`](https://docs.astral.sh/ruff/), and spelling is checked with +[`codespell`](https://github.com/codespell-project/codespell): + +```shell +ruff check . +ruff format . +codespell +``` + +These are also run in CI and must pass. + +## Testing + +The tests use [`pytest`](https://docs.pytest.org/) with +[`pytest-xdist`](https://pytest-xdist.readthedocs.io/) and fixtures from +[`modflow-devtools`](https://github.com/MODFLOW-ORG/modflow-devtools). They live in `autotest/` and +are run from that directory. + +### MODFLOW executables and example models + +The tests need the MODFLOW 6 executables, including the `libmf6` shared library, either on the +`PATH` or installed into `autotest/`. They also need the MODFLOW 6 example models. Both can be +fetched with the `mf` command from `modflow-devtools`: + +```shell +mf programs install libmf6 --bindir autotest +mf sync +``` + +FloPy's [`get-modflow`](https://github.com/modflowpy/flopy) utility is an alternative way to install +the executables. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml) for exactly what CI +installs, including the nightly build used for the example-model tests. + +### Running the tests + +From `autotest/`, run in parallel with verbose output: + +```shell +pytest -v -n auto +``` + +Two markers select subsets of the suite (see [`autotest/pytest.ini`](autotest/pytest.ini)): + +- `mf6`: tests that run the MODFLOW 6 example models +- `extensions`: tests for the `modflowapi` extensions + +For instance, to skip the example-model tests: `pytest -v -n auto -m "not mf6"`. + +## Releasing + +Releases are automated by [`.github/workflows/release.yml`](.github/workflows/release.yml). +Publishing to PyPI uses [trusted publishing](https://docs.pypi.org/trusted-publishers/), so no +API token is needed, but the repository must have a `release` environment configured. + +> [!IMPORTANT] +> PyPI matches a trusted publisher on the organisation name, the repository name, the workflow +> filename and the environment name. Renaming any of them silently invalidates the publisher, and +> nothing reports it until the next release fails with `invalid-publisher`. After any such rename, +> update the publisher at https://pypi.org/manage/project/modflowapi/settings/publishing/ to match. + +### 1. Start the release + +From the [Actions tab](https://github.com/MODFLOW-ORG/modflowapi/actions/workflows/release.yml), +select **Run workflow** and fill in the form: + +| Input | Description | +|:--|:--| +| `branch` | Branch to release from. Defaults to `develop`. | +| `version` | Explicit version number, e.g. `0.3.0`. Defaults to the version in `version.txt` with its `.dev` suffix removed. | +| `run_tests` | Run the test suite before drafting the release. Defaults to true. | + +This can also be done from the command line, for instance: + +```shell +gh workflow run release.yml -f branch=develop +``` + +The release version is normally the development version already set in `version.txt` (e.g. +`1.2.0.dev0` releases as `1.2.0`); pass `version` only to release something else. The workflow +creates a `v` release branch, updates the version number, regenerates the changelog with +[git-cliff](https://git-cliff.org/), runs the tests, and opens a draft pull request into `main`. + +A release can alternatively be started by pushing a release branch named `v..`. + +### 2. Review and approve + +Review the release pull request, in particular `HISTORY.md`. Mark it ready for review and merge it +into `main`. Merge rather than squash, to preserve the commit history. + +### 3. Automatic steps + +Merging the release pull request into `main` triggers jobs that: + +1. tag the release and create a GitHub release, with notes taken from `HISTORY.md` +2. build the package and publish it to [PyPI](https://pypi.org/project/modflowapi) +3. open a follow-up pull request resetting `develop` from `main`, with the version number + incremented to the next development version + +Merge the reset pull request to finish the release. + +### 4. conda-forge + +A few hours after the upload to PyPI, a bot opens a version pull request on the +[feedstock](https://github.com/conda-forge/modflowapi-feedstock). To start it immediately instead, +open an issue there titled `@conda-forge-admin, please update version`. + +> [!IMPORTANT] +> The bot updates the version number and the checksum, and nothing else. **Check the recipe's +> `host` and `run` requirements against the dependencies the release actually declares**, which are +> the `Requires-Dist` lines of the sdist on PyPI. A maintainer can push corrections to the bot's branch. + +Merging the feedstock pull request builds and uploads the package. It does not appear to a solver +until the channel index is regenerated, which takes up to about an hour; the package is visible on +anaconda.org before then. + +### Changelog conventions + +Release notes are generated from commit messages, so commits merged to `develop` must follow the +[conventional commits](https://www.conventionalcommits.org/) format (`feat:`, `fix:`, `refactor:`, +etc.). Commits that do not follow the convention are omitted from the changelog without warning. +See [`cliff.toml`](cliff.toml) for the commit groups and which ones are skipped. + +Pull requests are squash merged, so the title becomes the commit message the notes are generated +from. [`.github/workflows/pull_request.yml`](.github/workflows/pull_request.yml) rejects a title +that is not a conventional commit header, but it cannot tell whether the type is the right one: a +user facing change titled `chore:` still passes the check and is still dropped from the notes. + +Read the generated changelog on the release pull request before merging it, and make any necessary +edits to the section for the version being cut. diff --git a/README.md b/README.md index 8e05d20..2022c6c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ An extension to [xmipy](https://pypi.org/project/xmipy/) for the [MODFLOW API](h - [Introduction](#introduction) - [Installation](#installation) - [Documentation](#documentation) +- [Contributing](#contributing) - [Citation](#citation) @@ -61,6 +62,11 @@ Examples using `modflowapi` and its extensions can be found in the [Quickstart]( For more info on MODFLOW 6 see [the USGS overview](https://water.usgs.gov/ogw/modflow/). +## Contributing + +See [DEVELOPER.md](DEVELOPER.md) for how to set up a development environment, run the tests, and +cut a release. + ## Citation Hughes, Joseph D., Russcher, M. J., Langevin, C. D., Morway, E. D. and McDonald, R. R., 2022, The MODFLOW Application Programming Interface for simulationcontrol and software interoperability: Environmental Modelling & Software, v. 148, p. 105257, [doi:10.1016/j.envsoft.2021.105257](https://doi.org/10.1016/j.envsoft.2021.105257). diff --git a/guide-to-publish.md b/guide-to-publish.md deleted file mode 100644 index 73d16c6..0000000 --- a/guide-to-publish.md +++ /dev/null @@ -1,86 +0,0 @@ -# How to publish a release - -Releases are automated by [`.github/workflows/release.yml`](.github/workflows/release.yml). -Publishing to PyPI uses [trusted publishing](https://docs.pypi.org/trusted-publishers/), so no -API token is needed, but the repository must have a `release` environment configured. - -> [!IMPORTANT] -> PyPI matches a trusted publisher on the organisation name, the repository name, the workflow -> filename and the environment name. Renaming any of them silently invalidates the publisher, and -> nothing reports it until the next release fails with `invalid-publisher`. This happened when the -> organisation was renamed from `MODFLOW-USGS` to `MODFLOW-ORG`, and went unnoticed for the -> eighteen months until the next release. After any such rename, update the publisher at -> https://pypi.org/manage/project/modflowapi/settings/publishing/ to match. - -## 1. Start the release - -From the [Actions tab](https://github.com/MODFLOW-ORG/modflowapi/actions/workflows/release.yml), -select **Run workflow** and fill in the form: - -| Input | Description | -|:--|:--| -| `branch` | Branch to release from. Defaults to `develop`. | -| `bump` | Version increment relative to the latest release tag: `minor` (default), `patch`, `major`, or `dev` to release the current development version as-is. | -| `version` | Explicit version number, e.g. `0.3.0`. Overrides `bump`. | -| `run_tests` | Run the test suite before drafting the release. Defaults to true. | - -This can also be done from the command line, for instance: - -```shell -gh workflow run release.yml -f branch=develop -f bump=minor -``` - -The workflow creates a `v` release branch, updates the version number, regenerates the -changelog with [git-cliff](https://git-cliff.org/), runs the tests, and opens a draft pull request -into `main`. - -A release can alternatively be started by pushing a release branch named `v..`. - -## 2. Review and approve - -Review the release pull request, in particular `HISTORY.md`. Mark it ready for review and merge it -into `main`. Merge rather than squash, to preserve the commit history. - -## 3. Automatic steps - -Merging the release pull request into `main` triggers jobs that: - -1. tag the release and create a GitHub release, with notes taken from `HISTORY.md` -2. build the package and publish it to [PyPI](https://pypi.org/project/modflowapi) -3. open a follow-up pull request resetting `develop` from `main`, with the version number - incremented to the next development version - -Merge the reset pull request to finish the release. - -## 4. conda-forge - -A few hours after the upload to PyPI, a bot opens a version pull request on the -[feedstock](https://github.com/conda-forge/modflowapi-feedstock). To start it immediately instead, -open an issue there titled `@conda-forge-admin, please update version`. - -> [!IMPORTANT] -> The bot updates the version number and the checksum, and nothing else. **Check the recipe's -> `host` and `run` requirements against the dependencies the release actually declares**, which are -> the `Requires-Dist` lines of the sdist on PyPI. Both releases so far needed this by hand: 1.0.0 -> because the build backend had moved from setuptools to hatchling, and 1.0.1 because the pandas -> lower bound had been raised. A maintainer can push the correction to the bot's branch. - -Merging the feedstock pull request builds and uploads the package. It does not appear to a solver -until the channel index is regenerated, which takes up to about an hour; the package is visible on -anaconda.org before then. - -## Changelog conventions - -Release notes are generated from commit messages, so commits merged to `develop` must follow the -[conventional commits](https://www.conventionalcommits.org/) format (`feat:`, `fix:`, `refactor:`, -etc.). Commits that do not follow the convention are omitted from the changelog without warning. -See [`cliff.toml`](cliff.toml) for the commit groups and which ones are skipped. - -Pull requests are squash merged, so the title becomes the commit message the notes are generated -from. [`.github/workflows/pull_request.yml`](.github/workflows/pull_request.yml) rejects a title -that is not a conventional commit header, but it cannot tell whether the type is the right one: a -user facing change titled `chore:` still passes the check and is still dropped from the notes. - -Read the generated changelog on the release pull request before merging it. Anything missing is -added there, into the section for the version being cut, not to `develop`; the section does not -exist until the release workflow generates it. diff --git a/scripts/update_version.py b/scripts/update_version.py index df753d6..57722df 100644 --- a/scripts/update_version.py +++ b/scripts/update_version.py @@ -1,12 +1,12 @@ import argparse -import subprocess +import sys import textwrap from datetime import datetime from os.path import basename from pathlib import Path from filelock import FileLock -from packaging.version import InvalidVersion, Version +from packaging.version import Version _project_name = "modflowapi" _project_root_path = Path(__file__).parent.parent @@ -14,54 +14,20 @@ _version_py_path = _project_root_path / "modflowapi" / "version.py" _citation_cff_path = _project_root_path / "CITATION.cff" -_initial_version = Version("0.0.1") _current_version = Version(_version_txt_path.read_text().strip()) def log_update(path, version: Version): - print(f"Updated {path} with version {version}") + print(f"Updated {path} with version {version}", file=sys.stderr) -def latest_release() -> Version: - """Version of the most recent release tag, or the initial version if there is none.""" - try: - tags = subprocess.run( - ["git", "tag", "--list", "--sort=-v:refname"], - cwd=_project_root_path, - capture_output=True, - text=True, - check=True, - ).stdout.split() - except (subprocess.CalledProcessError, FileNotFoundError): - tags = [] - for tag in tags: - try: - return Version(tag) - except InvalidVersion: - continue - return _initial_version - - -def bump_version(bump: str) -> Version: - """Next release version, relative to the latest release tag. - - The 'dev' increment releases the current development version as-is, - with any development segment (e.g. '.dev0') stripped. - """ - if bump == "dev": - return Version(_current_version.base_version) - latest = latest_release() - if bump == "major": - return Version(f"{latest.major + 1}.0.0") - elif bump == "minor": - return Version(f"{latest.major}.{latest.minor + 1}.0") - elif bump == "patch": - return Version(f"{latest.major}.{latest.minor}.{latest.micro + 1}") - raise ValueError(f"Unsupported version increment: {bump}") - - -def next_dev_version() -> Version: - """Next development version, incrementing the minor version number.""" +def release_version() -> Version: + """The current development version with any development segment (e.g. '.dev0') removed.""" + return Version(_current_version.base_version) + + +def post_release_version() -> Version: + """Development version for the next cycle: minor incremented, '.dev0' suffix.""" version = Version(_current_version.base_version) return Version(f"{version.major}.{version.minor + 1}.0.dev0") @@ -115,52 +81,47 @@ def update_version(timestamp: datetime = datetime.now(), version: Version = None formatter_class=argparse.RawDescriptionHelpFormatter, epilog=textwrap.dedent( """\ - Update version information in version.txt in the project root, - as well as several other files in the repository. If neither - --version nor --bump nor --next-dev is provided, the version - number will not be changed. A file lock is held to synchronize - file access. The version tag must be standard + Update version information in version.txt in the project root, as + well as several other files in the repository, and print the new + version. If none of --version, --release or --post-release is + provided, the version number is not changed. A file lock is held to + synchronize file access. The version tag must be standard '..' format for semantic versioning. """ ), ) parser.add_argument("-v", "--version", required=False, help="Specify the release version") parser.add_argument( - "-b", - "--bump", + "-r", + "--release", required=False, - choices=["major", "minor", "patch", "dev"], - help=( - "Compute the release version by incrementing the latest release tag. " - "'dev' releases the current development version as-is" - ), + action="store_true", + help="Use the current development version with its development segment (e.g. '.dev0') removed", ) parser.add_argument( - "-n", - "--next-dev", + "-p", + "--post-release", required=False, action="store_true", - help="Compute the next development version, incrementing the minor version number", + help="Use the development version for the next cycle: the minor version incremented, with a '.dev0' suffix", ) parser.add_argument( - "-g", - "--get", + "--dry-run", required=False, action="store_true", - help="Print the version number, no updates (defaults false)", + help="Print the version that would be written, and exit without writing", ) args = parser.parse_args() - if args.next_dev: - version = next_dev_version() - elif args.bump: - version = bump_version(args.bump) + if args.post_release: + version = post_release_version() + elif args.release: + version = release_version() elif args.version: version = Version(args.version) else: version = _current_version - if args.get: - print(version) - else: + if not args.dry_run: update_version(timestamp=datetime.now(), version=version) + print(version)