From 1bf079c9c30e89f7d0ff156a5110f0c3a9e54a77 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sun, 6 Sep 2026 19:45:28 -0400 Subject: [PATCH 1/6] docs: consolidate the release guide with modflowapi Rewrite the Releasing section to match the wording used in MODFLOW-ORG/modflowapi's DEVELOPER.md: document trusted publishing and the required release environment, add the trusted-publisher rename caveat, add a conda-forge step, and describe the develop reset. Fix the stale master branch references (the repo moved to main). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UVbcrMW2Ax1LwR9bskJdeu --- DEVELOPER.md | 84 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index 858c098d..08e43087 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,77 @@ 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`. This happened to +> `modflowapi` when the organisation was renamed from `MODFLOW-USGS` to `MODFLOW-ORG`, and went +> unnoticed for eighteen months until the next release. 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` +Create a release branch from `develop`, named `v..` (e.g. `v1.9.3`), and push +it to `MODFLOW-ORG/modflow-devtools`. That triggers the workflow, which: -Merging the pull request into `master` triggers another job to draft a release. +- updates the version number to match the branch name +- regenerates the changelog with [git-cliff](https://git-cliff.org/) and prepends it to `HISTORY.md` +- commits the changes and opens a draft pull request into `main` -**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. +### 2. Review and approve -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 +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 the job that builds the package +and uploads it to [PyPI](https://pypi.org/project/modflow-devtools). + +Then reset `develop`: branch from `main`, set the next development version, and open a pull request +back into `develop`. + +```shell +git switch main && git pull +git switch -c post-x.y.z-release-reset +python scripts/update_version.py -v x.y.z.dev0 +``` + +Merge (do not squash) that 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 a 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 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. Nothing enforces the format on the title, so a user facing change merged with a `chore:` (or +non-conventional) title is dropped from the notes silently. Read the generated changelog on the +release pull request before merging it; add anything missing there, in the section for the version +being cut. \ No newline at end of file From dbc4254cf589519b70ec504ca417117e522481b4 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sun, 6 Sep 2026 19:57:35 -0400 Subject: [PATCH 2/6] ci(release): test the release branch before opening the PR The release branch is pushed by github-actions[bot], so GitHub fires no push or pull_request event for it and the draft release PR carries no checks. Make ci.yml a reusable workflow with a ref input, and have release.yml run it against the v branch between prep and opening the PR. The PR job now depends on that run, so a release cannot be drafted while CI is red. Splits the PR creation out of prep into its own job and passes the changelog through the existing artifact. The rtd job is skipped on these called runs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UVbcrMW2Ax1LwR9bskJdeu --- .github/workflows/ci.yml | 20 ++++++++++-- .github/workflows/release.yml | 58 ++++++++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92b0ae0c..9a05ec70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,18 @@ on: - '**.md' - '.github/workflows/release.yml' - '.gitignore' + # the release workflow calls this to test a release branch, which a bot + # pushes and so does not raise a push or pull_request event of its own + 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 }} + # distinct from the calling workflow's group, so a called run cannot cancel + # the release that started it + group: ${{ github.workflow }}-${{ inputs.ref || github.ref }}-tests cancel-in-progress: true jobs: lint: @@ -24,6 +34,8 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} - name: Setup uv uses: astral-sh/setup-uv@v5 @@ -52,6 +64,8 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} - name: Setup uv uses: astral-sh/setup-uv@v7 @@ -89,6 +103,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 with: + ref: ${{ inputs.ref }} path: modflow-devtools - name: Checkout modflow6 for DFN autodiscovery @@ -160,7 +175,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/release.yml b/.github/workflows/release.yml index e71d9d50..c787a500 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,8 @@ jobs: 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 @@ -89,17 +90,12 @@ 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]" @@ -108,17 +104,57 @@ jobs: git commit -m "ci(release): set version to ${{ steps.version.outputs.version }}, update changelog" git push origin "${{ github.ref_name }}" + test: + name: Test release branch + # a bot pushes the release branch, so no push or pull_request event fires + # for it; run the CI suite against it before opening the release PR + needs: prep + 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 From a757675ff37c85f73c674349ac50228dc2656955 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sun, 6 Sep 2026 20:07:19 -0400 Subject: [PATCH 3/6] ci(release): allow starting a release from the Actions UI Add a workflow_dispatch trigger to release.yml with branch, version and run_tests inputs, mirroring modflowapi. The prep job resolves the version (from the version input, else from version.txt with its development suffix removed, else from the branch name on the push path), creates the v branch itself, and pushes it. Pushing a release branch by hand keeps working. update_version.py gains --release (the current version with its development segment removed) and --post-release (the minor incremented with a '.dev0' suffix, for resetting develop). --get becomes --dry-run; the "Updated ..." lines move to stderr and the resolved version is printed on stdout whether or not it was written, so the develop reset can write and capture it in one call. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UVbcrMW2Ax1LwR9bskJdeu --- .github/workflows/release.yml | 70 ++++++++++++++++++++++++++++++----- DEVELOPER.md | 31 +++++++++++++--- scripts/update_version.py | 67 +++++++++++++++++++++++++-------- 3 files changed, 137 insertions(+), 31 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c787a500..53269f2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,38 @@ 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 outputs: @@ -21,9 +42,18 @@ jobs: 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 @@ -38,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 @@ -91,6 +138,8 @@ jobs: - name: Push release branch run: | + ver="${{ steps.version.outputs.version }}" + # 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 }} @@ -101,14 +150,15 @@ jobs: 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 # a bot pushes the release branch, so no push or pull_request event fires # for it; run the CI suite against it before opening the release PR needs: prep + if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_tests }} permissions: contents: read uses: ./.github/workflows/ci.yml diff --git a/DEVELOPER.md b/DEVELOPER.md index 08e43087..4a6c5740 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -92,12 +92,28 @@ API token is needed, but the repository must have a `release` environment config ### 1. Start the release -Create a release branch from `develop`, named `v..` (e.g. `v1.9.3`), and push -it to `MODFLOW-ORG/modflow-devtools`. That triggers the workflow, which: +From the [Actions tab](https://github.com/MODFLOW-ORG/modflow-devtools/actions/workflows/release.yml), +select **Run workflow** and fill in the form: -- updates the version number to match the branch name -- regenerates the changelog with [git-cliff](https://git-cliff.org/) and prepends it to `HISTORY.md` -- commits the changes and opens a draft pull request into `main` +| 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. | + +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.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 @@ -118,9 +134,12 @@ back into `develop`. ```shell git switch main && git pull git switch -c post-x.y.z-release-reset -python scripts/update_version.py -v x.y.z.dev0 +python scripts/update_version.py --post-release ``` +`--post-release` increments the minor version and adds a `.dev0` suffix; pass `-v x.y.z.dev0` +instead to set it explicitly. + Merge (do not squash) that pull request to finish the release. ### 4. conda-forge 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) From b90515c678b5b8e4b25ef5f2f41b8ed173199013 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 8 Sep 2026 08:10:28 -0400 Subject: [PATCH 4/6] trim --- .github/workflows/ci.yml | 4 ---- .github/workflows/release.yml | 2 -- DEVELOPER.md | 11 ++++------- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a05ec70..cebb2cc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,6 @@ on: - '**.md' - '.github/workflows/release.yml' - '.gitignore' - # the release workflow calls this to test a release branch, which a bot - # pushes and so does not raise a push or pull_request event of its own workflow_call: inputs: ref: @@ -22,8 +20,6 @@ on: required: false type: string concurrency: - # distinct from the calling workflow's group, so a called run cannot cancel - # the release that started it group: ${{ github.workflow }}-${{ inputs.ref || github.ref }}-tests cancel-in-progress: true jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53269f2c..08990db6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,8 +155,6 @@ jobs: test: name: Test release branch - # a bot pushes the release branch, so no push or pull_request event fires - # for it; run the CI suite against it before opening the release PR needs: prep if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_tests }} permissions: diff --git a/DEVELOPER.md b/DEVELOPER.md index 4a6c5740..dce7c928 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -85,10 +85,8 @@ API token is needed, but the repository must have a `release` environment config > [!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 to -> `modflowapi` when the organisation was renamed from `MODFLOW-USGS` to `MODFLOW-ORG`, and went -> unnoticed for eighteen months until the next release. After any such rename, update the publisher -> at https://pypi.org/manage/project/modflow-devtools/settings/publishing/ to match. +> 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. ### 1. Start the release @@ -151,8 +149,7 @@ 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 a correction to the bot's -> branch. +> `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 @@ -169,5 +166,5 @@ skipped. Pull requests are squash merged, so the title becomes the commit message the notes are generated from. Nothing enforces the format on the title, so a user facing change merged with a `chore:` (or non-conventional) title is dropped from the notes silently. Read the generated changelog on the -release pull request before merging it; add anything missing there, in the section for the version +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 From 5534f84ae98837b89b11be4aa1e6f727d4c04336 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 8 Sep 2026 08:16:30 -0400 Subject: [PATCH 5/6] automate post-release reset --- .github/workflows/release.yml | 56 +++++++++++++++++++++++++++++++++++ DEVELOPER.md | 19 ++++-------- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08990db6..cfa09553 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -281,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 dce7c928..68a485fc 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -123,22 +123,13 @@ Merging into `main` drafts a GitHub release, with notes taken from the generated ### 3. Publish -Review the draft release and publish it. Publishing it triggers the job that builds the package -and uploads it to [PyPI](https://pypi.org/project/modflow-devtools). +Review the draft release and publish it. Publishing it triggers jobs that: -Then reset `develop`: branch from `main`, set the next development version, and open a pull request -back into `develop`. +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) -```shell -git switch main && git pull -git switch -c post-x.y.z-release-reset -python scripts/update_version.py --post-release -``` - -`--post-release` increments the minor version and adds a `.dev0` suffix; pass `-v x.y.z.dev0` -instead to set it explicitly. - -Merge (do not squash) that pull request to finish the release. +Merge (do not squash) the reset pull request to finish the release. ### 4. conda-forge From 576476f00d6aa5807c5a5b28cd681da11795faac Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 8 Sep 2026 08:19:31 -0400 Subject: [PATCH 6/6] ci: reject pull request titles the changelog would drop Pull requests are squash merged, so the title becomes the commit message the release notes are generated from, and git-cliff drops anything that is not a conventional commit header without saying so. The title check runs on every pull request except the release and reset ones, which a bot opens and which are not squash merged. Ports MODFLOW-ORG/modflowapi#115. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GAtFFGBtJSR8f1bY2VNEZT --- .github/workflows/pull_request.yml | 43 ++++++++++++++++++++++++++++++ DEVELOPER.md | 9 ++++--- 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/pull_request.yml 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/DEVELOPER.md b/DEVELOPER.md index 68a485fc..3cd6208b 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -155,7 +155,8 @@ changelog without warning. See [`cliff.toml`](cliff.toml) for the commit groups skipped. Pull requests are squash merged, so the title becomes the commit message the notes are generated -from. Nothing enforces the format on the title, so a user facing change merged with a `chore:` (or -non-conventional) title is dropped from the notes silently. 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 +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