From cfd36719841059944ad7ec970cd788dea42611fe Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 3 Apr 2026 07:25:22 +0000 Subject: [PATCH 1/5] Configure setuptools-scm for version management - Add setuptools-scm>=8 to build-system requires - Add [tool.setuptools_scm] with version_file - Replace hardcoded __version__ with import from generated _version.py - Remove version from [tool.setuptools.dynamic] (scm provides it) - Remove MANIFEST.in (setuptools-scm auto-includes git-tracked files) - Add unittest2pytest/_version.py to .gitignore --- .gitignore | 1 + MANIFEST.in | 8 -------- pyproject.toml | 7 ++++--- unittest2pytest/__init__.py | 7 +++++-- 4 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 MANIFEST.in diff --git a/.gitignore b/.gitignore index eaf9c25..bfcc5ee 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ Icon? Thumbs.db *.pid ._.**~ +unittest2pytest/_version.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 1ac04f5..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,8 +0,0 @@ -include CHANGELOG.rst -include COPYING-GPLv3.txt -include NEWLINE.rst -include README.rst - -recursive-include tests * -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] diff --git a/pyproject.toml b/pyproject.toml index 30bacbb..7773064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=77"] +requires = ["setuptools>=77", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] @@ -43,10 +43,11 @@ include-package-data = false [tool.setuptools.dynamic] readme = {file = ["README.rst", "NEWLINE.rst", "CHANGELOG.rst"], content-type = "text/x-rst"} -version = {attr = "unittest2pytest.__version__"} + +[tool.setuptools_scm] +version_file = "unittest2pytest/_version.py" [tool.zest-releaser] -python-file-with-version = "unittest2pytest/__init__.py" push-changes = false tag-format = "v{version}" tag-message = "unittest2pytest {version}" diff --git a/unittest2pytest/__init__.py b/unittest2pytest/__init__.py index 22e45fb..0e91f60 100644 --- a/unittest2pytest/__init__.py +++ b/unittest2pytest/__init__.py @@ -21,6 +21,9 @@ __copyright__ = "Copyright 2015-2019 by Hartmut Goebel" __licence__ = "GNU General Public License version 3 or later (GPLv3+)" - __title__ = "unittest2pytest" -__version__ = "0.6.dev0" + +try: + from ._version import version as __version__ +except ImportError: + __version__ = "0.0.0.dev0+unknown" From a1c22332df12001ba8693a0342a26cc9e0561785 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 3 Apr 2026 07:34:47 +0000 Subject: [PATCH 2/5] Replace zest.releaser with release and changelog tooling - Remove [tool.zest-releaser] from pyproject.toml - Remove zest.releaser and check-manifest from tests/requirements.txt - Add make_changelog.py for preparing releases and adding UNRELEASED - Add check_changelog.py CI check (diffs UNRELEASED against main on PRs, verifies version section on tags) - Add RELEASE_PROCESS.md documenting the tag-based release flow - Merge deploy workflow into CI: publish is gated on tests passing - Add MANIFEST.in to exclude dev files from sdist --- .github/workflows/deploy.yml | 48 -------------- .github/workflows/test.yml | 64 ++++++++++++++++++- MANIFEST.in | 8 +++ RELEASE_PROCESS.md | 42 +++++++++++++ check_changelog.py | 118 +++++++++++++++++++++++++++++++++++ make_changelog.py | 84 +++++++++++++++++++++++++ pyproject.toml | 6 -- tests/requirements.txt | 2 - 8 files changed, 315 insertions(+), 57 deletions(-) delete mode 100644 .github/workflows/deploy.yml create mode 100644 MANIFEST.in create mode 100644 RELEASE_PROCESS.md create mode 100644 check_changelog.py create mode 100644 make_changelog.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 22ec241..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: deploy - -on: - workflow_dispatch: - inputs: - version: - description: 'Release version' - required: true - default: '1.2.3' - -jobs: - - package: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - name: Build and Check Package - uses: hynek/build-and-inspect-python-package@v2.17 - - deploy: - needs: package - runs-on: ubuntu-latest - permissions: - id-token: write # For PyPI trusted publishers. - contents: write # For tag. - - steps: - - uses: actions/checkout@v6 - - - name: Download Package - uses: actions/download-artifact@v8 - with: - name: Packages - path: dist - - - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@v1.13.0 - with: - attestations: true - - - name: GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create v${{ github.event.inputs.version }} --target=${{ github.ref_name }} --title v${{ github.event.inputs.version }} - gh pr merge ${{ github.ref_name }} --merge diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d4100a..b45ae73 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,12 @@ -name: Test +name: CI on: push: branches: - main - "test-me-*" + tags: + - "v*" pull_request: branches: @@ -19,10 +21,28 @@ concurrency: cancel-in-progress: true jobs: + changelog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check changelog (tagged release) + if: github.ref_type == 'tag' + run: python check_changelog.py --tag "${{ github.ref_name }}" + + - name: Check changelog (PR has new entries) + if: github.event_name == 'pull_request' + run: | + git fetch origin main --depth=1 + python check_changelog.py + package: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Build and Check Package uses: hynek/build-and-inspect-python-package@v2.17 @@ -59,3 +79,45 @@ jobs: shell: bash run: | tox run -e py --installpkg `find dist/*.tar.gz` + + pypi-publish: + if: github.ref_type == 'tag' + needs: [changelog, package, test] + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + + steps: + - name: Download Package + uses: actions/download-artifact@v8 + with: + name: Packages + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@v1.13.0 + with: + attestations: true + + github-release: + if: github.ref_type == 'tag' + needs: [pypi-publish] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v6 + + - name: Download Package + uses: actions/download-artifact@v8 + with: + name: Packages + path: dist + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" --generate-notes dist/* diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b70b85c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +exclude .gitignore +exclude RELEASE_PROCESS.md +exclude make_changelog.py +exclude check_changelog.py +exclude DEVELOPER.rst +exclude RELEASING.rst +exclude tox.ini +prune .github diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md new file mode 100644 index 0000000..942aecb --- /dev/null +++ b/RELEASE_PROCESS.md @@ -0,0 +1,42 @@ +# Release Process + +unittest2pytest uses [setuptools-scm](https://github.com/pypa/setuptools-scm) +for version management. Versions are derived automatically from git tags. + +## Cutting a Release + +1. **Update `CHANGELOG.rst`:** + + ``` + python make_changelog.py X.Y.Z + git commit -am "Prepare release X.Y.Z" + git push origin main + ``` + + This replaces the `UNRELEASED` section with a dated `X.Y.Z` section. + Review the result and add any missing entries before committing. + +2. **Tag and push:** + + ``` + git tag -s vX.Y.Z -m "unittest2pytest X.Y.Z" + git push origin vX.Y.Z + ``` + + Pushing the tag triggers the release workflow, which builds, + publishes to PyPI, and creates a GitHub release. + +3. **Start the next development cycle:** + + ``` + python make_changelog.py UNRELEASED + git commit -am "Start next development cycle" + git push origin main + ``` + +## How Versioning Works + +- Tagged commits (e.g. `v0.6`) produce version `0.6`. +- Commits after a tag produce dev versions like `0.7.dev3+gabcdef`. +- The version is written to `unittest2pytest/_version.py` at build time. + This file is git-ignored and should not be committed. diff --git a/check_changelog.py b/check_changelog.py new file mode 100644 index 0000000..414459a --- /dev/null +++ b/check_changelog.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +check_changelog.py — Verify CHANGELOG.rst is up to date. + +Usage: + python check_changelog.py Check UNRELEASED has new entries vs main. + python check_changelog.py --tag v0.6 Check a version section exists for the tag. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +CHANGELOG = Path(__file__).resolve().parent / "CHANGELOG.rst" +REPO_ROOT = CHANGELOG.parent + + +def _unreleased_content(text: str) -> str | None: + """Extract the body of the UNRELEASED section, or None if missing.""" + header = re.search( + r"^UNRELEASED\n-+\n\n\*UNRELEASED\*\n", + text, + re.MULTILINE, + ) + if not header: + return None + rest = text[header.end() :] + next_section = re.search(r"^\S+\n[-=]+\n", rest, re.MULTILINE) + content = rest[: next_section.start()] if next_section else rest + return content.strip() + + +def _main_changelog() -> str | None: + """Read CHANGELOG.rst from the main branch, or None if unavailable.""" + for ref in ("origin/main", "main"): + result = subprocess.run( + ["git", "show", f"{ref}:CHANGELOG.rst"], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + if result.returncode == 0: + return result.stdout + return None + + +def check_unreleased() -> int: + text = CHANGELOG.read_text() + + current = _unreleased_content(text) + if current is None: + print("ERROR: No UNRELEASED section found", file=sys.stderr) + return 1 + + if not current: + print("ERROR: UNRELEASED section is empty — add a changelog entry", file=sys.stderr) + return 1 + + main_text = _main_changelog() + if main_text is not None: + main_content = _unreleased_content(main_text) + if current == (main_content or ""): + print( + "ERROR: UNRELEASED section is unchanged from main — add a changelog entry", + file=sys.stderr, + ) + return 1 + + print("OK: UNRELEASED section has new content") + return 0 + + +def check_tag(tag: str) -> int: + text = CHANGELOG.read_text() + + # Strip leading v from tag + version = tag.removeprefix("v") + + # Look for a section matching this version + pattern = re.compile( + rf"^{re.escape(version)}\n-+\n", + re.MULTILINE, + ) + if not pattern.search(text): + print( + f"ERROR: No changelog section found for {version}", + file=sys.stderr, + ) + return 1 + + # UNRELEASED should not be present in a tagged release + if re.search(r"^UNRELEASED\n-+\n", text, re.MULTILINE): + print( + "ERROR: UNRELEASED section still present in tagged release", + file=sys.stderr, + ) + return 1 + + print(f"OK: Changelog has section for {version}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", help="Git tag to check against (e.g. v0.6)") + args = parser.parse_args() + + if args.tag: + return check_tag(args.tag) + return check_unreleased() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/make_changelog.py b/make_changelog.py new file mode 100644 index 0000000..354bb4f --- /dev/null +++ b/make_changelog.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +make_changelog.py — Update CHANGELOG.rst for releases. + +Usage: + python make_changelog.py Replace UNRELEASED with a dated section. + python make_changelog.py UNRELEASED Add a new UNRELEASED section. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from datetime import date +from pathlib import Path + +CHANGELOG = Path(__file__).resolve().parent / "CHANGELOG.rst" + +UNRELEASED_SECTION = """\ +UNRELEASED +---------- + +*UNRELEASED* + +""" + + +def add_unreleased() -> int: + text = CHANGELOG.read_text() + + if re.search(r"^UNRELEASED\n-+\n", text, re.MULTILINE): + print("ERROR: UNRELEASED section already exists", file=sys.stderr) + return 1 + + # Insert after the top-level heading + match = re.search(r"^(Changelog\n=+\n)\n", text, re.MULTILINE) + if not match: + print("ERROR: Could not find Changelog heading", file=sys.stderr) + return 1 + + insert_at = match.end() + new_text = text[:insert_at] + UNRELEASED_SECTION + "\n" + text[insert_at:] + CHANGELOG.write_text(new_text) + + print("CHANGELOG.rst updated: added UNRELEASED section") + return 0 + + +def cut_release(version: str) -> int: + today = date.today().strftime("%Y-%m-%d") + text = CHANGELOG.read_text() + + pattern = re.compile( + r"^UNRELEASED\n-+\n\n\*UNRELEASED\*\n", + re.MULTILINE, + ) + match = pattern.search(text) + if not match: + print("ERROR: Could not find UNRELEASED section in CHANGELOG.rst", file=sys.stderr) + return 1 + + underline = "-" * len(version) + replacement = f"{version}\n{underline}\n\n*{today}*\n" + + new_text = text[: match.start()] + replacement + text[match.end() :] + CHANGELOG.write_text(new_text) + + print(f"CHANGELOG.rst updated: UNRELEASED → {version} ({today})") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help="Release version (e.g. 0.6) or UNRELEASED") + args = parser.parse_args() + + if args.version == "UNRELEASED": + return add_unreleased() + return cut_release(args.version) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 7773064..ac0be84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,12 +47,6 @@ readme = {file = ["README.rst", "NEWLINE.rst", "CHANGELOG.rst"], content-type = [tool.setuptools_scm] version_file = "unittest2pytest/_version.py" -[tool.zest-releaser] -push-changes = false -tag-format = "v{version}" -tag-message = "unittest2pytest {version}" -tag-signing = true - [tool.ruff] target-version = "py39" extend-exclude = ["tests/fixtures"] diff --git a/tests/requirements.txt b/tests/requirements.txt index aac2c6b..52abf0a 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -2,9 +2,7 @@ pytest >= 3.3.0 # Testing framework. # Helper tools -zest.releaser # Makes releasing easier wheel # For creating .whl packages in Appveyour to avoid compiling again. -check-manifest # Checks MANIFEST.in pyroma # Checks if package follows best practices of Python packaging. chardet # character encoding detector. readme # Check PYPI description. From 8e9eb21483af1fb894173c0155d610d0ab7b5f13 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 3 Apr 2026 07:50:54 +0000 Subject: [PATCH 3/5] Add changelog entries for upcoming release --- CHANGELOG.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a2f231d..72ed6a6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,30 @@ UNRELEASED *UNRELEASED* +- Switch from lib2to3 to `fissix `_, + a maintained fork of lib2to3 (`#95`_). + +- Add Python 3.13, 3.14, and 3.15 support. + +- Migrate packaging from ``setup.py`` to ``pyproject.toml`` with + PEP 639 SPDX license metadata (``GPL-3.0-or-later``) (`#96`_). + +- Use ``setuptools-scm`` for version management. Versions are now + derived from git tags (`#97`_). + +- Add ``check_changelog.py`` CI check to enforce changelog entries + (`#97`_). + +- Gate PyPI publishing on tests passing by merging the release + workflow into CI (`#97`_). + +- Update installation instructions with ``uv tool install`` (`#92`_). + +.. _#92: https://github.com/pytest-dev/unittest2pytest/pull/92 +.. _#95: https://github.com/pytest-dev/unittest2pytest/pull/95 +.. _#96: https://github.com/pytest-dev/unittest2pytest/pull/96 +.. _#97: https://github.com/pytest-dev/unittest2pytest/pull/97 + 0.5 --- From 3ff51d64252ea0132e1bf0f76dc3f84765736e9f Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 3 Apr 2026 09:17:17 +0000 Subject: [PATCH 4/5] Unify RELEASING.rst and RELEASE_PROCESS.md Update RELEASING.rst with the setuptools-scm tag-based release process and delete RELEASE_PROCESS.md. --- MANIFEST.in | 1 - RELEASE_PROCESS.md | 42 --------------------------------- RELEASING.rst | 59 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 45 insertions(+), 57 deletions(-) delete mode 100644 RELEASE_PROCESS.md diff --git a/MANIFEST.in b/MANIFEST.in index b70b85c..aa4e688 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,4 @@ exclude .gitignore -exclude RELEASE_PROCESS.md exclude make_changelog.py exclude check_changelog.py exclude DEVELOPER.rst diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md deleted file mode 100644 index 942aecb..0000000 --- a/RELEASE_PROCESS.md +++ /dev/null @@ -1,42 +0,0 @@ -# Release Process - -unittest2pytest uses [setuptools-scm](https://github.com/pypa/setuptools-scm) -for version management. Versions are derived automatically from git tags. - -## Cutting a Release - -1. **Update `CHANGELOG.rst`:** - - ``` - python make_changelog.py X.Y.Z - git commit -am "Prepare release X.Y.Z" - git push origin main - ``` - - This replaces the `UNRELEASED` section with a dated `X.Y.Z` section. - Review the result and add any missing entries before committing. - -2. **Tag and push:** - - ``` - git tag -s vX.Y.Z -m "unittest2pytest X.Y.Z" - git push origin vX.Y.Z - ``` - - Pushing the tag triggers the release workflow, which builds, - publishes to PyPI, and creates a GitHub release. - -3. **Start the next development cycle:** - - ``` - python make_changelog.py UNRELEASED - git commit -am "Start next development cycle" - git push origin main - ``` - -## How Versioning Works - -- Tagged commits (e.g. `v0.6`) produce version `0.6`. -- Commits after a tag produce dev versions like `0.7.dev3+gabcdef`. -- The version is written to `unittest2pytest/_version.py` at build time. - This file is git-ignored and should not be committed. diff --git a/RELEASING.rst b/RELEASING.rst index 5bd3ff4..8147917 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -2,17 +2,22 @@ Releasing unittest2pytest ========================= -This document describes the steps to make a new ``unittest2pytest`` release. +``unittest2pytest`` uses `setuptools-scm`_ for version management. +Versions are derived automatically from git tags. + +.. _setuptools-scm: https://github.com/pypa/setuptools-scm + Version ------- -``main`` should always be green and a potential release candidate. ``unittest2pytest`` follows -semantic versioning, so given that the current version is ``X.Y.Z``, to find the next version number -one needs to look at the ``CHANGELOG.rst`` file: +``main`` should always be green and a potential release candidate. +``unittest2pytest`` follows semantic versioning, so given that the +current version is ``X.Y.Z``, to find the next version number one +needs to look at the ``CHANGELOG.rst`` file: -- If there any new feature, then we must make a new **minor** release: next - release will be ``X.Y+1.0``. +- If there any new feature, then we must make a new **minor** release: + next release will be ``X.Y+1.0``. - Otherwise it is just a **bug fix** release: ``X.Y.Z+1``. @@ -22,20 +27,46 @@ Steps To publish a new release ``X.Y.Z``, the steps are as follows: -#. Create a new branch named ``release-X.Y.Z`` from the latest ``main``. +#. Update ``CHANGELOG.rst``: + + .. code-block:: console -#. Update the version in ``unittest2pytest/__init__.py``. + python make_changelog.py X.Y.Z -#. Update the ``CHANGELOG.rst`` file with the new release information. + This replaces the ``UNRELEASED`` section with a dated ``X.Y.Z`` + section. Review the result and add any missing entries before + committing. + +#. Commit and push: + + .. code-block:: console -#. Commit and push the branch to ``upstream`` and open a PR. + git commit -am "Prepare release X.Y.Z" + git push origin main -#. Once the PR is **green** and **approved**, start the ``deploy`` workflow: +#. Tag and push: .. code-block:: console - gh workflow run deploy.yml -R pytest-dev/unittest2pytest --ref release-VERSION --field version=VERSION + git tag -s vX.Y.Z -m "unittest2pytest X.Y.Z" + git push origin vX.Y.Z + + Pushing the tag triggers the CI workflow, which builds, tests, + publishes to PyPI, and creates a GitHub release. + +#. Start the next development cycle: + + .. code-block:: console + + python make_changelog.py UNRELEASED + git commit -am "Start next development cycle" + git push origin main + - The PR will be automatically merged. +How versioning works +-------------------- -#. Update the version in ``unittest2pytest/__init__.py`` and ``CHANGELOG.rst`` for the next release (usually use "minor+1" with the ``.dev0`` suffix). +- Tagged commits (e.g. ``v0.6``) produce version ``0.6``. +- Commits after a tag produce dev versions like ``0.7.dev3+gabcdef``. +- The version is written to ``unittest2pytest/_version.py`` at build + time. This file is git-ignored and should not be committed. From 9ec3d2e0a69b2ccf91e44da6d8d7d1bf80208f14 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 3 Apr 2026 09:24:20 +0000 Subject: [PATCH 5/5] Automate release process with workflow_dispatch Add release.yml workflow that handles the full release: prepare changelog, commit, tag, build, test, publish to PyPI, create GitHub release, and start next development cycle. Move publish jobs out of test.yml (CI only tests now). Update RELEASING.rst with instructions to run the workflow. --- .github/workflows/release.yml | 79 +++++++++ .github/workflows/test.yml | 46 +---- MANIFEST.in | 3 +- RELEASING.rst | 62 +++---- make_changelog.py | 84 --------- .../check_changelog.py | 18 +- scripts/extract_changelog.py | 73 ++++++++ scripts/make_changelog.py | 164 ++++++++++++++++++ 8 files changed, 356 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 make_changelog.py rename check_changelog.py => scripts/check_changelog.py (88%) create mode 100644 scripts/extract_changelog.py create mode 100644 scripts/make_changelog.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..94c9f23 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump (ignored if version is set)" + required: false + type: choice + default: minor + options: + - major + - minor + - micro + - post + version: + description: "Exact version (e.g. 1.0rc1). Overrides bump." + required: false + +jobs: + release: + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Prepare changelog + id: version + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + version=$(python scripts/make_changelog.py "${{ github.event.inputs.version }}") + else + version=$(python scripts/make_changelog.py --${{ github.event.inputs.bump }}) + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + + - name: Commit and tag + run: | + git commit -am "Prepare release ${{ steps.version.outputs.version }}" + git tag "${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" + + - name: Build + id: build + uses: hynek/build-and-inspect-python-package@v2.14 + with: + upload-name-suffix: -release + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python scripts/extract_changelog.py "${{ steps.version.outputs.version }}" --format gfm > /tmp/release-notes.md + gh release create "${{ steps.version.outputs.tag }}" \ + --notes-file /tmp/release-notes.md \ + ${{ steps.build.outputs.dist }}/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@v1.13.0 + with: + attestations: true + packages-dir: ${{ steps.build.outputs.dist }} + + - name: Start next development cycle + run: | + python scripts/make_changelog.py UNRELEASED + git commit -am "Start next development cycle" + git push origin main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b45ae73..e605075 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,13 +28,13 @@ jobs: - name: Check changelog (tagged release) if: github.ref_type == 'tag' - run: python check_changelog.py --tag "${{ github.ref_name }}" + run: python scripts/check_changelog.py --tag "${{ github.ref_name }}" - name: Check changelog (PR has new entries) if: github.event_name == 'pull_request' run: | git fetch origin main --depth=1 - python check_changelog.py + python scripts/check_changelog.py package: runs-on: ubuntu-latest @@ -79,45 +79,3 @@ jobs: shell: bash run: | tox run -e py --installpkg `find dist/*.tar.gz` - - pypi-publish: - if: github.ref_type == 'tag' - needs: [changelog, package, test] - runs-on: ubuntu-latest - environment: release - permissions: - id-token: write - - steps: - - name: Download Package - uses: actions/download-artifact@v8 - with: - name: Packages - path: dist - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.13.0 - with: - attestations: true - - github-release: - if: github.ref_type == 'tag' - needs: [pypi-publish] - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - uses: actions/checkout@v6 - - - name: Download Package - uses: actions/download-artifact@v8 - with: - name: Packages - path: dist - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "${{ github.ref_name }}" --generate-notes dist/* diff --git a/MANIFEST.in b/MANIFEST.in index aa4e688..13d9ed1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,6 @@ exclude .gitignore -exclude make_changelog.py -exclude check_changelog.py exclude DEVELOPER.rst exclude RELEASING.rst exclude tox.ini prune .github +prune scripts diff --git a/RELEASING.rst b/RELEASING.rst index 8147917..9327c27 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -8,59 +8,39 @@ Versions are derived automatically from git tags. .. _setuptools-scm: https://github.com/pypa/setuptools-scm -Version -------- +Relative bump +------------- -``main`` should always be green and a potential release candidate. -``unittest2pytest`` follows semantic versioning, so given that the -current version is ``X.Y.Z``, to find the next version number one -needs to look at the ``CHANGELOG.rst`` file: +Run the **Release** workflow, selecting the version bump type: -- If there any new feature, then we must make a new **minor** release: - next release will be ``X.Y+1.0``. +.. code-block:: console -- Otherwise it is just a **bug fix** release: ``X.Y.Z+1``. + gh workflow run release.yml -R pytest-dev/unittest2pytest --field bump=minor +Options: ``major``, ``minor``, ``micro``, ``post``. The version is +computed automatically from the latest git tag. -Steps ------ -To publish a new release ``X.Y.Z``, the steps are as follows: +Absolute version +---------------- -#. Update ``CHANGELOG.rst``: +To release a specific version (e.g. a release candidate): - .. code-block:: console +.. code-block:: console - python make_changelog.py X.Y.Z + gh workflow run release.yml -R pytest-dev/unittest2pytest --field version=1.0rc1 - This replaces the ``UNRELEASED`` section with a dated ``X.Y.Z`` - section. Review the result and add any missing entries before - committing. -#. Commit and push: +What the workflow does +---------------------- - .. code-block:: console - - git commit -am "Prepare release X.Y.Z" - git push origin main - -#. Tag and push: - - .. code-block:: console - - git tag -s vX.Y.Z -m "unittest2pytest X.Y.Z" - git push origin vX.Y.Z - - Pushing the tag triggers the CI workflow, which builds, tests, - publishes to PyPI, and creates a GitHub release. - -#. Start the next development cycle: - - .. code-block:: console - - python make_changelog.py UNRELEASED - git commit -am "Start next development cycle" - git push origin main +#. Runs ``scripts/make_changelog.py`` to replace the ``UNRELEASED`` section + with the version number and today's date. +#. Commits, tags, and pushes. +#. Builds the package. +#. Creates a GitHub Release with the built artifacts. +#. Publishes to PyPI (requires the ``release`` environment). +#. Runs ``scripts/make_changelog.py UNRELEASED`` and pushes a follow-up commit. How versioning works diff --git a/make_changelog.py b/make_changelog.py deleted file mode 100644 index 354bb4f..0000000 --- a/make_changelog.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -""" -make_changelog.py — Update CHANGELOG.rst for releases. - -Usage: - python make_changelog.py Replace UNRELEASED with a dated section. - python make_changelog.py UNRELEASED Add a new UNRELEASED section. -""" - -from __future__ import annotations - -import argparse -import re -import sys -from datetime import date -from pathlib import Path - -CHANGELOG = Path(__file__).resolve().parent / "CHANGELOG.rst" - -UNRELEASED_SECTION = """\ -UNRELEASED ----------- - -*UNRELEASED* - -""" - - -def add_unreleased() -> int: - text = CHANGELOG.read_text() - - if re.search(r"^UNRELEASED\n-+\n", text, re.MULTILINE): - print("ERROR: UNRELEASED section already exists", file=sys.stderr) - return 1 - - # Insert after the top-level heading - match = re.search(r"^(Changelog\n=+\n)\n", text, re.MULTILINE) - if not match: - print("ERROR: Could not find Changelog heading", file=sys.stderr) - return 1 - - insert_at = match.end() - new_text = text[:insert_at] + UNRELEASED_SECTION + "\n" + text[insert_at:] - CHANGELOG.write_text(new_text) - - print("CHANGELOG.rst updated: added UNRELEASED section") - return 0 - - -def cut_release(version: str) -> int: - today = date.today().strftime("%Y-%m-%d") - text = CHANGELOG.read_text() - - pattern = re.compile( - r"^UNRELEASED\n-+\n\n\*UNRELEASED\*\n", - re.MULTILINE, - ) - match = pattern.search(text) - if not match: - print("ERROR: Could not find UNRELEASED section in CHANGELOG.rst", file=sys.stderr) - return 1 - - underline = "-" * len(version) - replacement = f"{version}\n{underline}\n\n*{today}*\n" - - new_text = text[: match.start()] + replacement + text[match.end() :] - CHANGELOG.write_text(new_text) - - print(f"CHANGELOG.rst updated: UNRELEASED → {version} ({today})") - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("version", help="Release version (e.g. 0.6) or UNRELEASED") - args = parser.parse_args() - - if args.version == "UNRELEASED": - return add_unreleased() - return cut_release(args.version) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/check_changelog.py b/scripts/check_changelog.py similarity index 88% rename from check_changelog.py rename to scripts/check_changelog.py index 414459a..1f63c14 100644 --- a/check_changelog.py +++ b/scripts/check_changelog.py @@ -15,7 +15,18 @@ import sys from pathlib import Path -CHANGELOG = Path(__file__).resolve().parent / "CHANGELOG.rst" + +def _find_changelog() -> Path: + d = Path(__file__).resolve().parent + while d != d.parent: + p = d / "CHANGELOG.rst" + if p.exists(): + return p + d = d.parent + raise FileNotFoundError("CHANGELOG.rst not found") + + +CHANGELOG = _find_changelog() REPO_ROOT = CHANGELOG.parent @@ -57,7 +68,10 @@ def check_unreleased() -> int: return 1 if not current: - print("ERROR: UNRELEASED section is empty — add a changelog entry", file=sys.stderr) + print( + "ERROR: UNRELEASED section is empty — add a changelog entry", + file=sys.stderr, + ) return 1 main_text = _main_changelog() diff --git a/scripts/extract_changelog.py b/scripts/extract_changelog.py new file mode 100644 index 0000000..36e577a --- /dev/null +++ b/scripts/extract_changelog.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +extract_changelog.py — Extract a version's changelog section. + +Usage: + python scripts/extract_changelog.py 0.6 + python scripts/extract_changelog.py 0.6 --format gfm +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + + +def _find_changelog() -> Path: + d = Path(__file__).resolve().parent + while d != d.parent: + p = d / "CHANGELOG.rst" + if p.exists(): + return p + d = d.parent + raise FileNotFoundError("CHANGELOG.rst not found") + + +CHANGELOG = _find_changelog() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help="Version to extract (e.g. 0.6)") + parser.add_argument( + "--format", + default="rst", + help="Output format (default: rst). Use 'gfm' for GitHub Flavored Markdown.", + ) + args = parser.parse_args() + + text = CHANGELOG.read_text() + pattern = re.compile( + rf"^{re.escape(args.version)}\n-+\n", + re.MULTILINE, + ) + match = pattern.search(text) + if not match: + print(f"ERROR: No section found for {args.version}", file=sys.stderr) + return 1 + + rest = text[match.end() :] + next_section = re.search(r"^\S+\n[-=]+\n", rest, re.MULTILINE) + body = rest[: next_section.start()].strip() if next_section else rest.strip() + + if args.format != "rst": + result = subprocess.run( + ["pandoc", "-f", "rst", "-t", args.format], + input=body, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"ERROR: pandoc failed: {result.stderr}", file=sys.stderr) + return 1 + body = result.stdout.rstrip() + + print(body) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/make_changelog.py b/scripts/make_changelog.py new file mode 100644 index 0000000..b49a43c --- /dev/null +++ b/scripts/make_changelog.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +make_changelog.py — Update CHANGELOG.rst for releases. + +Usage: + python make_changelog.py 0.6 Replace UNRELEASED with a dated section. + python make_changelog.py --minor Bump minor version from latest tag. + python make_changelog.py --major Bump major version from latest tag. + python make_changelog.py --micro Bump micro version from latest tag. + python make_changelog.py --post Bump post version from latest tag. + python make_changelog.py UNRELEASED Add a new UNRELEASED section. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from datetime import date +from pathlib import Path + +from packaging.version import Version + + +def _find_changelog() -> Path: + d = Path(__file__).resolve().parent + while d != d.parent: + p = d / "CHANGELOG.rst" + if p.exists(): + return p + d = d.parent + raise FileNotFoundError("CHANGELOG.rst not found") + + +CHANGELOG = _find_changelog() +REPO_ROOT = CHANGELOG.parent + +UNRELEASED_SECTION = """\ +UNRELEASED +---------- + +*UNRELEASED* + +""" + + +def _latest_tag_version() -> Version: + """Get the latest vX.Y.Z tag as a packaging Version.""" + result = subprocess.run( + ["git", "tag", "-l", "v*", "--sort=-v:refname"], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + for line in result.stdout.strip().splitlines(): + tag = line.strip().removeprefix("v") + try: + return Version(tag) + except Exception: + continue + print("ERROR: No version tags found", file=sys.stderr) + sys.exit(1) + + +def _bump_version(bump: str) -> str: + """Compute the next version string from the latest tag.""" + v = _latest_tag_version() + + if bump == "major": + return f"{v.major + 1}.0" + elif bump == "minor": + return f"{v.major}.{v.minor + 1}" + elif bump == "micro": + return f"{v.major}.{v.minor}.{v.micro + 1}" + elif bump == "post": + post = (v.post or 0) + 1 + base = f"{v.major}.{v.minor}.{v.micro}" if v.micro else f"{v.major}.{v.minor}" + return f"{base}.post{post}" + else: + raise ValueError(f"Unknown bump: {bump}") + + +def add_unreleased() -> int: + text = CHANGELOG.read_text() + + if re.search(r"^UNRELEASED\n-+\n", text, re.MULTILINE): + print("ERROR: UNRELEASED section already exists", file=sys.stderr) + return 1 + + # Insert after the top-level heading + match = re.search(r"^(Changelog\n=+\n)\n", text, re.MULTILINE) + if not match: + print("ERROR: Could not find Changelog heading", file=sys.stderr) + return 1 + + insert_at = match.end() + new_text = text[:insert_at] + UNRELEASED_SECTION + "\n" + text[insert_at:] + CHANGELOG.write_text(new_text) + + print("CHANGELOG.rst updated: added UNRELEASED section") + return 0 + + +def cut_release(version: str) -> int: + today = date.today().strftime("%Y-%m-%d") + text = CHANGELOG.read_text() + + pattern = re.compile( + r"^UNRELEASED\n-+\n\n\*UNRELEASED\*\n", + re.MULTILINE, + ) + match = pattern.search(text) + if not match: + print( + "ERROR: Could not find UNRELEASED section in CHANGELOG.rst", file=sys.stderr + ) + return 1 + + underline = "-" * len(version) + replacement = f"{version}\n{underline}\n\n*{today}*\n" + + new_text = text[: match.start()] + replacement + text[match.end() :] + CHANGELOG.write_text(new_text) + + print(f"CHANGELOG.rst updated: UNRELEASED → {version} ({today})", file=sys.stderr) + print(version) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "version", nargs="?", help="Release version (e.g. 0.6) or UNRELEASED" + ) + + bump = parser.add_mutually_exclusive_group() + bump.add_argument("--major", action="store_const", const="major", dest="bump") + bump.add_argument("--minor", action="store_const", const="minor", dest="bump") + bump.add_argument("--micro", action="store_const", const="micro", dest="bump") + bump.add_argument("--post", action="store_const", const="post", dest="bump") + + args = parser.parse_args() + + if args.version and args.bump: + parser.error("Cannot specify both a version and a bump flag") + + if args.bump: + version = _bump_version(args.bump) + return cut_release(version) + + if args.version == "UNRELEASED": + return add_unreleased() + + if args.version: + return cut_release(args.version) + + parser.error( + "Provide a version, UNRELEASED, or a bump flag (--major/--minor/--micro/--post)" + ) + + +if __name__ == "__main__": + sys.exit(main())