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/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 5d4100a..e605075 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 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 scripts/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 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/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 --- diff --git a/MANIFEST.in b/MANIFEST.in index 1ac04f5..13d9ed1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,6 @@ -include CHANGELOG.rst -include COPYING-GPLv3.txt -include NEWLINE.rst -include README.rst - -recursive-include tests * -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] +exclude .gitignore +exclude DEVELOPER.rst +exclude RELEASING.rst +exclude tox.ini +prune .github +prune scripts diff --git a/RELEASING.rst b/RELEASING.rst index 5bd3ff4..9327c27 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -2,40 +2,51 @@ 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. -Version -------- +.. _setuptools-scm: https://github.com/pypa/setuptools-scm -``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``. +Relative bump +------------- -- Otherwise it is just a **bug fix** release: ``X.Y.Z+1``. +Run the **Release** workflow, selecting the version bump type: +.. code-block:: console -Steps ------ + gh workflow run release.yml -R pytest-dev/unittest2pytest --field bump=minor -To publish a new release ``X.Y.Z``, the steps are as follows: +Options: ``major``, ``minor``, ``micro``, ``post``. The version is +computed automatically from the latest git tag. -#. Create a new branch named ``release-X.Y.Z`` from the latest ``main``. -#. Update the version in ``unittest2pytest/__init__.py``. +Absolute version +---------------- -#. Update the ``CHANGELOG.rst`` file with the new release information. +To release a specific version (e.g. a release candidate): -#. Commit and push the branch to ``upstream`` and open a PR. +.. code-block:: console -#. Once the PR is **green** and **approved**, start the ``deploy`` workflow: + gh workflow run release.yml -R pytest-dev/unittest2pytest --field version=1.0rc1 - .. code-block:: console - gh workflow run deploy.yml -R pytest-dev/unittest2pytest --ref release-VERSION --field version=VERSION +What the workflow does +---------------------- - The PR will be automatically merged. +#. 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. -#. Update the version in ``unittest2pytest/__init__.py`` and ``CHANGELOG.rst`` for the next release (usually use "minor+1" with the ``.dev0`` suffix). + +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/pyproject.toml b/pyproject.toml index 30bacbb..ac0be84 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,14 +43,9 @@ 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.zest-releaser] -python-file-with-version = "unittest2pytest/__init__.py" -push-changes = false -tag-format = "v{version}" -tag-message = "unittest2pytest {version}" -tag-signing = true +[tool.setuptools_scm] +version_file = "unittest2pytest/_version.py" [tool.ruff] target-version = "py39" diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py new file mode 100644 index 0000000..1f63c14 --- /dev/null +++ b/scripts/check_changelog.py @@ -0,0 +1,132 @@ +#!/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 + + +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 + + +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/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()) 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. 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"