Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/changes/unreleased.md
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# Unreleased

## ✨ Features

* [#378](https://github.com/exasol/python-toolbox/pull/378/files): Add Nox task to trigger a release
2 changes: 1 addition & 1 deletion doc/developer_guide/developer_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
../design
plugins
modules/modules
../user_guide/how_to_release
how_to_release
1 change: 1 addition & 0 deletions doc/developer_guide/how_to_release.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.. include:: ../shared_content/how_release.rst
52 changes: 52 additions & 0 deletions doc/shared_content/how_release.rst
Original file line number Diff line number Diff line change
@@ -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 "<major>.<minor>.<patch>""

#. Delete the remote tag

.. code-block:: shell

git push --delete origin "<major>.<minor>.<patch>"

#. 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
80 changes: 1 addition & 79 deletions doc/user_guide/how_to_release.rst
Original file line number Diff line number Diff line change
@@ -1,79 +1 @@
How to Release?
===============

Creating a Release
++++++++++++++++++

1. Set a variable named **TAG** with the appropriate version numbers:

.. code-block:: shell

TAG="<major>.<minor>.<patch>"

#. 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
.. include:: ../shared_content/how_release.rst
70 changes: 53 additions & 17 deletions exasol/toolbox/nox/_release.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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}")
Expand Down Expand Up @@ -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()}")
57 changes: 44 additions & 13 deletions exasol/toolbox/release/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading