Repository: relekang/python-semantic-release
Pull request #413 (2022-02-10 07:53:27)

Feature/prerelease
## First take on making prereleases possible

I had some time implementing my feature request from here https://github.com/relekang/python-semantic-release/issues/386
 

### What is does

 
It is a first take on enabling to do prereleases with python-semantic-release. For a start I implemented a config option to define what suffix a prerelease should use (e.g. `alpha`, `beta`, `rc` - defaulting to `beta`) as well as a cli flag to trigger a prerelease.
Using the `--prerelease` flag will trigger the determined next version for the cli commands `print_version`, `version` and `publish` to be a prerelease.
The version number will be determined as normal:
Find the last (important) non-prerelease version and determine the bump (or use the forced level)
Bump the version and append `-{prerelease_tag}.0` to it.
Should there already be a prerelease version for the determined new version (`1.0.0-beta.0` already exists for the next version beeing `1.0.0`) then the prerelease number will be incremented, leading to `1.0.0-beta.1` in the previouse example.

I tried to make my code additions as unintrusive as possible and added/updated tests to check the behavior. All unit tests pass, so I'm optimistic that it will work in all cases.
Happy to get feedback and a review :)

One finding: It would be nice to have some full end-to-end tests, without mocks (maybe expect for the git interaction) to see if all parts work well together. Wdyt?


Patch:
diff --git a/.gitignore b/.gitignore
index 47f9aef..27b561b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ __pycache__/
 
 # Distribution / packaging
 .Python
+.venv
 env/
 build/
 develop-eggs/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67b2ffa..6e294c0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
 
 <!--next-version-placeholder-->
 
+## v7.25.2 (2022-02-24)
+### Fix
+* **gitea:** Use form-data from asset upload ([#421](https://github.com/relekang/python-semantic-release/issues/421)) ([`e011944`](https://github.com/relekang/python-semantic-release/commit/e011944987885f75b80fe16a363f4befb2519a91))
+
+## v7.25.1 (2022-02-23)
+### Fix
+* **gitea:** Build status and asset upload ([#420](https://github.com/relekang/python-semantic-release/issues/420)) ([`57db81f`](https://github.com/relekang/python-semantic-release/commit/57db81f4c6b96da8259e3bad9137eaccbcd10f6e))
+
+## v7.25.0 (2022-02-17)
+### Feature
+* **hvcs:** Add gitea support ([#412](https://github.com/relekang/python-semantic-release/issues/412)) ([`b7e7936`](https://github.com/relekang/python-semantic-release/commit/b7e7936331b7939db09abab235c8866d800ddc1a))
+
+### Documentation
+* Document tag_commit ([`b631ca0`](https://github.com/relekang/python-semantic-release/commit/b631ca0a79cb2d5499715d43688fc284cffb3044))
+
 ## v7.24.0 (2022-01-24)
 ### Feature
 * Include additional changes in release commits ([`3e34f95`](https://github.com/relekang/python-semantic-release/commit/3e34f957ff5a3ec6e6f984cc4a79a38ce4391ea9))
diff --git a/docs/commands.rst b/docs/commands.rst
index fa1d7e6..267a9fe 100644
--- a/docs/commands.rst
+++ b/docs/commands.rst
@@ -12,7 +12,8 @@ Print the changelog to stdout.
 
 If the option ``--post`` is used and there is an authentication token configured
 for your vcs provider (:ref:`env-gh_token` for GitHub, :ref:`env-gl_token` for
-GitLab), the changelog will be posted there too.
+GitLab, :ref:`env-gitea_token` for
+Gitea), the changelog will be posted there too.
 
 
 .. _cmd-version:
@@ -74,6 +75,12 @@ Force a minor release, ignoring the version bump determined from commit messages
 
 Force a major release, ignoring the version bump determined from commit messages.
 
+``--prerelease``
+...........
+
+Makes the next release a prerelease, version bumps are still determined or can be forced,
+but the `prerelease_tag` (see :ref:`config-prerelease_tag`) will be appended to version number.
+
 ``--noop``
 ..........
 
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 03019b3..abbd875 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -33,12 +33,12 @@ The file and variable name of where the version number is stored, for example::
 
     semantic_release/__init__.py:__version__
 
-You can specify multiple version variables (i.e. in different files) by 
+You can specify multiple version variables (i.e. in different files) by
 providing comma-separated list of such strings::
 
     semantic_release/__init__.py:__version__,docs/conf.py:version
 
-In ``pyproject.toml`` specifically, you can also use the TOML list syntax to 
+In ``pyproject.toml`` specifically, you can also use the TOML list syntax to
 specify multiple versions:
 
 .. code-block:: toml
@@ -67,14 +67,14 @@ identified using an arbitrary regular expression::
 
     README.rst:VERSION (\d+\.\d+\.\d+)
 
-The regular expression must contain a parenthesized group that matches the 
-version number itself.  Anything outside that group is just context.  For 
-example, the above specifies that there is a version number in ``README.rst`` 
+The regular expression must contain a parenthesized group that matches the
+version number itself.  Anything outside that group is just context.  For
+example, the above specifies that there is a version number in ``README.rst``
 preceded by the string "VERSION".
 
-If the pattern contains the string ``{version}``, it will be replaced with the 
-regular expression used internally by ``python-semantic-release`` to match 
-semantic version numbers.  So the above example would probably be better 
+If the pattern contains the string ``{version}``, it will be replaced with the
+regular expression used internally by ``python-semantic-release`` to match
+semantic version numbers.  So the above example would probably be better
 written as::
 
     README.rst:VERSION {version}
@@ -95,6 +95,17 @@ The way we get and set the new version. Can be `commit` or `tag`.
 
 Default: `commit`
 
+.. _config-prerelease_tag:
+
+``prerelease_tag``
+------------------
+Defined the prerelease marker appended to the version when doing a prerelease.
+
+- The format of a prerelease version will be `{tag_format}-{prerelease_tag}.<prerelease_number>`,
+  e.g. `1.0.0-beta.0` or `1.1.0-beta.1`
+
+Default: `beta`
+
 .. _config-tag_commit:
 
 ``tag_commit``
diff --git a/docs/envvars.rst b/docs/envvars.rst
index 2644ff5..58505a8 100644
--- a/docs/envvars.rst
+++ b/docs/envvars.rst
@@ -82,6 +82,13 @@ and click on *Personal access token*.
 A personal access token from GitLab. This is used for authenticating
 when pushing tags, publishing releases etc.
 
+.. _env-gitea_token:
+
+``GITEA_TOKEN``
+------------
+A personal access token from Gitea. This is used for authenticating
+when pushing tags, publishing releases etc.
+
 .. _env-repository:
 
 Artifact Repository Authentication
diff --git a/docs/index.rst b/docs/index.rst
index d67de41..3ff228f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -78,6 +78,7 @@ release notes and new versions to GitHub / GitLab:
 - :ref:`config-hvcs` - change this if you are using GitLab.
 - :ref:`env-gh_token` - GitHub personal access token.
 - :ref:`env-gl_token` - GitLab personal access token.
+- :ref:`env-gitea_token` - Gitea personal access token.
 
 Distributing release on PyPI or custom repository
 -----------------
diff --git a/semantic_release/__init__.py b/semantic_release/__init__.py
index 544147a..85a6cbd 100644
--- a/semantic_release/__init__.py
+++ b/semantic_release/__init__.py
@@ -1,6 +1,6 @@
 """Semantic Release
 """
-__version__ = "7.24.0"
+__version__ = "7.25.2"
 
 
 from .errors import UnknownCommitMessageStyleError  # noqa; noqa
diff --git a/semantic_release/cli.py b/semantic_release/cli.py
index 0950e9d..9052b9b 100644
--- a/semantic_release/cli.py
+++ b/semantic_release/cli.py
@@ -18,7 +18,7 @@ from .history import (
     get_current_version,
     get_new_version,
     get_previous_version,
-    set_new_version,
+    set_new_version
 )
 from .history.logs import generate_changelog
 from .hvcs import (
@@ -66,8 +66,12 @@ COMMON_OPTIONS = [
     click.option(
         "--patch", "force_level", flag_value="patch", help="Force patch version."
     ),
+    click.option(
+        "--prerelease", is_flag=True, help="Creates a prerelease version."
+    ),
     click.option("--post", is_flag=True, help="Post changelog."),
-    click.option("--retry", is_flag=True, help="Retry the same release, do not bump."),
+    click.option("--retry", is_flag=True,
+                 help="Retry the same release, do not bump."),
     click.option(
         "--noop",
         is_flag=True,
@@ -92,7 +96,7 @@ def common_options(func):
     return func
 
 
-def print_version(*, current=False, force_level=None, **kwargs):
+def print_version(*, current=False, force_level=None, prerelease=False, **kwargs):
     """
     Print the current or new version to standard output.
     """
@@ -107,7 +111,7 @@ def print_version(*, current=False, force_level=None, **kwargs):
 
     # Find what the new version number should be
     level_bump = evaluate_version_bump(current_version, force_level)
-    new_version = get_new_version(current_version, level_bump)
+    new_version = get_new_version(current_version, level_bump, prerelease)
     if should_bump_version(current_version=current_version, new_version=new_version):
         print(new_version, end="")
         return True
@@ -116,7 +120,7 @@ def print_version(*, current=False, force_level=None, **kwargs):
     return False
 
 
-def version(*, retry=False, noop=False, force_level=None, **kwargs):
+def version(*, retry=False, noop=False, force_level=None, prerelease=False, **kwargs):
     """
     Detect the new version according to git log and semver.
 
@@ -136,7 +140,7 @@ def version(*, retry=False, noop=False, force_level=None, **kwargs):
         return False
     # Find what the new version number should be
     level_bump = evaluate_version_bump(current_version, force_level)
-    new_version = get_new_version(current_version, level_bump)
+    new_version = get_new_version(current_version, level_bump, prerelease)
 
     if not should_bump_version(
         current_version=current_version, new_version=new_version, retry=retry, noop=noop
@@ -228,13 +232,14 @@ def changelog(*, unreleased=False, noop=False, post=False, **kwargs):
                 owner,
                 name,
                 current_version,
-                markdown_changelog(owner, name, current_version, log, header=False),
+                markdown_changelog(
+                    owner, name, current_version, log, header=False),
             )
         else:
             logger.error("Missing token: cannot post changelog to HVCS")
 
 
-def publish(retry: bool = False, noop: bool = False, **kwargs):
+def publish(retry: bool = False, noop: bool = False, prerelease=False, **kwargs):
     """Run the version task, then push to git and upload to an artifact repository / GitHub Releases."""
     current_version = get_current_version()
 
@@ -248,8 +253,9 @@ def publish(retry: bool = False, noop: bool = False, **kwargs):
         current_version = get_previous_version(current_version)
     else:
         # Calculate the new version
-        level_bump = evaluate_version_bump(current_version, kwargs.get("force_level"))
-        new_version = get_new_version(current_version, level_bump)
+        level_bump = evaluate_version_bump(
+            current_version, kwargs.get("force_level"))
+        new_version = get_new_version(current_version, level_bump, prerelease)
 
     owner, name = get_repository_owner_and_name()
 
diff --git a/semantic_release/defaults.cfg b/semantic_release/defaults.cfg
index 22c2713..05b1533 100644
--- a/semantic_release/defaults.cfg
+++ b/semantic_release/defaults.cfg
@@ -20,6 +20,8 @@ dist_path=dist
 fix_tag=:nut_and_bolt:
 github_token_var=GH_TOKEN
 gitlab_token_var=GL_TOKEN
+gitea_token_var=GITEA_TOKEN
+
 hvcs=github
 major_emoji=:boom:
 major_on_zero=true
@@ -40,3 +42,4 @@ upload_to_repository=true
 upload_to_pypi=true
 upload_to_release=true
 version_source=commit
+prerelease_tag=beta
diff --git a/semantic_release/history/__init__.py b/semantic_release/history/__init__.py
index b7160a0..47bc363 100644
--- a/semantic_release/history/__init__.py
+++ b/semantic_release/history/__init__.py
@@ -176,14 +176,18 @@ class PatternVersionDeclaration(VersionDeclaration):
         self.path.write_text(new_content)
 
 
+def get_prerelease_pattern() -> str:
+    return "-" + config.get("prerelease_tag") + "."
+
+
 @LoggedFunction(logger)
-def get_current_version_by_tag() -> str:
+def get_current_version_by_tag(omit_pattern=None) -> str:
     """
     Find the current version of the package in the current working directory using git tags.
 
     :return: A string with the version number or 0.0.0 on failure.
     """
-    version = get_last_version()
+    version = get_last_version(omit_pattern=omit_pattern)
     if version:
         return version
 
@@ -192,7 +196,7 @@ def get_current_version_by_tag() -> str:
 
 
 @LoggedFunction(logger)
-def get_current_version_by_config_file() -> str:
+def get_current_version_by_config_file(omit_pattern=None) -> str:
     """
     Get current version from the version variable defined in the configuration.
 
@@ -216,35 +220,55 @@ def get_current_version_by_config_file() -> str:
     return version
 
 
-def get_current_version() -> str:
+def get_current_version(prerelease_version: bool = False) -> str:
     """
     Get current version from tag or version variable, depending on configuration.
+    This will not return prerelease versions.
 
     :return: A string with the current version number
     """
+    omit_pattern = None if prerelease_version else get_prerelease_pattern()
     if config.get("version_source") == "tag":
-        return get_current_version_by_tag()
-    return get_current_version_by_config_file()
+        return get_current_version_by_tag(omit_pattern)
+    current_version = get_current_version_by_config_file(omit_pattern)
+    if omit_pattern and omit_pattern in current_version:
+        return get_previous_version(current_version)
+    return current_version
 
 
 @LoggedFunction(logger)
-def get_new_version(current_version: str, level_bump: str) -> str:
+def get_new_version(current_version: str, level_bump: str, prerelease: bool = False) -> str:
     """
     Calculate the next version based on the given bump level with semver.
 
     :param current_version: The version the package has now.
     :param level_bump: The level of the version number that should be bumped.
         Should be `'major'`, `'minor'` or `'patch'`.
+    :param prerelease: Should the version bump be marked as a prerelease
     :return: A string with the next version number.
     """
     if not level_bump:
-        logger.debug("No bump requested, returning input version")
-        return current_version
-    return str(semver.VersionInfo.parse(current_version).next_version(part=level_bump))
+        logger.debug("No bump requested, using input version")
+        new_version =  current_version
+    else:
+        new_version = str(semver.VersionInfo.parse(current_version).next_version(part=level_bump))
+
+    if prerelease:
+        logger.debug("Prerelease requested")
+        potentialy_prereleased_current_version = get_current_version(prerelease_version=True)
+        if get_prerelease_pattern() in potentialy_prereleased_current_version:
+            logger.debug("Previouse prerelease detected, increment prerelease version")
+            prerelease_num = int(potentialy_prereleased_current_version.split(".")[-1]) + 1
+        else:
+            logger.debug("No previouse prerelease detected, starting from 0")
+            prerelease_num = 0
+        new_version = new_version + get_prerelease_pattern() + str(prerelease_num)
+
+    return new_version
 
 
 @LoggedFunction(logger)
-def get_previous_version(version: str) -> Optional[str]:
+def get_previous_version(version: str, omit_pattern: str = None) -> Optional[str]:
     """
     Return the version prior to the given version.
 
@@ -260,12 +284,14 @@ def get_previous_version(version: str) -> Optional[str]:
             continue
 
         if found_version:
+            if omit_pattern and omit_pattern in commit_message:
+                continue
             matches = re.match(r"v?(\d+.\d+.\d+)", commit_message)
             if matches:
                 logger.debug(f"Version matches regex {commit_message}")
                 return matches.group(1).strip()
 
-    return get_last_version([version, get_formatted_tag(version)])
+    return get_last_version([version, get_formatted_tag(version)], omit_pattern=omit_pattern)
 
 
 @LoggedFunction(logger)
diff --git a/semantic_release/hvcs.py b/semantic_release/hvcs.py
index 150f0a0..a2da31e 100644
--- a/semantic_release/hvcs.py
+++ b/semantic_release/hvcs.py
@@ -352,6 +352,282 @@ class Github(Base):
         return not one_or_more_failed
 
 
+class Gitea(Base):
+    """Gitea helper class"""
+
+    DEFAULT_DOMAIN = "gitea.com"
+    DEFAULT_API_PATH = "/api/v1"
+
+    _fix_mime_types()
+
+    @staticmethod
+    def domain() -> str:
+        """Gitea domain property
+
+        :return: The Gitea domain
+        """
+        # ref: https://docs.gitea.com/en/actions/reference/environment-variables#default-environment-variables
+        hvcs_domain = config.get(
+            "hvcs_domain", os.getenv("GITEA_SERVER_URL", "").replace("https://", "")
+        )
+        domain = hvcs_domain if hvcs_domain else Gitea.DEFAULT_DOMAIN
+        return domain
+
+    @staticmethod
+    def api_url() -> str:
+        """Gitea api_url property
+
+        :return: The Gitea API URL
+        """
+
+        hvcs_domain = config.get(
+            "hvcs_domain", os.getenv("GITEA_SERVER_URL", "").replace("https://", "")
+        )
+        hostname = config.get(
+            "hvcs_api_domain", os.getenv("GITEA_API_URL", "").replace("https://", "")
+        )
+
+        if hvcs_domain and not hostname:
+            hostname = hvcs_domain + Gitea.DEFAULT_API_PATH
+        elif not hostname:
+            hostname = Gitea.DEFAULT_DOMAIN + Gitea.DEFAULT_API_PATH
+
+        return f"https://{hostname}"
+
+    @staticmethod
+    def token() -> Optional[str]:
+        """Gitea token property
+
+        :return: The Gitea token environment variable (GITEA_TOKEN) value
+        """
+        return os.environ.get(config.get("gitea_token_var"))
+
+    @staticmethod
+    def auth() -> Optional[TokenAuth]:
+        """Gitea token property
+
+        :return: The Gitea token environment variable (GITEA_TOKEN) value
+        """
+        token = Gitea.token()
+        if not token:
+            return None
+        return TokenAuth(token)
+
+    @staticmethod
+    def session(
+        raise_for_status=True, retry: Union[Retry, bool, int] = True
+    ) -> Session:
+        session = build_requests_session(raise_for_status=raise_for_status, retry=retry)
+        session.auth = Gitea.auth()
+        return session
+
+    @staticmethod
+    @LoggedFunction(logger)
+    def check_build_status(owner: str, repo: str, ref: str) -> bool:
+        """Check build status
+
+        https://gitea.com/api/swagger#/repository/repoCreateStatus
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param ref: The sha1 hash of the commit ref
+
+        :return: Was the build status success?
+        """
+        url = "{domain}/repos/{owner}/{repo}/statuses/{ref}"
+        try:
+            response = Gitea.session().get(
+                url.format(domain=Gitea.api_url(), owner=owner, repo=repo, ref=ref)
+            )
+            data = response.json()
+            if type(data) == list:
+                return data[0].get("status") == "success"
+            else:
+                return data.get("status") == "success"
+        except HTTPError as e:
+            logger.warning(f"Build status check on Gitea has failed: {e}")
+            return False
+
+    @classmethod
+    @LoggedFunction(logger)
+    def create_release(cls, owner: str, repo: str, tag: str, changelog: str) -> bool:
+        """Create a new release
+
+        https://gitea.com/api/swagger#/repository/repoCreateRelease
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param tag: Tag to create release for
+        :param changelog: The release notes for this version
+
+        :return: Whether the request succeeded
+        """
+        try:
+            Gitea.session().post(
+                f"{Gitea.api_url()}/repos/{owner}/{repo}/releases",
+                json={
+                    "tag_name": tag,
+                    "name": tag,
+                    "body": changelog,
+                    "draft": False,
+                    "prerelease": False,
+                },
+            )
+            return True
+        except HTTPError as e:
+            logger.warning(f"Release creation on Gitea has failed: {e}")
+            return False
+
+    @classmethod
+    @LoggedFunction(logger)
+    def get_release(cls, owner: str, repo: str, tag: str) -> Optional[int]:
+        """Get a release by its tag name
+
+        https://gitea.com/api/swagger#/repository/repoGetReleaseByTag
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param tag: Tag to get release for
+
+        :return: ID of found release
+        """
+        try:
+            response = Gitea.session().get(
+                f"{Gitea.api_url()}/repos/{owner}/{repo}/releases/tags/{tag}"
+            )
+            return response.json().get("id")
+        except HTTPError as e:
+            if e.response.status_code != 404:
+                logger.debug(f"Get release by tag on Gitea has failed: {e}")
+            return None
+
+    @classmethod
+    @LoggedFunction(logger)
+    def edit_release(cls, owner: str, repo: str, id: int, changelog: str) -> bool:
+        """Edit a release with updated change notes
+
+        https://gitea.com/api/swagger#/repository/repoEditRelease
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param id: ID of release to update
+        :param changelog: The release notes for this version
+
+        :return: Whether the request succeeded
+        """
+        try:
+            Gitea.session().post(
+                f"{Gitea.api_url()}/repos/{owner}/{repo}/releases/{id}",
+                json={"body": changelog},
+            )
+            return True
+        except HTTPError as e:
+            logger.warning(f"Edit release on Gitea has failed: {e}")
+            return False
+
+    @classmethod
+    @LoggedFunction(logger)
+    def post_release_changelog(
+        cls, owner: str, repo: str, version: str, changelog: str
+    ) -> bool:
+        """Post release changelog
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param version: The version number
+        :param changelog: The release notes for this version
+
+        :return: The status of the request
+        """
+        tag = get_formatted_tag(version)
+        logger.debug(f"Attempting to create release for {tag}")
+        success = Gitea.create_release(owner, repo, tag, changelog)
+
+        if not success:
+            logger.debug("Unsuccessful, looking for an existing release to update")
+            release_id = Gitea.get_release(owner, repo, tag)
+            if release_id:
+                logger.debug(f"Updating release {release_id}")
+                success = Gitea.edit_release(owner, repo, release_id, changelog)
+            else:
+                logger.debug(f"Existing release not found")
+
+        return success
+
+    @classmethod
+    @LoggedFunction(logger)
+    def upload_asset(
+        cls, owner: str, repo: str, release_id: int, file: str, label: str = None
+    ) -> bool:
+        """Upload an asset to an existing release
+
+        https://gitea.com/api/swagger#/repository/repoCreateReleaseAttachment
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param release_id: ID of the release to upload to
+        :param file: Path of the file to upload
+        :param label: Custom label for this file
+
+        :return: The status of the request
+        """
+        url = f"{Gitea.api_url()}/repos/{owner}/{repo}/releases/{release_id}/assets"
+        try:
+            name = os.path.basename(file)
+            response = Gitea.session().post(
+                url,
+                params={"name": name},
+                data={},
+                files=[
+                    (
+                        "attachment",
+                        (
+                            name,
+                            open(file, "rb"),
+                            "application/octet-stream",
+                        ),
+                    )
+                ],
+            )
+
+            logger.debug(
+                f"Asset upload on Gitea completed, url: {response.url}, status code: {response.status_code}"
+            )
+
+            return True
+        except HTTPError as e:
+            logger.warning(f"Asset upload {file} on Gitea has failed: {e}")
+            return False
+
+    @classmethod
+    def upload_dists(cls, owner: str, repo: str, version: str, path: str) -> bool:
+        """Upload distributions to a release
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param version: Version to upload for
+        :param path: Path to the dist directory
+
+        :return: The status of the request
+        """
+
+        # Find the release corresponding to this version
+        release_id = Gitea.get_release(owner, repo, get_formatted_tag(version))
+        if not release_id:
+            logger.debug("No release found to upload assets to")
+            return False
+
+        # Upload assets
+        one_or_more_failed = False
+        for file in os.listdir(path):
+            file_path = os.path.join(path, file)
+
+            if not Gitea.upload_asset(owner, repo, release_id, file_path):
+                one_or_more_failed = True
+
+        return not one_or_more_failed
+
+
 class Gitlab(Base):
     """Gitlab helper class"""
 
@@ -460,7 +736,11 @@ def get_hvcs() -> Base:
     try:
         return globals()[hvcs.capitalize()]
     except KeyError:
-        raise ImproperConfigurationError('"{0}" is not a valid option for hvcs.')
+        raise ImproperConfigurationError(
+            '"{0}" is not a valid option for hvcs. Please use "github"|"gitlab"|"gitea"'.format(
+                hvcs
+            )
+        )
 
 
 def check_build_status(owner: str, repository: str, ref: str) -> bool:
diff --git a/semantic_release/vcs_helpers.py b/semantic_release/vcs_helpers.py
index 78c6e5a..a58d984 100644
--- a/semantic_release/vcs_helpers.py
+++ b/semantic_release/vcs_helpers.py
@@ -63,7 +63,7 @@ def get_commit_log(from_rev=None):
 
 @check_repo
 @LoggedFunction(logger)
-def get_last_version(skip_tags=None) -> Optional[str]:
+def get_last_version(skip_tags=None, omit_pattern=None) -> Optional[str]:
     """
     Find the latest version using repo tags.
 
@@ -78,8 +78,12 @@ def get_last_version(skip_tags=None) -> Optional[str]:
 
     for i in sorted(repo.tags, reverse=True, key=version_finder):
         match = re.search(r"\d+\.\d+\.\d+", i.name)
-        if match and i.name not in skip_tags:
-            return match.group(0)  # Return only numeric vesion like 1.2.3
+        if match:
+            # check if the omit pattern is present in the tag (e.g. -beta for pre-release tags)
+            if omit_pattern and omit_pattern in i.name:
+                continue
+            if i.name not in skip_tags:
+                return match.group(0)  # Return only numeric vesion like 1.2.3
 
     return None
 


Test patch:
diff --git a/tests/history/test_version.py b/tests/history/test_version.py
index a8c111c..a37df1b 100644
--- a/tests/history/test_version.py
+++ b/tests/history/test_version.py
@@ -65,6 +65,21 @@ class TestGetPreviousVersion:
     def test_should_return_correct_version_with_v(self):
         assert get_previous_version("0.10.0") == "0.9.0"
 
+    @mock.patch(
+        "semantic_release.history.get_commit_log",
+        lambda: [("211", "0.10.0-beta"), ("13", "0.9.0")],
+    )
+    def test_should_return_correct_version_from_prerelease(self):
+        assert get_previous_version("0.10.0-beta") == "0.9.0"
+
+    @mock.patch(
+        "semantic_release.history.get_commit_log",
+        lambda: [("211", "0.10.0"), ("13", "0.10.0-beta"), ("13", "0.9.0")],
+    )
+    def test_should_return_correct_version_skip_prerelease(self):
+        assert get_previous_version(
+            "0.10.0-beta", omit_pattern="-beta") == "0.9.0"
+
 
 class TestGetNewVersion:
     def test_major_bump(self):
@@ -88,6 +103,19 @@ class TestGetNewVersion:
     def test_none_bump(self):
         assert get_new_version("1.0.0", None) == "1.0.0"
 
+    def test_prerelease(self):
+        assert get_new_version("1.0.0", None, True) == "1.0.0-beta.0"
+        assert get_new_version("1.0.0", "major", True) == "2.0.0-beta.0"
+        assert get_new_version("1.0.0", "minor", True) == "1.1.0-beta.0"
+        assert get_new_version("1.0.0", "patch", True) == "1.0.1-beta.0"
+
+    def test_prerelease_bump(self, mocker):
+        mocker.patch(
+            "semantic_release.history.get_current_version",
+            return_value="1.0.0-beta.0"
+        )
+        assert get_new_version("1.0.0", None, True) == "1.0.0-beta.1"
+
 
 @mock.patch(
     "semantic_release.history.config.get",
@@ -253,11 +281,11 @@ class TestVersionPattern:
                     name = "my-package"
                     version = "0.1.0"
                     description = "A super package"
-                    
+
                     [build-system]
                     requires = ["poetry-core>=1.0.0"]
                     build-backend = "poetry.core.masonry.api"
-                    
+
                     [tool.semantic_release]
                     version_toml = "pyproject.toml:tool.poetry.version"
                     """
@@ -268,11 +296,11 @@ class TestVersionPattern:
                     name = "my-package"
                     version = "-"
                     description = "A super package"
-                    
+
                     [build-system]
                     requires = ["poetry-core>=1.0.0"]
                     build-backend = "poetry.core.masonry.api"
-                    
+
                     [tool.semantic_release]
                     version_toml = "pyproject.toml:tool.poetry.version"
                     """
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a503231..821fd07 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -22,6 +22,7 @@ def test_main_should_call_correct_function(mocker, runner):
         noop=False,
         post=False,
         force_level=None,
+        prerelease=False,
         retry=False,
         define=(),
     )
@@ -36,7 +37,8 @@ def test_version_by_commit_should_call_correct_functions(mocker):
         ),
     )
     mock_tag_new_version = mocker.patch("semantic_release.cli.tag_new_version")
-    mock_commit_new_version = mocker.patch("semantic_release.cli.commit_new_version")
+    mock_commit_new_version = mocker.patch(
+        "semantic_release.cli.commit_new_version")
     mock_set_new_version = mocker.patch("semantic_release.cli.set_new_version")
     mock_new_version = mocker.patch(
         "semantic_release.cli.get_new_version", return_value="2.0.0"
@@ -52,7 +54,7 @@ def test_version_by_commit_should_call_correct_functions(mocker):
 
     mock_current_version.assert_called_once_with()
     mock_evaluate_bump.assert_called_once_with("1.2.3", None)
-    mock_new_version.assert_called_once_with("1.2.3", "major")
+    mock_new_version.assert_called_once_with("1.2.3", "major", False)
     mock_set_new_version.assert_called_once_with("2.0.0")
     mock_commit_new_version.assert_called_once_with("2.0.0")
     mock_tag_new_version.assert_called_once_with("2.0.0")
@@ -72,7 +74,8 @@ def test_version_by_tag_with_commit_version_number_should_call_correct_functions
 
     mock_set_new_version = mocker.patch("semantic_release.cli.set_new_version")
     mock_tag_new_version = mocker.patch("semantic_release.cli.tag_new_version")
-    mock_commit_new_version = mocker.patch("semantic_release.cli.commit_new_version")
+    mock_commit_new_version = mocker.patch(
+        "semantic_release.cli.commit_new_version")
     mock_new_version = mocker.patch(
         "semantic_release.cli.get_new_version", return_value="2.0.0"
     )
@@ -87,7 +90,7 @@ def test_version_by_tag_with_commit_version_number_should_call_correct_functions
 
     mock_current_version.assert_called_once_with()
     mock_evaluate_bump.assert_called_once_with("1.2.3", None)
-    mock_new_version.assert_called_once_with("1.2.3", "major")
+    mock_new_version.assert_called_once_with("1.2.3", "major", False)
     mock_set_new_version.assert_called_once_with("2.0.0")
     mock_commit_new_version.assert_called_once_with("2.0.0")
     mock_tag_new_version.assert_called_once_with("2.0.0")
@@ -114,7 +117,7 @@ def test_version_by_tag_should_call_correct_functions(mocker):
 
     mock_current_version.assert_called_once_with()
     mock_evaluate_bump.assert_called_once_with("1.2.3", None)
-    mock_new_version.assert_called_once_with("1.2.3", "major")
+    mock_new_version.assert_called_once_with("1.2.3", "major", False)
     mock_set_new_version.assert_called_once_with("2.0.0")
     mock_tag_new_version.assert_called_once_with("2.0.0")
 
@@ -126,7 +129,8 @@ def test_version_by_commit_without_tag_should_call_correct_functions(mocker):
     )
     mock_set_new_version = mocker.patch("semantic_release.cli.set_new_version")
     mock_tag_new_version = mocker.patch("semantic_release.cli.tag_new_version")
-    mock_commit_new_version = mocker.patch("semantic_release.cli.commit_new_version")
+    mock_commit_new_version = mocker.patch(
+        "semantic_release.cli.commit_new_version")
     mock_new_version = mocker.patch(
         "semantic_release.cli.get_new_version", return_value="2.0.0"
     )
@@ -141,7 +145,7 @@ def test_version_by_commit_without_tag_should_call_correct_functions(mocker):
 
     mock_current_version.assert_called_once_with()
     mock_evaluate_bump.assert_called_once_with("1.2.3", None)
-    mock_new_version.assert_called_once_with("1.2.3", "major")
+    mock_new_version.assert_called_once_with("1.2.3", "major", False)
     mock_set_new_version.assert_called_once_with("2.0.0")
     mock_commit_new_version.assert_called_once_with("2.0.0")
     assert not mock_tag_new_version.called
@@ -154,6 +158,7 @@ def test_force_major(mocker, runner):
         noop=False,
         post=False,
         force_level="major",
+        prerelease=False,
         retry=False,
         define=(),
     )
@@ -168,6 +173,7 @@ def test_force_minor(mocker, runner):
         noop=False,
         post=False,
         force_level="minor",
+        prerelease=False,
         retry=False,
         define=(),
     )
@@ -182,6 +188,7 @@ def test_force_patch(mocker, runner):
         noop=False,
         post=False,
         force_level="patch",
+        prerelease=False,
         retry=False,
         define=(),
     )
@@ -196,6 +203,7 @@ def test_retry(mocker, runner):
         noop=False,
         post=False,
         force_level=None,
+        prerelease=False,
         retry=True,
         define=(),
     )
@@ -206,7 +214,8 @@ def test_noop_mode(mocker):
     mock_tag_new_version = mocker.patch("semantic_release.cli.tag_new_version")
     mock_set_new = mocker.patch("semantic_release.cli.commit_new_version")
     mock_commit_new = mocker.patch("semantic_release.cli.set_new_version")
-    mocker.patch("semantic_release.cli.evaluate_version_bump", lambda *x: "major")
+    mocker.patch("semantic_release.cli.evaluate_version_bump",
+                 lambda *x: "major")
 
     version(noop=True)
 
@@ -221,6 +230,7 @@ def test_cli_print_version(mocker, runner):
     mock_print_version.assert_called_once_with(
         current=False,
         force_level=None,
+        prerelease=False,
         noop=False,
         post=False,
         retry=False,
@@ -235,6 +245,22 @@ def test_cli_print_version_force_major(mocker, runner):
     mock_print_version.assert_called_once_with(
         current=False,
         force_level="major",
+        prerelease=False,
+        noop=False,
+        post=False,
+        retry=False,
+        define=(),
+    )
+    assert result.exit_code == 0
+
+
+def test_cli_print_version_prerelease(mocker, runner):
+    mock_print_version = mocker.patch("semantic_release.cli.print_version")
+    result = runner.invoke(main, ["print-version", "--prerelease"])
+    mock_print_version.assert_called_once_with(
+        current=False,
+        force_level=None,
+        prerelease=True,
         noop=False,
         post=False,
         retry=False,
@@ -249,6 +275,7 @@ def test_cli_print_version_current(mocker, runner):
     mock_print_version.assert_called_once_with(
         current=True,
         force_level=None,
+        prerelease=False,
         noop=False,
         post=False,
         retry=False,
@@ -263,6 +290,7 @@ def test_cli_print_version_next(mocker, runner):
     mock_print_version.assert_called_once_with(
         current=False,
         force_level=None,
+        prerelease=False,
         noop=False,
         post=False,
         retry=False,
@@ -289,7 +317,7 @@ def test_print_version_no_change(mocker, runner, capsys):
 
     mock_current_version.assert_called_once_with()
     mock_evaluate_bump.assert_called_once_with("1.2.3", None)
-    mock_new_version.assert_called_once_with("1.2.3", None)
+    mock_new_version.assert_called_once_with("1.2.3", None, False)
 
 
 def test_print_version_change(mocker, runner, capsys):
@@ -309,6 +337,43 @@ def test_print_version_change(mocker, runner, capsys):
     mock_evaluate_bump.assert_called_once_with("1.2.3", None)
 
 
+def test_print_version_change_prerelease(mocker, runner, capsys):
+    mock_current_version = mocker.patch(
+        "semantic_release.cli.get_current_version", return_value="1.2.3"
+    )
+    mock_evaluate_bump = mocker.patch(
+        "semantic_release.cli.evaluate_version_bump", return_value="minor"
+    )
+
+    print_version(prerelease=True)
+    outerr = capsys.readouterr()
+    assert outerr.out == "1.3.0-beta.0"
+    assert outerr.err == ""
+
+    mock_current_version.assert_called_once()
+    mock_evaluate_bump.assert_called_once_with("1.2.3", None)
+
+
+def test_print_version_change_prerelease_bump(mocker, runner, capsys):
+    mock_current_version = mocker.patch(
+        "semantic_release.cli.get_current_version", return_value="1.2.3"
+    )
+    mock_current_version = mocker.patch(
+        "semantic_release.history.get_current_version", return_value="1.3.0-beta.0"
+    )
+    mock_evaluate_bump = mocker.patch(
+        "semantic_release.cli.evaluate_version_bump", return_value="minor"
+    )
+
+    print_version(prerelease=True)
+    outerr = capsys.readouterr()
+    assert outerr.out == "1.3.0-beta.1"
+    assert outerr.err == ""
+
+    mock_current_version.assert_called_once()
+    mock_evaluate_bump.assert_called_once_with("1.2.3", None)
+
+
 def test_print_version_force_major(mocker, runner, capsys):
     mock_current_version = mocker.patch(
         "semantic_release.cli.get_current_version", return_value="1.2.3"
@@ -326,9 +391,27 @@ def test_print_version_force_major(mocker, runner, capsys):
     mock_evaluate_bump.assert_called_once_with("1.2.3", "major")
 
 
+def test_print_version_force_major_prerelease(mocker, runner, capsys):
+    mock_current_version = mocker.patch(
+        "semantic_release.cli.get_current_version", return_value="1.2.3"
+    )
+    mock_evaluate_bump = mocker.patch(
+        "semantic_release.cli.evaluate_version_bump", return_value="major"
+    )
+
+    print_version(force_level="major", prerelease=True)
+    outerr = capsys.readouterr()
+    assert outerr.out == "2.0.0-beta.0"
+    assert outerr.err == ""
+
+    mock_current_version.assert_called_once()
+    mock_evaluate_bump.assert_called_once_with("1.2.3", "major")
+
+
 def test_version_no_change(mocker, runner):
     mock_tag_new_version = mocker.patch("semantic_release.cli.tag_new_version")
-    mock_commit_new_version = mocker.patch("semantic_release.cli.commit_new_version")
+    mock_commit_new_version = mocker.patch(
+        "semantic_release.cli.commit_new_version")
     mock_set_new_version = mocker.patch("semantic_release.cli.set_new_version")
     mock_new_version = mocker.patch(
         "semantic_release.cli.get_new_version", return_value="1.2.3"
@@ -344,7 +427,7 @@ def test_version_no_change(mocker, runner):
 
     mock_current_version.assert_called_once_with()
     mock_evaluate_bump.assert_called_once_with("1.2.3", None)
-    mock_new_version.assert_called_once_with("1.2.3", None)
+    mock_new_version.assert_called_once_with("1.2.3", None, False)
     assert not mock_set_new_version.called
     assert not mock_commit_new_version.called
     assert not mock_tag_new_version.called
@@ -358,7 +441,8 @@ def test_version_check_build_status_fails(mocker):
     mock_commit_new = mocker.patch("semantic_release.cli.commit_new_version")
     mock_set_new = mocker.patch("semantic_release.cli.set_new_version")
     mocker.patch("semantic_release.cli.config.get", lambda *x: True)
-    mocker.patch("semantic_release.cli.evaluate_version_bump", lambda *x: "major")
+    mocker.patch("semantic_release.cli.evaluate_version_bump",
+                 lambda *x: "major")
 
     version()
 
@@ -374,7 +458,8 @@ def test_version_by_commit_check_build_status_succeeds(mocker):
         "semantic_release.cli.check_build_status", return_value=True
     )
     mock_tag_new_version = mocker.patch("semantic_release.cli.tag_new_version")
-    mocker.patch("semantic_release.cli.evaluate_version_bump", lambda *x: "major")
+    mocker.patch("semantic_release.cli.evaluate_version_bump",
+                 lambda *x: "major")
     mock_commit_new = mocker.patch("semantic_release.cli.commit_new_version")
     mock_set_new = mocker.patch("semantic_release.cli.set_new_version")
 
@@ -400,7 +485,8 @@ def test_version_by_tag_check_build_status_succeeds(mocker):
     )
     mock_set_new_version = mocker.patch("semantic_release.cli.set_new_version")
     mock_tag_new_version = mocker.patch("semantic_release.cli.tag_new_version")
-    mocker.patch("semantic_release.cli.evaluate_version_bump", lambda *x: "major")
+    mocker.patch("semantic_release.cli.evaluate_version_bump",
+                 lambda *x: "major")
 
     version()
 
@@ -410,10 +496,12 @@ def test_version_by_tag_check_build_status_succeeds(mocker):
 
 
 def test_version_check_build_status_not_called_if_disabled(mocker):
-    mock_check_build_status = mocker.patch("semantic_release.cli.check_build_status")
+    mock_check_build_status = mocker.patch(
+        "semantic_release.cli.check_build_status")
     mocker.patch("semantic_release.cli.config.get", lambda *x, **y: False)
     mocker.patch("semantic_release.cli.tag_new_version")
-    mocker.patch("semantic_release.cli.evaluate_version_bump", lambda *x: "major")
+    mocker.patch("semantic_release.cli.evaluate_version_bump",
+                 lambda *x: "major")
     mocker.patch("semantic_release.cli.commit_new_version")
     mocker.patch("semantic_release.cli.set_new_version")
 
@@ -424,7 +512,8 @@ def test_version_check_build_status_not_called_if_disabled(mocker):
 
 def test_version_retry_and_giterror(mocker):
     mocker.patch(
-        "semantic_release.cli.get_current_version", mock.Mock(side_effect=GitError())
+        "semantic_release.cli.get_current_version", mock.Mock(
+            side_effect=GitError())
     )
 
     result = version(retry=True)
@@ -449,7 +538,7 @@ def test_version_retry(mocker):
     assert result
     mock_get_current.assert_called_once_with()
     mock_evaluate_bump.assert_called_once_with("current", False)
-    mock_get_new.assert_called_once_with("current", "patch")
+    mock_get_new.assert_called_once_with("current", "patch", False)
 
 
 def test_publish_should_not_run_pre_commit_by_default(mocker):
@@ -460,7 +549,8 @@ def test_publish_should_not_run_pre_commit_by_default(mocker):
     mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
     mocker.patch("semantic_release.cli.push_new_version", return_value=True)
     mocker.patch("semantic_release.cli.should_bump_version", return_value=True)
-    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.markdown_changelog",
+                 lambda *x, **y: "CHANGES")
     mocker.patch("semantic_release.cli.update_changelog_file")
     mocker.patch("semantic_release.cli.bump_version")
     mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
@@ -474,7 +564,8 @@ def test_publish_should_not_run_pre_commit_by_default(mocker):
             upload_to_release=False,
         ),
     )
-    mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
+    mocker.patch("semantic_release.cli.update_changelog_file",
+                 lambda *x, **y: None)
 
     publish()
 
@@ -489,7 +580,8 @@ def test_publish_should_not_run_pre_commit_with_empty_command(mocker):
     mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
     mocker.patch("semantic_release.cli.push_new_version", return_value=True)
     mocker.patch("semantic_release.cli.should_bump_version", return_value=True)
-    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.markdown_changelog",
+                 lambda *x, **y: "CHANGES")
     mocker.patch("semantic_release.cli.update_changelog_file")
     mocker.patch("semantic_release.cli.bump_version")
     mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
@@ -504,7 +596,8 @@ def test_publish_should_not_run_pre_commit_with_empty_command(mocker):
             pre_commit_command="",
         ),
     )
-    mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
+    mocker.patch("semantic_release.cli.update_changelog_file",
+                 lambda *x, **y: None)
 
     publish()
 
@@ -519,7 +612,8 @@ def test_publish_should_run_pre_commit_if_provided(mocker):
     mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
     mocker.patch("semantic_release.cli.push_new_version", return_value=True)
     mocker.patch("semantic_release.cli.should_bump_version", return_value=True)
-    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.markdown_changelog",
+                 lambda *x, **y: "CHANGES")
     mocker.patch("semantic_release.cli.update_changelog_file")
     mocker.patch("semantic_release.cli.bump_version")
     mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
@@ -534,7 +628,8 @@ def test_publish_should_run_pre_commit_if_provided(mocker):
             pre_commit_command='echo "Hello, world."',
         ),
     )
-    mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
+    mocker.patch("semantic_release.cli.update_changelog_file",
+                 lambda *x, **y: None)
 
     publish()
 
@@ -545,11 +640,14 @@ def test_publish_should_not_upload_to_pypi_if_option_is_false(mocker):
     mocker.patch("semantic_release.cli.checkout")
     mocker.patch("semantic_release.cli.ci_checks.check")
     mock_repository = mocker.patch.object(ArtifactRepo, "upload")
-    mock_upload_release = mocker.patch("semantic_release.cli.upload_to_release")
+    mock_upload_release = mocker.patch(
+        "semantic_release.cli.upload_to_release")
     mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
     mocker.patch("semantic_release.cli.push_new_version", return_value=True)
-    mocker.patch("semantic_release.cli.should_bump_version", return_value=False)
-    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.should_bump_version",
+                 return_value=False)
+    mocker.patch("semantic_release.cli.markdown_changelog",
+                 lambda *x, **y: "CHANGES")
     mocker.patch("semantic_release.cli.update_changelog_file")
     mocker.patch("semantic_release.cli.bump_version")
     mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
@@ -562,7 +660,8 @@ def test_publish_should_not_upload_to_pypi_if_option_is_false(mocker):
             upload_to_release=False,
         ),
     )
-    mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
+    mocker.patch("semantic_release.cli.update_changelog_file",
+                 lambda *x, **y: None)
 
     publish()
 
@@ -574,11 +673,14 @@ def test_publish_should_not_upload_to_repository_if_option_is_false(mocker):
     mocker.patch("semantic_release.cli.checkout")
     mocker.patch("semantic_release.cli.ci_checks.check")
     mock_repository = mocker.patch.object(ArtifactRepo, "upload")
-    mock_upload_release = mocker.patch("semantic_release.cli.upload_to_release")
+    mock_upload_release = mocker.patch(
+        "semantic_release.cli.upload_to_release")
     mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
     mocker.patch("semantic_release.cli.push_new_version", return_value=True)
-    mocker.patch("semantic_release.cli.should_bump_version", return_value=False)
-    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.should_bump_version",
+                 return_value=False)
+    mocker.patch("semantic_release.cli.markdown_changelog",
+                 lambda *x, **y: "CHANGES")
     mocker.patch("semantic_release.cli.update_changelog_file")
     mocker.patch("semantic_release.cli.bump_version")
     mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
@@ -591,7 +693,8 @@ def test_publish_should_not_upload_to_repository_if_option_is_false(mocker):
             upload_to_release=False,
         ),
     )
-    mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
+    mocker.patch("semantic_release.cli.update_changelog_file",
+                 lambda *x, **y: None)
 
     publish()
 
@@ -602,11 +705,13 @@ def test_publish_should_not_upload_to_repository_if_option_is_false(mocker):
 def test_publish_should_do_nothing_when_not_should_bump_version(mocker):
     mocker.patch("semantic_release.cli.checkout")
     mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
-    mocker.patch("semantic_release.cli.evaluate_version_bump", lambda *x: "feature")
+    mocker.patch("semantic_release.cli.evaluate_version_bump",
+                 lambda *x: "feature")
     mocker.patch("semantic_release.cli.generate_changelog")
     mock_log = mocker.patch("semantic_release.cli.post_changelog")
     mock_repository = mocker.patch.object(ArtifactRepo, "upload")
-    mock_upload_release = mocker.patch("semantic_release.cli.upload_to_release")
+    mock_upload_release = mocker.patch(
+        "semantic_release.cli.upload_to_release")
     mock_push = mocker.patch("semantic_release.cli.push_new_version")
     mock_ci_check = mocker.patch("semantic_release.ci_checks.check")
     mock_should_bump_version = mocker.patch(
@@ -646,9 +751,11 @@ def test_publish_should_call_functions(mocker):
         "semantic_release.cli.get_repository_owner_and_name",
         return_value=("relekang", "python-semantic-release"),
     )
-    mocker.patch("semantic_release.cli.evaluate_version_bump", lambda *x: "feature")
+    mocker.patch("semantic_release.cli.evaluate_version_bump",
+                 lambda *x: "feature")
     mocker.patch("semantic_release.cli.generate_changelog")
-    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.markdown_changelog",
+                 lambda *x, **y: "CHANGES")
     mocker.patch("semantic_release.cli.update_changelog_file")
     mocker.patch("semantic_release.cli.bump_version")
     mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
@@ -692,9 +799,11 @@ def test_publish_should_skip_build_when_command_is_empty(mocker):
         "semantic_release.cli.get_repository_owner_and_name",
         return_value=("relekang", "python-semantic-release"),
     )
-    mocker.patch("semantic_release.cli.evaluate_version_bump", lambda *x: "feature")
+    mocker.patch("semantic_release.cli.evaluate_version_bump",
+                 lambda *x: "feature")
     mocker.patch("semantic_release.cli.generate_changelog")
-    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.markdown_changelog",
+                 lambda *x, **y: "CHANGES")
     mocker.patch("semantic_release.cli.update_changelog_file")
     mocker.patch("semantic_release.cli.bump_version")
     mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
@@ -774,7 +883,8 @@ def test_publish_bad_token(mocker):
             remove_dist=False,
         ),
     )
-    mock_should_bump_version = mocker.patch("semantic_release.cli.should_bump_version")
+    mock_should_bump_version = mocker.patch(
+        "semantic_release.cli.should_bump_version")
     mock_get_token = mocker.patch(
         "semantic_release.cli.get_token", return_value="SUPERTOKEN"
     )
@@ -857,21 +967,23 @@ def test_publish_giterror_when_posting(mocker):
         "semantic_release.cli.update_changelog_file"
     )
     mock_post = mocker.patch(
-        "semantic_release.cli.post_changelog", mock.Mock(side_effect=GitError())
+        "semantic_release.cli.post_changelog", mock.Mock(
+            side_effect=GitError())
     )
 
     publish(noop=False, retry=False, force_level=False)
 
     mock_get_current.assert_called_once_with()
     mock_evaluate.assert_called_once_with("current", False)
-    mock_get_new.assert_called_once_with("current", "patch")
+    mock_get_new.assert_called_once_with("current", "patch", False)
     mock_get_owner_name.assert_called_once_with()
     mock_ci_check.assert_called()
     mock_checkout.assert_called_once_with("my_branch")
     mock_should_bump_version.assert_called_once_with(
         current_version="current", new_version="new", noop=False, retry=False
     )
-    mock_update_changelog_file.assert_called_once_with("new", "super md changelog")
+    mock_update_changelog_file.assert_called_once_with(
+        "new", "super md changelog")
     mock_bump_version.assert_called_once_with("new", "patch")
     mock_get_token.assert_called_once_with()
     mock_get_domain.assert_called_once_with()
@@ -892,17 +1004,20 @@ def test_publish_giterror_when_posting(mocker):
         header=False,
         previous_version="current",
     )
-    mock_post.assert_called_once_with("owner", "name", "new", "super md changelog")
+    mock_post.assert_called_once_with(
+        "owner", "name", "new", "super md changelog")
 
 
 def test_changelog_should_call_functions(mocker, runner):
-    mock_changelog = mocker.patch("semantic_release.cli.changelog", return_value=True)
+    mock_changelog = mocker.patch(
+        "semantic_release.cli.changelog", return_value=True)
     result = runner.invoke(main, ["changelog"])
     assert result.exit_code == 0
     mock_changelog.assert_called_once_with(
         noop=False,
         post=False,
         force_level=None,
+        prerelease=False,
         retry=False,
         unreleased=False,
         define=(),
@@ -929,7 +1044,8 @@ def test_overload_by_cli(mocker, runner):
 
 
 def test_changelog_noop(mocker):
-    mocker.patch("semantic_release.cli.get_current_version", return_value="current")
+    mocker.patch("semantic_release.cli.get_current_version",
+                 return_value="current")
     mock_previous_version = mocker.patch(
         "semantic_release.cli.get_previous_version", return_value="previous"
     )
@@ -954,7 +1070,8 @@ def test_changelog_noop(mocker):
 
 
 def test_changelog_post_unreleased_no_token(mocker):
-    mocker.patch("semantic_release.cli.get_current_version", return_value="current")
+    mocker.patch("semantic_release.cli.get_current_version",
+                 return_value="current")
     mock_previous_version = mocker.patch(
         "semantic_release.cli.get_previous_version", return_value="previous"
     )
@@ -983,7 +1100,8 @@ def test_changelog_post_unreleased_no_token(mocker):
 
 
 def test_changelog_post_complete(mocker):
-    mocker.patch("semantic_release.cli.get_current_version", return_value="current")
+    mocker.patch("semantic_release.cli.get_current_version",
+                 return_value="current")
     mock_previous_version = mocker.patch(
         "semantic_release.cli.get_previous_version", return_value="previous"
     )
diff --git a/tests/test_hvcs.py b/tests/test_hvcs.py
index b95c35a..c806173 100644
--- a/tests/test_hvcs.py
+++ b/tests/test_hvcs.py
@@ -10,6 +10,7 @@ import responses
 
 from semantic_release.errors import ImproperConfigurationError
 from semantic_release.hvcs import (
+    Gitea,
     Github,
     Gitlab,
     check_build_status,
@@ -38,6 +39,11 @@ def test_get_hvcs_should_return_gitlab():
     assert get_hvcs() == Gitlab
 
 
+@mock.patch("semantic_release.hvcs.config.get", wrapped_config_get(hvcs="gitea"))
+def test_get_hvcs_should_return_gitea():
+    assert get_hvcs() == Gitea
+
+
 @mock.patch("semantic_release.hvcs.config.get", lambda *x: "doesnotexist")
 def test_get_hvcs_should_raise_improper_config():
     pytest.raises(ImproperConfigurationError, get_hvcs)
@@ -80,6 +86,24 @@ def test_check_custom_github_token_should_return_false():
     assert check_token() is False
 
 
+@mock.patch("os.environ", {"GITEA_TOKEN": "token"})
+@mock.patch(
+    "semantic_release.hvcs.config.get",
+    wrapped_config_get(gitea_token_var="CUSTOM_TOKEN"),
+)
+def test_check_custom_gitea_token_should_return_false():
+    assert check_token() is False
+
+
+@mock.patch("os.environ", {"GITEA_CUSTOM_TOKEN": "token"})
+@mock.patch(
+    "semantic_release.hvcs.config.get",
+    wrapped_config_get(gitea_token_var="GITEA_CUSTOM_TOKEN"),
+)
+def test_check_custom_gitea_token_should_return_false():
+    assert check_token() is False
+
+
 @mock.patch("os.environ", {"CUSTOM_GL_TOKEN": "token"})
 @mock.patch(
     "semantic_release.hvcs.config.get",
@@ -99,10 +123,26 @@ def test_check_custom_gitlab_token_should_return_false():
 
 
 @pytest.mark.parametrize(
-    "hvcs,hvcs_domain,expected_domain,api_url,ci_server_host,hvcs_api_domain",
+    "hvcs,hvcs_domain,expected_domain,expected_api_url,ci_server_host,hvcs_api_domain",
     [
         ("github", None, "github.com", "https://api.github.com", None, None),
         ("gitlab", None, "gitlab.com", "https://gitlab.com", None, None),
+        (
+            "gitea",
+            "git.grassrootseconomics.net",
+            "git.grassrootseconomics.net",
+            "https://git.grassrootseconomics.net/api/v1",
+            None,
+            None,
+        ),
+        (
+            "github",
+            "github.example.com",
+            "github.example.com",
+            "https://api.github.com",
+            None,
+            None,
+        ),
         (
             "github",
             "github.example.com",
@@ -139,7 +179,12 @@ def test_check_custom_gitlab_token_should_return_false():
 )
 @mock.patch("os.environ", {"GL_TOKEN": "token"})
 def test_get_domain_should_have_expected_domain(
-    hvcs, hvcs_domain, expected_domain, api_url, ci_server_host, hvcs_api_domain
+    hvcs,
+    hvcs_domain,
+    expected_domain,
+    expected_api_url,
+    ci_server_host,
+    hvcs_api_domain,
 ):
 
     with mock.patch(
@@ -153,12 +198,13 @@ def test_get_domain_should_have_expected_domain(
             {
                 "GL_TOKEN": "token",
                 "GH_TOKEN": "token",
+                "GITEA_TOKEN": "token",
                 "CI_SERVER_HOST": ci_server_host,
             },
         ):
 
             assert get_hvcs().domain() == expected_domain
-            assert get_hvcs().api_url() == api_url
+            assert get_hvcs().api_url() == expected_api_url
 
 
 @mock.patch("semantic_release.hvcs.config.get", wrapped_config_get(hvcs="github"))
@@ -239,6 +285,59 @@ class GithubCheckBuildStatusTests(TestCase):
         )
 
 
+class GiteaCheckBuildStatusTests(TestCase):
+    url = "https://gitea.com/api/v1/repos/gitea/tea/statuses/268aa061795d53a1914b734c479b7c1f62cfd7af"
+
+    def get_response(self, status):
+        return json.dumps(
+            {
+                "status": status,
+            }
+        )
+
+    @responses.activate
+    def test_should_return_false_if_pending(self):
+        responses.add(
+            responses.GET,
+            self.url,
+            body=self.get_response("pending"),
+            content_type="application/json",
+        )
+        self.assertFalse(
+            Gitea.check_build_status(
+                "gitea", "tea", "268aa061795d53a1914b734c479b7c1f62cfd7af"
+            )
+        )
+
+    @responses.activate
+    def test_should_return_false_if_failure(self):
+        responses.add(
+            responses.GET,
+            self.url,
+            body=self.get_response("failure"),
+            content_type="application/json",
+        )
+        self.assertFalse(
+            Gitea.check_build_status(
+                "gitea", "tea", "268aa061795d53a1914b734c479b7c1f62cfd7af"
+            )
+        )
+
+    @responses.activate
+    def test_should_return_true_if_success(self):
+        responses.add(
+            responses.GET,
+            self.url,
+            body=self.get_response("success"),
+            content_type="application/json",
+        )
+        self.assertTrue(
+            Gitea.check_build_status(
+                "gitea", "tea", "268aa061795d53a1914b734c479b7c1f62cfd7af"
+            )
+        )
+
+
 class GitlabCheckBuildStatusTests(TestCase):
     @mock_gitlab("pending")
     def test_should_return_false_if_pending(self, mock_auth, mock_project):
@@ -473,6 +572,213 @@ class GithubReleaseTests(TestCase):
         os.remove(dummy_file_path)
 
 
+class GiteaReleaseTests(TestCase):
+
+    url = "https://gitea.com/api/v1/repos/gitea/tea/releases"
+    edit_url = "https://gitea.com/api/v1/repos/gitea/tea/releases/1"
+    get_url = "https://gitea.com/api/v1/repos/gitea/tea/releases/tags/v1.0.0"
+    asset_url = "https://gitea.com/api/v1/repos/gitea/tea/releases/1/assets"
+    asset_url_params = (
+        "https://gitea.com/api/v1/repos/gitea/tea/releases/1/assets"
+        "?name=testupload.md"
+    )
+    asset_no_extension_url_params = (
+        "https://gitea.com/api/v1/repos/gitea/tea/releases/1/assets"
+        "?name=testupload-no-extension"
+    )
+    dist_asset_url_params = (
+        "https://gitea.com/api/v1/repos/gitea/tea/releases/1/assets"
+        "?name=testupload.md"
+    )
+
+    @responses.activate
+    @mock.patch("semantic_release.hvcs.Gitea.token", return_value="super-token")
+    def test_should_post_changelog_using_gitea_token(self, mock_token):
+        with NamedTemporaryFile("w") as netrc_file:
+            netrc_file.write("machine gitea.com\n")
+            netrc_file.write("login username\n")
+            netrc_file.write("password password\n")
+
+            netrc_file.flush()
+
+            def request_callback(request):
+                payload = json.loads(request.body)
+                self.assertEqual(payload["tag_name"], "v1.0.0")
+                self.assertEqual(payload["body"], "text")
+                self.assertEqual(payload["draft"], False)
+                self.assertEqual(payload["prerelease"], False)
+                self.assertEqual(
+                    "token super-token", request.headers.get("Authorization")
+                )
+
+                return 201, {}, json.dumps({})
+
+            responses.add_callback(
+                responses.POST,
+                self.url,
+                callback=request_callback,
+                content_type="application/json",
+            )
+
+            with mock.patch.dict(os.environ, {"NETRC": netrc_file.name}):
+                status = Gitea.post_release_changelog("gitea", "tea", "1.0.0", "text")
+                self.assertTrue(status)
+
+    @responses.activate
+    @mock.patch("semantic_release.hvcs.Gitea.token", return_value=None)
+    def test_should_post_changelog_using_netrc(self, mock_token):
+        with NamedTemporaryFile("w") as netrc_file:
+            netrc_file.write("machine gitea.com\n")
+            netrc_file.write("login username\n")
+            netrc_file.write("password password\n")
+
+            netrc_file.flush()
+
+            def request_callback(request):
+                payload = json.loads(request.body)
+                self.assertEqual(payload["tag_name"], "v1.0.0")
+                self.assertEqual(payload["body"], "text")
+                self.assertEqual(payload["draft"], False)
+                self.assertEqual(payload["prerelease"], False)
+                self.assertEqual(
+                    "Basic "
+                    + base64.encodebytes(b"username:password").decode("ascii").strip(),
+                    request.headers.get("Authorization"),
+                )
+
+                return 201, {}, json.dumps({})
+
+            responses.add_callback(
+                responses.POST,
+                self.url,
+                callback=request_callback,
+                content_type="application/json",
+            )
+
+            with mock.patch.dict(os.environ, {"NETRC": netrc_file.name}):
+                status = Gitea.post_release_changelog("gitea", "tea", "1.0.0", "text")
+                self.assertTrue(status)
+
+    @responses.activate
+    def test_should_return_false_status_if_it_failed(self):
+        responses.add(
+            responses.POST,
+            self.url,
+            status=400,
+            body="{}",
+            content_type="application/json",
+        )
+        responses.add(
+            responses.GET,
+            self.get_url,
+            status=200,
+            body='{"id": 1}',
+            content_type="application/json",
+        )
+        responses.add(
+            responses.POST,
+            self.edit_url,
+            status=400,
+            body="{}",
+            content_type="application/json",
+        )
+        self.assertFalse(Gitea.post_release_changelog("gitea", "tea", "1.0.0", "text"))
+
+    @responses.activate
+    @mock.patch("semantic_release.hvcs.Gitea.token", return_value="super-token")
+    def test_should_upload_asset(self, mock_token):
+        # Create temporary file to upload
+        dummy_file_path = os.path.join(temp_dir, "testupload.md")
+        os.makedirs(os.path.dirname(dummy_file_path), exist_ok=True)
+        dummy_content = "# Test File\n\n*Dummy asset for testing uploads.*"
+        with open(dummy_file_path, "w") as dummy_file:
+            dummy_file.write(dummy_content)
+
+        def request_callback(request):
+            self.assertTrue(dummy_content in request.body.decode())
+            self.assertEqual(request.url, self.asset_url_params)
+            self.assertTrue("multipart/form-data" in request.headers["Content-Type"])
+            self.assertEqual("token super-token", request.headers.get("Authorization"))
+
+            return 201, {}, json.dumps({})
+
+        responses.add_callback(
+            responses.POST, self.asset_url, callback=request_callback
+        )
+        status = Gitea.upload_asset("gitea", "tea", 1, dummy_file_path, "Dummy file")
+        self.assertTrue(status)
+
+        # Remove test file
+        os.remove(dummy_file_path)
+
+    @responses.activate
+    @mock.patch("semantic_release.hvcs.Gitea.token", return_value="super-token")
+    def test_should_upload_asset_with_no_extension(self, mock_token):
+        # Create temporary file to upload
+        dummy_file_path = os.path.join(temp_dir, "testupload-no-extension")
+        os.makedirs(os.path.dirname(dummy_file_path), exist_ok=True)
+        dummy_content = (
+            "# Test File with no extension\n\n*Dummy asset for testing uploads.*"
+        )
+        with open(dummy_file_path, "w") as dummy_file:
+            dummy_file.write(dummy_content)
+
+        def request_callback(request):
+            self.assertTrue(dummy_content in request.body.decode())
+            self.assertEqual(request.url, self.asset_no_extension_url_params)
+            self.assertTrue("multipart/form-data" in request.headers["Content-Type"])
+            self.assertEqual("token super-token", request.headers["Authorization"])
+
+            return 201, {}, json.dumps({})
+
+        responses.add_callback(
+            responses.POST, self.asset_url, callback=request_callback
+        )
+        status = Gitea.upload_asset(
+            "gitea", "tea", 1, dummy_file_path, "Dummy file no extension"
+        )
+        self.assertTrue(status)
+
+        # Remove test file
+        os.remove(dummy_file_path)
+
+    @responses.activate
+    @mock.patch("semantic_release.hvcs.Gitea.token", return_value="super-token")
+    def test_should_upload_dists(self, mock_token):
+        # Create temporary file to upload
+        dummy_file_path = os.path.join(temp_dir, "dist", "testupload.md")
+        os.makedirs(os.path.dirname(dummy_file_path), exist_ok=True)
+        dummy_content = "# Test File\n\n*Dummy asset for testing uploads.*"
+        with open(dummy_file_path, "w") as dummy_file:
+            dummy_file.write(dummy_content)
+
+        def request_callback(request):
+            self.assertTrue(dummy_content in request.body.decode())
+            self.assertEqual(request.url, self.dist_asset_url_params)
+            self.assertTrue("multipart/form-data" in request.headers["Content-Type"])
+            self.assertEqual("token super-token", request.headers.get("Authorization"))
+
+            return 201, {}, json.dumps({})
+
+        responses.add(
+            responses.GET,
+            self.get_url,
+            status=200,
+            body='{"id": 1}',
+            content_type="application/json",
+        )
+        responses.add_callback(
+            responses.POST, self.asset_url, callback=request_callback
+        )
+        status = Gitea.upload_dists(
+            "gitea", "tea", "1.0.0", os.path.dirname(dummy_file_path)
+        )
+        self.assertTrue(status)
+
+        # Remove test file
+        os.remove(dummy_file_path)
+
+
 class GitlabReleaseTests(TestCase):
     @mock_gitlab()
     def test_should_return_true_if_success(self, mock_auth, mock_project):
@@ -487,6 +793,11 @@ class GitlabReleaseTests(TestCase):
         self.assertTrue(post_changelog("owner", "repo", "my_locked_tag", "changelog"))
 
 
+def test_gitea_token():
+    with mock.patch("os.environ", {"GITEA_TOKEN": "token"}):
+        assert Gitea.token() == "token"
+
+
 def test_github_token():
     with mock.patch("os.environ", {"GH_TOKEN": "token"}):
         assert Github.token() == "token"
diff --git a/tests/test_repository.py b/tests/test_repository.py
index 975d25d..0a4ff43 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -1,3 +1,4 @@
+import os
 from pathlib import Path
 from unittest import TestCase, mock
 
@@ -111,7 +112,7 @@ class RepositoryTests(TestCase):
     @mock.patch.object(ArtifactRepo, "_handle_credentials_init")
     def test_repo_with_custom_dist_path(self, mock_handle_creds):
         repo = ArtifactRepo(Path("custom-dist"))
-        self.assertEqual(repo.dists, ["custom-dist/*"])
+        self.assertEqual(repo.dists, [os.path.join("custom-dist", "*")])
 
     @mock.patch.object(ArtifactRepo, "_handle_credentials_init")
     @mock.patch(
@@ -120,7 +121,10 @@ class RepositoryTests(TestCase):
     )
     def test_repo_with_custom_dist_globs(self, mock_handle_creds):
         repo = ArtifactRepo(Path("dist"))
-        self.assertEqual(repo.dists, ["dist/*.tar.gz", "dist/*.whl"])
+        self.assertEqual(
+            repo.dists,
+            [os.path.join("dist", "*.tar.gz"), os.path.join("dist", "*.whl")],
+        )
 
     @mock.patch.object(ArtifactRepo, "_handle_credentials_init")
     @mock.patch(
@@ -129,7 +133,10 @@ class RepositoryTests(TestCase):
     )
     def test_repo_with_custom_pypi_globs(self, mock_handle_creds):
         repo = ArtifactRepo(Path("dist"))
-        self.assertEqual(repo.dists, ["dist/*.tar.gz", "dist/*.whl"])
+        self.assertEqual(
+            repo.dists,
+            [os.path.join("dist", "*.tar.gz"), os.path.join("dist", "*.whl")],
+        )
 
     @mock.patch.object(ArtifactRepo, "_handle_credentials_init")
     @mock.patch(
diff --git a/tests/test_settings.py b/tests/test_settings.py
index bdc7b1c..4ef43b8 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -40,6 +40,7 @@ class ConfigTests(TestCase):
         self.assertEqual(config.get("hvcs"), "github")
         self.assertEqual(config.get("upload_to_repository"), True)
         self.assertEqual(config.get("github_token_var"), "GH_TOKEN")
+        self.assertEqual(config.get("gitea_token_var"), "GITEA_TOKEN")
         self.assertEqual(config.get("gitlab_token_var"), "GL_TOKEN")
         self.assertEqual(config.get("pypi_pass_var"), "PYPI_PASSWORD")
         self.assertEqual(config.get("pypi_token_var"), "PYPI_TOKEN")
diff --git a/tests/test_vcs_helpers.py b/tests/test_vcs_helpers.py
index 0227d0b..1fbabf9 100644
--- a/tests/test_vcs_helpers.py
+++ b/tests/test_vcs_helpers.py
@@ -348,6 +348,47 @@ def test_get_last_version(skip_tags, expected_result):
     assert expected_result == get_last_version(skip_tags)
 
 
+@pytest.mark.parametrize(
+    "skip_tags,expected_result",
+    [
+        (None, "2.0.0"),
+        (["v2.0.0"], "1.1.0"),
+        (["v0.1.0", "v1.0.0", "v1.1.0", "v2.0.0"], None),
+    ],
+)
+def test_get_last_version_with_omit_pattern(skip_tags, expected_result):
+    class FakeCommit:
+        def __init__(self, com_date):
+            self.committed_date = com_date
+
+    class FakeTagObject:
+        def __init__(self, tag_date):
+            self.tagged_date = tag_date
+
+    class FakeTag:
+        def __init__(self, name, sha, date, is_tag_object):
+            self.name = name
+            self.tag = FakeTagObject(date)
+            if is_tag_object:
+                self.commit = TagObject(Repo(), sha)
+            else:
+                self.commit = FakeCommit(date)
+
+    mock.patch("semantic_release.vcs_helpers.check_repo")
+    git.repo.base.Repo.tags = mock.PropertyMock(
+        return_value=[
+            FakeTag("v0.1.0", "aaaaaaaaaaaaaaaaaaaa", 1, True),
+            FakeTag("v2.0.0", "dddddddddddddddddddd", 5, True),
+            FakeTag("v2.1.0-beta", "ffffffffffffffffffff", 7, True),
+            FakeTag("badly_formatted", "eeeeeeeeeeeeeeeeeeee", 6, False),
+            FakeTag("v2.0.0-beta", "ffffffffffffffffffff", 4, True),
+            FakeTag("v1.1.0", "cccccccccccccccccccc", 3, True),
+            FakeTag("v1.0.0", "bbbbbbbbbbbbbbbbbbbb", 2, False),
+        ]
+    )
+    assert expected_result == get_last_version(skip_tags, omit_pattern="-beta")
+
+
 @pytest.mark.parametrize(
     "tag_name,expected_version",
     [