diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92b0ae0c..cebb2cc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,14 @@ on: - '**.md' - '.github/workflows/release.yml' - '.gitignore' + workflow_call: + inputs: + ref: + description: 'Ref to test. Defaults to the ref that triggered the workflow.' + required: false + type: string concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ inputs.ref || github.ref }}-tests cancel-in-progress: true jobs: lint: @@ -24,6 +30,8 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} - name: Setup uv uses: astral-sh/setup-uv@v5 @@ -52,6 +60,8 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} - name: Setup uv uses: astral-sh/setup-uv@v7 @@ -89,6 +99,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 with: + ref: ${{ inputs.ref }} path: modflow-devtools - name: Checkout modflow6 for DFN autodiscovery @@ -160,7 +171,8 @@ jobs: name: Docs needs: test runs-on: ubuntu-22.04 - if: github.repository_owner == 'MODFLOW-ORG' && github.event_name == 'push' + # skip on release-branch test runs called by the release workflow + if: github.repository_owner == 'MODFLOW-ORG' && github.event_name == 'push' && !inputs.ref steps: - name: Trigger RTD uses: dfm/rtds-action@v1 diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 00000000..27bab9d9 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,43 @@ +name: pull request + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + branches: [develop] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + + title: + name: check title + # the release and reset pull requests are opened by a bot and are not + # squash merged, so their titles do not reach the changelog + if: github.event.pull_request.user.type != 'Bot' + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + + - name: Check conventional commit format + env: + TITLE: ${{ github.event.pull_request.title }} + run: | + # pull requests are squash merged, so the title becomes the commit + # message in develop, and the changelog is generated from those + # messages. a title that is not a conventional commit is dropped + # from the release notes without warning. + types="build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test" + if [[ ! "$TITLE" =~ ^($types)(\([^\)]+\))?!?:\ .+ ]]; then + echo "::error::'$TITLE' is not a conventional commit header." + echo "" + echo "Use '(): ', for example:" + echo " feat(fixtures): add a session-scoped temp dir" + echo "" + echo "Type is one of: ${types//|/, }." + echo "Only feat, fix, perf and refactor reach the release notes;" + echo "see cliff.toml. A user facing change needs one of those." + exit 1 + fi + echo "'$TITLE' is a conventional commit header" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e71d9d50..cfa09553 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,28 +1,59 @@ name: Release on: + # start a release from the GitHub Actions UI (or `gh workflow run release.yml`) + workflow_dispatch: + inputs: + branch: + description: 'Branch to release from.' + required: true + type: string + default: 'develop' + version: + description: "Version number to release, e.g. 1.9.3. Defaults to version.txt with its development suffix removed." + required: false + type: string + run_tests: + description: 'Run the test suite before drafting the release.' + required: false + type: boolean + default: true push: branches: - - main + # a release can also be started by pushing a release branch - v[0-9]+.[0-9]+.[0-9]+* + # merging the release branch drafts the release and publishes to PyPI + - main release: types: - published jobs: prep: name: Prepare release + # runs on workflow_dispatch, or when a release branch is first pushed. + # later pushes to the release branch must not prepare the release again. + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name != 'main' && github.event.created) }} runs-on: ubuntu-latest - if: ${{ github.event_name == 'push' && github.ref_name != 'main' }} permissions: contents: write - pull-requests: write + outputs: + version: ${{ steps.version.outputs.version }} defaults: run: shell: bash steps: - - name: Checkout release branch + - name: Check release branch + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + if [[ "${{ inputs.branch }}" == "main" ]]; then + echo "error: releases may not be started from main" + exit 1 + fi + + - name: Checkout source branch uses: actions/checkout@v3 with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref_name }} fetch-depth: 0 - name: Setup Python @@ -37,14 +68,31 @@ jobs: pip install --upgrade pip pip install . --group test --group build twine - - name: Update version + - name: Resolve version id: version run: | - ref="${{ github.ref_name }}" - version="${ref#"v"}" - python scripts/update_version.py -v "$version" + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + if [[ -n "${{ inputs.version }}" ]]; then + ver="${{ inputs.version }}" + else + ver=$(python scripts/update_version.py --release --dry-run) + fi + else + # release branch name is the version number, prefixed with 'v' + ref="${{ github.ref_name }}" + ver="${ref#"v"}" + fi + echo "releasing version $ver" + echo "version=$ver" >> $GITHUB_OUTPUT + + - name: Create release branch + if: ${{ github.event_name == 'workflow_dispatch' }} + run: git switch -c "v${{ steps.version.outputs.version }}" + + - name: Update version + run: | + python scripts/update_version.py -v "${{ steps.version.outputs.version }}" python -c "import modflow_devtools; print('Version: ', modflow_devtools.__version__)" - echo "version=$version" >> $GITHUB_OUTPUT - name: Touch changelog run: touch HISTORY.md @@ -89,36 +137,72 @@ jobs: path: ${{ steps.update-changelog.outputs.changelog }} - name: Push release branch - env: - GITHUB_TOKEN: ${{ github.token }} run: | ver="${{ steps.version.outputs.version }}" - changelog=$(cat ${{ steps.update-changelog.outputs.changelog }} | grep -v "### Version $ver") - + # remove this release's changelog so we don't commit it # the changes have already been prepended to HISTORY.md rm ${{ steps.update-changelog.outputs.changelog }} rm -f CHANGELOG.md - + # commit and push changes git config core.sharedRepository true git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A - git commit -m "ci(release): set version to ${{ steps.version.outputs.version }}, update changelog" - git push origin "${{ github.ref_name }}" + git commit -m "ci(release): set version to $ver, update changelog" + git push origin "v$ver" + + test: + name: Test release branch + needs: prep + if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_tests }} + permissions: + contents: read + uses: ./.github/workflows/ci.yml + with: + ref: v${{ needs.prep.outputs.version }} + pr: + name: Draft release pull request + needs: [prep, test] + if: ${{ always() && needs.prep.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped') }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + defaults: + run: + shell: bash + steps: + + - name: Checkout release branch + uses: actions/checkout@v3 + with: + ref: v${{ needs.prep.outputs.version }} + + - name: Download changelog + uses: actions/download-artifact@v4 + with: + name: changelog + + - name: Draft pull request + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + ver="${{ needs.prep.outputs.version }}" + changelog=$(grep -v "### Version $ver" "CHANGELOG_$ver.md") title="Release $ver" body=' # Release '$ver' - + The release can be approved by merging this pull request into `main`. This will trigger a job to publish the release to PyPI. - + ## Changelog - + '$changelog' ' - gh pr create -B "main" -H "${{ github.ref_name }}" --title "$title" --draft --body "$body" + gh pr create -B "main" -H "v$ver" --title "$title" --draft --body "$body" release: name: Draft release @@ -197,3 +281,59 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + reset: + name: Reset develop + # runs after the release is published, opens a PR merging main back into develop + needs: publish + if: ${{ github.event_name == 'release' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + defaults: + run: + shell: bash + steps: + + - name: Checkout main branch + uses: actions/checkout@v3 + with: + ref: main + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: 3.x + + - name: Install Python dependencies + run: | + pip install --upgrade pip + pip install filelock packaging + + - name: Open reset pull request + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + ver=$(cat version.txt) + branch="post-release-$ver-reset" + + # bump the minor version and re-add the '.dev0' suffix; prints the new version + next=$(python scripts/update_version.py --post-release) + + git config core.sharedRepository true + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add version.txt modflow_devtools/__init__.py docs/conf.py + git commit -m "ci(release): update version to $next" + git push origin "$branch" + + body=' + # Reset `develop` after release '$ver' + + Merge (do not squash) this pull request to bring `main` back into `develop` and set the + development version to `'$next'`. + ' + gh pr create -B "develop" -H "$branch" --title "Reset develop after release $ver" --body "$body" diff --git a/DEVELOPER.md b/DEVELOPER.md index 858c098d..3cd6208b 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -14,6 +14,11 @@ This document provides guidance to set up a development environment and discusse - [Writing new tests](#writing-new-tests) - [Temporary directories](#temporary-directories) - [Releasing](#releasing) + - [1. Start the release](#1-start-the-release) + - [2. Review and approve](#2-review-and-approve) + - [3. Publish](#3-publish) + - [4. conda-forge](#4-conda-forge) + - [Changelog conventions](#changelog-conventions) @@ -73,20 +78,85 @@ Tests which must write to disk use `pytest`'s built-in `temp_dir` fixture or one ## Releasing -The `modflow-devtools` release procedure is automated with GitHub Actions in [`.github/workflows/release.yml`](.github/workflows/release.yml). Making a release involves the following steps: +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. -1. Release from `master` branch -2. Reinitialize the `develop` branch -3. Publish the package to PyPI +> [!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/modflow-devtools/settings/publishing/ to match. -To begin an automated release, create a release branch from `develop`. The release branch name should be the version number of with a `v`a prefix (e.g., `v0.0.6`). Pushing the release branch to the `MODFLOW-ORG/modflow-devtools` repository will trigger the release workflow, which begins with the following steps: +### 1. Start the release -- update version strings to match the version number in the release branch name -- generate a changelog since the last release and update `HISTORY.md` -- open a PR from the release branch to `master` +From the [Actions tab](https://github.com/MODFLOW-ORG/modflow-devtools/actions/workflows/release.yml), +select **Run workflow** and fill in the form: -Merging the pull request into `master` triggers another job to draft a release. +| Input | Description | +|:--|:--| +| `branch` | Branch to release from. Defaults to `develop`. | +| `version` | Explicit version number, e.g. `1.9.3`. 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. | -**Note:** the PR should be merged, not squashed. Squashing removes the commit history from the `master` branch and causes `develop` and `master` to diverge, which can cause future PRs updating `master` to replay commits from previous releases. +This can also be done from the command line, for instance: -Publishing the release triggers jobs to publish the `modflow-devtools` package to PyPI and open a PR updating `develop` from `master`. This PR also updates version strings, incrementing the patch version number. \ No newline at end of file +```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.10.0.dev0` releases as `1.10.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/) and prepends it to `HISTORY.md`, runs the CI suite against the +branch, 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: squashing drops the commit history from `main` and makes +`develop` and `main` diverge, which causes later `main` updates to replay old release commits. + +Merging into `main` drafts a GitHub release, with notes taken from the generated changelog. + +### 3. Publish + +Review the draft release and publish it. Publishing it triggers jobs that: + +1. build the package and upload it to [PyPI](https://pypi.org/project/modflow-devtools) +2. open a follow-up pull request resetting `develop` from `main`, with the version number + incremented to the next development version (minor bumped, `.dev0` suffix) + +Merge (do not squash) 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/modflow-devtools-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 with git-cliff, so commits reaching `develop` +should 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. \ No newline at end of file diff --git a/scripts/update_version.py b/scripts/update_version.py index 3e148b77..c39b37ad 100644 --- a/scripts/update_version.py +++ b/scripts/update_version.py @@ -1,4 +1,5 @@ import argparse +import sys import textwrap from datetime import datetime from pathlib import Path @@ -14,9 +15,20 @@ _current_version = Version(_version_txt_path.read_text().strip()) +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") + + def update_version_txt(version: Version): _version_txt_path.write_text(str(version)) - print(f"Updated {_version_txt_path} to version {version}") + print(f"Updated {_version_txt_path} to version {version}", file=sys.stderr) def update_init_py(timestamp: datetime, version: Version): @@ -28,7 +40,7 @@ def update_init_py(timestamp: datetime, version: Version): line = f'__version__ = "{version}"' lines.append(line) _package_init_path.write_text("\n".join(lines) + "\n") - print(f"Updated {_package_init_path} to version {version}") + print(f"Updated {_package_init_path} to version {version}", file=sys.stderr) def update_docs_config(version: Version): @@ -38,7 +50,7 @@ def update_docs_config(version: Version): line = f'release = "{version}"' lines.append(line) _docs_config_path.write_text("\n".join(lines) + "\n") - print(f"Updated {_docs_config_path} to version {version}") + print(f"Updated {_docs_config_path} to version {version}", file=sys.stderr) def update_version( @@ -65,9 +77,10 @@ def update_version( epilog=textwrap.dedent( """\ Update version information stored in version.txt in the project root, - as well as several other files in the repository. If --version is not - provided, the version number will not be changed. A file lock is held - to synchronize file access. The version tag must comply with standard + 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 comply with standard '..' format conventions for semantic versioning. """ ), @@ -79,18 +92,42 @@ def update_version( help="Specify the release version", ) parser.add_argument( - "-g", - "--get", + "-r", + "--release", + required=False, + action="store_true", + help=( + "Use the current development version with its development segment " + "(e.g. '.dev0') removed" + ), + ) + parser.add_argument( + "-p", + "--post-release", + required=False, + action="store_true", + help=( + "Use the development version for the next cycle: the minor version " + "incremented, with a '.dev0' suffix" + ), + ) + parser.add_argument( + "--dry-run", required=False, action="store_true", - help="Just get the current version number, don't update anything (defaults to false)", + help="Print the version that would be written, and exit without writing", ) args = parser.parse_args() - if args.get: - print(Version(_version_txt_path.read_text().strip())) + if args.post_release: + version = post_release_version() + elif args.release: + version = release_version() + elif args.version: + version = Version(args.version) else: - update_version( - timestamp=datetime.now(), - version=(Version(args.version) if args.version else _current_version), - ) + version = _current_version + + if not args.dry_run: + update_version(timestamp=datetime.now(), version=version) + print(version)