diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 79e701b844..f39adb8469 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -1 +1,5 @@ # Unreleased + +## ✨ Features + +* [#378](https://github.com/exasol/python-toolbox/pull/378/files): Add Nox task to trigger a release \ No newline at end of file diff --git a/doc/developer_guide/developer_guide.rst b/doc/developer_guide/developer_guide.rst index c6e0f1ce23..3365c30426 100644 --- a/doc/developer_guide/developer_guide.rst +++ b/doc/developer_guide/developer_guide.rst @@ -10,4 +10,4 @@ ../design plugins modules/modules - ../user_guide/how_to_release + how_to_release diff --git a/doc/developer_guide/how_to_release.rst b/doc/developer_guide/how_to_release.rst new file mode 100644 index 0000000000..c5f3bee848 --- /dev/null +++ b/doc/developer_guide/how_to_release.rst @@ -0,0 +1 @@ +.. include:: ../shared_content/how_release.rst diff --git a/doc/shared_content/how_release.rst b/doc/shared_content/how_release.rst new file mode 100644 index 0000000000..cd95b89332 --- /dev/null +++ b/doc/shared_content/how_release.rst @@ -0,0 +1,52 @@ +How to Release? +=============== + +Creating a Release +++++++++++++++++++ + +#. Prepare the project for a new release: + + .. code-block:: shell + + nox -s release:prepare -- --type {major,minor,patch} + +#. Merge your **Pull Request** to the **default branch** + +#. Trigger the release: + + .. code-block:: shell + + nox -s release:trigger + +What to do if the release failed? ++++++++++++++++++++++++++++++++++ + +The release failed during pre-release checks +-------------------------------------------- + +#. Delete the local tag + + .. code-block:: shell + + git tag -d ".."" + +#. Delete the remote tag + + .. code-block:: shell + + git push --delete origin ".." + +#. Fix the issue(s) which led to the failing checks +#. Start the release process from the beginning + + +One of the release steps failed (Partial Release) +------------------------------------------------- +#. Check the GitHub action/workflow to see which steps failed +#. Finish or redo the failed release steps manually + +.. note:: Example + + **Scenario**: Publishing of the release on GitHub was successfully but during the PyPi release, the upload step was interrupted. + + **Solution**: Manually push the package to PyPi \ No newline at end of file diff --git a/doc/user_guide/how_to_release.rst b/doc/user_guide/how_to_release.rst index 66e740c1ed..8b3292cc90 100644 --- a/doc/user_guide/how_to_release.rst +++ b/doc/user_guide/how_to_release.rst @@ -1,79 +1 @@ -How to Release? -=============== - -Creating a Release -++++++++++++++++++ - -1. Set a variable named **TAG** with the appropriate version numbers: - - .. code-block:: shell - - TAG=".." - -#. Prepare the project for a new release: - - .. code-block:: shell - - nox -s release:prepare -- "${TAG}" - -#. Merge your **Pull Request** to the **default branch** -#. Switch to the **default branch**: - - .. code-block:: shell - - git checkout $(git remote show origin | sed -n '/HEAD branch/s/.*: //p') - -#. Update branch: - - .. code-block:: shell - - git pull - -#. Create a new tag in your local repo: - - .. code-block:: shell - - git tag "${TAG}" - -#. Push the repo to remote: - - .. code-block:: shell - - git push origin "${TAG}" - - .. hint:: - - GitHub workflow **.github/workflows/cd.yml** reacts on this tag and starts the release process - -What to do if the release failed? -+++++++++++++++++++++++++++++++++ - -The release failed during pre-release checks --------------------------------------------- - -#. Delete the local tag - - .. code-block:: shell - - git tag -d "${TAG}" - -#. Delete the remote tag - - .. code-block:: shell - - git push --delete origin "${TAG}" - -#. Fix the issue(s) which lead to the failing checks -#. Start the release process from the beginning - - -One of the release steps failed (Partial Release) -------------------------------------------------- -#. Check the Github action/workflow to see which steps failed -#. Finish or redo the failed release steps manually - -.. note:: Example - - **Scenario**: Publishing of the release on Github was successfully but during the PyPi release, the upload step got interrupted. - - **Solution**: Manually push the package to PyPi \ No newline at end of file +.. include:: ../shared_content/how_release.rst \ No newline at end of file diff --git a/exasol/toolbox/nox/_release.py b/exasol/toolbox/nox/_release.py index 02d676d707..2d9db76008 100644 --- a/exasol/toolbox/nox/_release.py +++ b/exasol/toolbox/nox/_release.py @@ -1,22 +1,20 @@ from __future__ import annotations import argparse +import re +import subprocess from pathlib import Path -from typing import ( - List, - Tuple, -) import nox from nox import Session -from exasol.toolbox import cli from exasol.toolbox.nox._shared import ( Mode, _version, ) from exasol.toolbox.nox.plugin import NoxTasks from exasol.toolbox.release import ( + ReleaseTypes, Version, extract_release_notes, new_changelog, @@ -29,13 +27,16 @@ def _create_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="nox -s release:prepare", - usage="nox -s release:prepare -- [-h] version", + usage="nox -s release:prepare -- [-h] [-t | --type {major,minor,patch}]", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( - "version", - type=cli.version, - help=("A version string of the following format:" '"NUMBER.NUMBER.NUMBER"'), + "-t", + "--type", + type=ReleaseTypes, + help="specifies which type of upgrade is to be performed", + required=True, + default=argparse.SUPPRESS, ) parser.add_argument( "--no-add", @@ -89,6 +90,42 @@ def _add_files_to_index(session: Session, files: list[Path]) -> None: session.run("git", "add", f"{file}") +class ReleaseError(Exception): + """Error during trigger release""" + + +def _trigger_release() -> Version: + def run(*args: str): + try: + return subprocess.run( + args, capture_output=True, text=True, check=True + ).stdout + except subprocess.CalledProcessError as ex: + raise ReleaseError( + f"failed to execute command {ex.cmd}\n\n{ex.stderr}" + ) from ex + + branches = run("git", "remote", "show", "origin") + if not (result := re.search(r"HEAD branch: (\S+)", branches)): + raise ReleaseError("default branch could not be found") + default_branch = result.group(1) + + run("git", "checkout", default_branch) + run("git", "pull") + + release_version = Version.from_poetry() + print(f"release version: {release_version}") + + if re.search(rf"{release_version}", run("git", "tag", "--list")): + raise ReleaseError(f"tag {release_version} already exists") + if re.search(rf"{release_version}", run("gh", "release", "list")): + raise ReleaseError(f"release {release_version} already exists") + + run("git", "tag", str(release_version)) + run("git", "push", "origin", str(release_version)) + return release_version + + @nox.session(name="release:prepare", python=False) def prepare_release(session: Session, python=False) -> None: """ @@ -97,14 +134,7 @@ def prepare_release(session: Session, python=False) -> None: parser = _create_parser() args = parser.parse_args(session.posargs) - if not _is_valid_version( - old=(old_version := Version.from_poetry()), - new=(new_version := args.version), - ): - session.error( - f"Invalid version: the release version ({new_version}) " - f"must be greater than or equal to the current version ({old_version})" - ) + new_version = Version.upgrade_version_from_poetry(args.type) if not args.no_branch and not args.no_add: session.run("git", "switch", "-c", f"release/prepare-{new_version}") @@ -146,3 +176,9 @@ def prepare_release(session: Session, python=False) -> None: "--body", '""', ) + + +@nox.session(name="release:trigger", python=False) +def trigger_release(session: Session) -> None: + """trigger an automatic project release""" + print(f"new version: {_trigger_release()}") diff --git a/exasol/toolbox/release/__init__.py b/exasol/toolbox/release/__init__.py index ad4ca5deb3..a8e5b93e6a 100644 --- a/exasol/toolbox/release/__init__.py +++ b/exasol/toolbox/release/__init__.py @@ -3,7 +3,11 @@ import subprocess from dataclasses import dataclass from datetime import datetime -from functools import total_ordering +from enum import Enum +from functools import ( + total_ordering, + wraps, +) from inspect import cleandoc from pathlib import Path from shutil import which @@ -18,6 +22,29 @@ def _index_or(container, index, default): return default +class ReleaseTypes(Enum): + Major = "major" + Minor = "minor" + Patch = "patch" + + def __str__(self): + return self.name.lower() + + +def poetry_command(func): + @wraps(func) + def wrapper(*args, **kwargs): + cmd = which("poetry") + if not cmd: + raise ToolboxError("Couldn't find poetry executable") + try: + return func(*args, **kwargs) + except subprocess.CalledProcessError as ex: + raise ToolboxError(f"Failed to execute: {ex.cmd}") from ex + + return wrapper + + @total_ordering @dataclass(frozen=True) class Version: @@ -62,20 +89,24 @@ def from_string(version): return Version(*version) @staticmethod + @poetry_command def from_poetry(): - poetry = which("poetry") - if not poetry: - raise ToolboxError("Couldn't find poetry executable") - - try: - result = subprocess.run( - [poetry, "version", "--no-ansi", "--short"], capture_output=True - ) - except subprocess.CalledProcessError as ex: - raise ToolboxError() from ex - version = result.stdout.decode().strip() + output = subprocess.run( + ["poetry", "version", "--no-ansi", "--short"], + capture_output=True, + text=True, + ) + return Version.from_string(output.stdout.strip()) - return Version.from_string(version) + @staticmethod + @poetry_command + def upgrade_version_from_poetry(t: ReleaseTypes): + output = subprocess.run( + ["poetry", "version", str(t), "--dry-run", "--no-ansi", "--short"], + capture_output=True, + text=True, + ) + return Version.from_string(output.stdout.strip()) def extract_release_notes(file: str | Path) -> str: diff --git a/test/unit/release_test.py b/test/unit/release_test.py index 6fd1e391aa..d1009fd423 100644 --- a/test/unit/release_test.py +++ b/test/unit/release_test.py @@ -1,14 +1,25 @@ import subprocess from datetime import datetime from inspect import cleandoc -from unittest.mock import patch +from subprocess import CalledProcessError +from unittest.mock import ( + MagicMock, + patch, +) import pytest +from exasol.toolbox.error import ToolboxError +from exasol.toolbox.nox._release import ( + ReleaseError, + _trigger_release, +) from exasol.toolbox.release import ( + ReleaseTypes, Version, extract_release_notes, new_changelog, + poetry_command, ) @@ -47,7 +58,7 @@ def set_poetry_version(version): return subprocess.CompletedProcess( args=["poetry", "version", "--no-ansi", "--short"], returncode=0, - stdout=version.encode("utf8"), + stdout=version, stderr="", ) @@ -143,3 +154,115 @@ def test_extract_release_notes(unreleased_md): ) actual = extract_release_notes(unreleased_md) assert expected == actual + + +@pytest.fixture(scope="class") +def mock_from_poetry(): + with patch( + "exasol.toolbox.nox._release.Version.from_poetry", return_value="0.3.0" + ) as mock_obj: + yield mock_obj + + +class TestTriggerReleaseWithMocking: + @staticmethod + def _get_mock_string(args) -> str: + if args == ("git", "remote", "show", "origin"): + return "test\nHEAD branch: main\ntest" + if args in [("git", "tag", "--list"), ("gh", "release", "list")]: + return "0.1.0\n0.2.0" + return "" + + def _get_subprocess_run_mock(self, args) -> str: + return MagicMock(returncode=0, stdout=self._get_mock_string(args)) + + def test_works_as_expected(self, mock_from_poetry): + def simulate_pass(args, **kwargs): + return self._get_subprocess_run_mock(args) + + with patch("subprocess.run", side_effect=simulate_pass): + result = _trigger_release() + assert result == mock_from_poetry.return_value + + @pytest.mark.parametrize( + "error_cmd", + [ + ("git", "remote", "show", "origin"), + ("git", "checkout", "main"), + ("git", "pull"), + ("git", "tag", "--list"), + ("gh", "release", "list"), + ("git", "tag", "0.3.0"), + ("git", "push", "origin", "0.3.0"), + ], + ) + def test_caught_called_process_error_raises_release_error( + self, mock_from_poetry, error_cmd + ): + def simulate_fail(args, **kwargs): + if args == error_cmd: + raise CalledProcessError(returncode=1, cmd=error_cmd) + return self._get_subprocess_run_mock(args) + + with patch("subprocess.run", side_effect=simulate_fail): + with pytest.raises(ReleaseError) as ex: + _trigger_release() + assert str(error_cmd) in str(ex) + + def test_default_branch_could_not_be_found(self, mock_from_poetry): + def simulate_fail(args, **kwargs): + if args == ("git", "remote", "show", "origin"): + return MagicMock(returncode=0, stdout="DUMMY TEXT") + return self._get_subprocess_run_mock(args) + + with patch("subprocess.run", side_effect=simulate_fail): + with pytest.raises(ReleaseError) as ex: + _trigger_release() + assert "default branch could not be found" in str(ex) + + def test_tag_already_exists(self, mock_from_poetry): + version = mock_from_poetry.return_value + + def simulate_fail(args, **kwargs): + if args == ("git", "tag", "--list"): + return MagicMock(returncode=0, stdout=f"0.1.0\n0.2.0\n{version}") + return self._get_subprocess_run_mock(args) + + with patch("subprocess.run", side_effect=simulate_fail): + with pytest.raises(ReleaseError) as ex: + _trigger_release() + assert f"tag {version} already exists" in str(ex) + + def test_release_already_exists(self, mock_from_poetry): + version = mock_from_poetry.return_value + + def simulate_fail(args, **kwargs): + if args == ("gh", "release", "list"): + return MagicMock(returncode=0, stdout=f"0.1.0\n0.2.0\n{version}") + return self._get_subprocess_run_mock(args) + + with patch("subprocess.run", side_effect=simulate_fail): + with pytest.raises(ReleaseError) as ex: + _trigger_release() + assert f"release {version} already exists" in str(ex) + + +@patch("exasol.toolbox.release.which", return_value=None) +def test_poetry_decorator_no_poetry_executable(mock): + @poetry_command + def test(): + pass + + with pytest.raises(ToolboxError): + test() + + +@patch("exasol.toolbox.release.which", return_value="test/path") +def test_poetry_decorator_subprocess(mock): + @poetry_command + def test(): + raise subprocess.CalledProcessError(returncode=1, cmd=["test"]) + pass + + with pytest.raises(ToolboxError): + test()