diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4808e3..bcb1b38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,52 @@ 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' + 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.' + 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 + # publishing a release draft manually also publishes to PyPI release: types: - published +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false jobs: + prep: name: Prepare release + # runs on workflow_dispatch, or when a release branch is pushed + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name != 'main') }} runs-on: ubuntu-latest - if: ${{ github.event_name == 'push' && github.ref_name != 'main' }} permissions: contents: write pull-requests: write @@ -20,39 +55,79 @@ 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@v4 with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref_name }} fetch-depth: 0 - - name: Setup Python - uses: actions/setup-python@v4 + - name: Setup uv + uses: astral-sh/setup-uv@v5 with: - python-version: '3.11' - cache: 'pip' - cache-dependency-path: pyproject.toml + cache-dependency-glob: "**/pyproject.toml" + + - name: Install + run: uv sync --all-extras - - name: Install Python dependencies + - name: Resolve version + id: version run: | - pip install --upgrade pip - pip install build twine - pip install . --group dev + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + if [[ -n "${{ inputs.version }}" ]]; then + ver="${{ inputs.version }}" + else + ver=$(uv run scripts/update_version.py --get --bump "${{ inputs.bump }}") + 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 - id: version run: | - ref="${{ github.ref_name }}" - version="${ref#"v"}" - python scripts/update_version.py -v "$version" - python -c "import modflowapi; print('Version: ', modflowapi.__version__)" - echo "version=$version" >> $GITHUB_OUTPUT + uv run scripts/update_version.py -v "${{ steps.version.outputs.version }}" + uv run python -c "import modflowapi; print('Version: ', modflowapi.__version__)" - - name: Touch changelog - run: touch HISTORY.md + - name: Lint and format + run: | + uvx ruff check --fix . + uvx ruff format . + uvx codespell + + - name: Install modflow executables + if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_tests }} + uses: modflowpy/install-modflow-action@v1 + with: + path: ${{ github.workspace }}/autotest + repo: modflow6-nightly-build + + # temporary. devtools 2.x will autosync, but 1.x needs opt-in. + - name: Sync devtools + if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_tests }} + run: uv run mf sync + + - name: Run tests + if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_tests }} + working-directory: ./autotest + run: uv run pytest -v -n auto -m "not mf6" - name: Generate changelog - id: cliff - uses: orhun/git-cliff-action@v1 + uses: orhun/git-cliff-action@v4 with: config: cliff.toml args: --verbose --unreleased --tag ${{ steps.version.outputs.version }} @@ -65,115 +140,110 @@ jobs: sed -i 's/#### Ci/#### Continuous integration/' CHANGELOG.md sed -i 's/#### Feat/#### New features/' CHANGELOG.md sed -i 's/#### Fix/#### Bug fixes/' CHANGELOG.md + sed -i 's/#### Perf/#### Performance/' CHANGELOG.md sed -i 's/#### Refactor/#### Refactoring/' CHANGELOG.md sed -i 's/#### Test/#### Testing/' CHANGELOG.md - - # prepend release changelog to cumulative changelog + + # prepend this release's changelog to the cumulative changelog clog="HISTORY.md" temp="temp.md" echo "$(tail -n +2 $clog)" > $clog cat CHANGELOG.md $clog > $temp - sudo mv $temp $clog + mv $temp $clog sed -i '1i # Changelog' $clog - - name: Upload changelog - uses: actions/upload-artifact@v3 - with: - name: changelog - path: CHANGELOG.md - - - name: Lint Python files - run: ruff check . - - - name: Format Python files - run: ruff format . - - name: Push release branch env: GITHUB_TOKEN: ${{ github.token }} run: | ver="${{ steps.version.outputs.version }}" - changelog=$(cat CHANGELOG.md | grep -v "### Version $ver") - - # remove this release's changelog so we don't commit it - # the changes have already been prepended to HISTORY.md + + # the release notes have already been prepended to HISTORY.md, + # keep a copy for the PR body then drop the standalone changelog + notes=$(grep -v "### Version $ver" CHANGELOG.md) 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" - title="Release $ver" body=' # Release '$ver' - - The release can be approved by merging this pull request into `main`. This will trigger jobs to publish the release to PyPI and reset `develop` from `main`, incrementing the minor version number. - + + The release can be approved by merging this pull request into `main`. This will tag and + draft a GitHub release, publish the package to PyPI, and open a follow-up pull request + resetting `develop` from `main` with the version incremented. + ## Changelog - - '$changelog' + + '$notes' ' - gh pr create -B "main" -H "${{ github.ref_name }}" --title "$title" --draft --body "$body" + gh pr create -B "main" -H "v$ver" --title "Release $ver" --draft --body "$body" release: name: Draft release - # runs only when changes are merged to main + # runs only when the release branch is merged to main if: ${{ github.event_name == 'push' && github.ref_name == 'main' }} runs-on: ubuntu-latest permissions: contents: write - pull-requests: write + defaults: + run: + shell: bash + outputs: + version: ${{ steps.release.outputs.version }} steps: - - name: Checkout repo + - name: Checkout main branch uses: actions/checkout@v4 with: ref: main - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install Python dependencies - run: | - pip install --upgrade pip - pip install . --group test - - # actions/download-artifact won't look at previous workflow runs but we need to in order to get changelog - - name: Download artifacts - uses: dawidd6/action-download-artifact@v2 + fetch-depth: 0 - - name: Draft release + - name: Create release + id: release env: GITHUB_TOKEN: ${{ github.token }} run: | - version=$(python scripts/update_version.py -g) - title="modflowapi $version" - notes=$(cat "changelog/CHANGELOG.md" | grep -v "### Version $version") + version=$(cat version.txt) + echo "version=$version" >> $GITHUB_OUTPUT + + # skip if this version has already been released + if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then + echo "tag $version already exists, nothing to release" + exit 0 + fi + + # pull this release's notes back out of the cumulative changelog + notes=$(awk -v hdr="### Version $version" ' + index($0, hdr) == 1 { flag = 1; next } + /^### Version / { flag = 0 } + flag' HISTORY.md) + gh release create "$version" \ --target main \ - --title "$title" \ + --title "modflowapi $version" \ --notes "$notes" \ - --draft \ --latest publish: name: Publish package - # runs only after release is published (manually promoted from draft) - if: github.event_name == 'release' && github.repository_owner == 'MODFLOW-ORG' - runs-on: ubuntu-22.04 + # runs after the release is created, or after a draft release is published manually + needs: release + if: ${{ always() && github.repository_owner == 'MODFLOW-ORG' && ((github.event_name == 'push' && needs.release.result == 'success') || github.event_name == 'release') }} + runs-on: ubuntu-latest permissions: - contents: write - pull-requests: write - id-token: write + contents: read + id-token: write # mandatory for trusted publishing environment: # requires a 'release' environment in repo settings name: release url: https://pypi.org/p/modflowapi + defaults: + run: + shell: bash steps: - name: Checkout main branch @@ -181,23 +251,17 @@ jobs: with: ref: main - - name: Setup Python - uses: actions/setup-python@v4 + - name: Setup uv + uses: astral-sh/setup-uv@v5 with: - python-version: '3.11' - - - name: Install Python dependencies - run: | - pip install --upgrade pip - pip install build twine - pip install . + enable-cache: false - name: Build package - run: python -m build - + run: uv build + - name: Check package - run: twine check --strict dist/* - + run: uvx twine check --strict dist/* + - name: Upload package uses: actions/upload-artifact@v4 with: @@ -206,3 +270,57 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + reset: + name: Reset develop + # runs after the release is created, opens a PR merging main back into develop + needs: release + if: ${{ github.event_name == 'push' && github.ref_name == 'main' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + defaults: + run: + shell: bash + steps: + + - name: Checkout main branch + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + cache-dependency-glob: "**/pyproject.toml" + + - name: Install + run: uv sync --all-extras + + - name: Open reset pull request + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + 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" + + 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 -A + 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/guide-to-publish.md b/guide-to-publish.md index dd43652..e6b70bc 100644 --- a/guide-to-publish.md +++ b/guide-to-publish.md @@ -1,18 +1,52 @@ -# How to publish to PyPi +# How to publish a release -1) If present delete dist folder +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. -2) If not done yet, install build and twine via -``` -pip install build twine -``` -3) Update the version in ``modflowapi/version.py`` +## 1. Start the release -4) Re-create the wheels: -``` -python -m build -``` -5) Re-upload the new files: -``` -twine upload dist/* +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. + +## Changelog conventions + +Release notes are generated from commit messages, so commits merged to `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. See +[`cliff.toml`](cliff.toml) for the commit groups and which ones are skipped. diff --git a/scripts/update_version.py b/scripts/update_version.py index 946a4b0..df753d6 100644 --- a/scripts/update_version.py +++ b/scripts/update_version.py @@ -1,11 +1,12 @@ import argparse +import subprocess import textwrap from datetime import datetime from os.path import basename from pathlib import Path from filelock import FileLock -from packaging.version import Version +from packaging.version import InvalidVersion, Version _project_name = "modflowapi" _project_root_path = Path(__file__).parent.parent @@ -21,6 +22,50 @@ def log_update(path, version: Version): print(f"Updated {path} with version {version}") +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.""" + version = Version(_current_version.base_version) + return Version(f"{version.major}.{version.minor + 1}.0.dev0") + + def update_version_txt(version: Version): with open(_version_txt_path, "w") as f: f.write(str(version)) @@ -35,12 +80,14 @@ def update_version_py(timestamp: datetime, version: Version): log_update(_version_py_path, version) -def update_citation_cff(version: Version): +def update_citation_cff(timestamp: datetime, version: Version): lines = open(_citation_cff_path, "r").readlines() with open(_citation_cff_path, "w") as f: for line in lines: if line.startswith("version:"): line = f"version: {version}\n" + elif line.startswith("date-released:"): + line = f"date-released: '{timestamp.strftime('%Y-%m-%d')}'\n" f.write(line) log_update(_citation_cff_path, version) @@ -49,13 +96,12 @@ def update_version(timestamp: datetime = datetime.now(), version: Version = None lock_path = Path(_version_py_path.name + ".lock") try: lock = FileLock(lock_path) - previous = Version(_version_txt_path.read_text().strip()) - version = version if version else Version(previous.major, previous.minor, previous.patch) + version = version if version else _current_version with lock: update_version_txt(version) update_version_py(timestamp, version) - update_citation_cff(version) + update_citation_cff(timestamp, version) finally: try: lock_path.unlink() @@ -70,27 +116,51 @@ def update_version(timestamp: datetime = datetime.now(), version: Version = None epilog=textwrap.dedent( """\ Update version information 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 be - standard '..' format for semantic versioning. + 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 + '..' format for semantic versioning. """ ), ) parser.add_argument("-v", "--version", required=False, help="Specify the release version") + parser.add_argument( + "-b", + "--bump", + 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" + ), + ) + parser.add_argument( + "-n", + "--next-dev", + required=False, + action="store_true", + help="Compute the next development version, incrementing the minor version number", + ) parser.add_argument( "-g", "--get", required=False, action="store_true", - help="Get the current version number, no updates (defaults false)", + help="Print the version number, no updates (defaults false)", ) args = parser.parse_args() + if args.next_dev: + version = next_dev_version() + elif args.bump: + version = bump_version(args.bump) + elif args.version: + version = Version(args.version) + else: + version = _current_version + if args.get: - print(_current_version) + print(version) else: - update_version( - timestamp=datetime.now(), - version=(Version(args.version) if args.version else _current_version), - ) + update_version(timestamp=datetime.now(), version=version)