From f0bad6f7ecd007c67b8ef044c4103a6b98854d6f Mon Sep 17 00:00:00 2001 From: "Tobias.Lengsholz" Date: Thu, 23 Apr 2026 15:38:52 +0200 Subject: [PATCH 1/2] fix: bump past existing tag when only_bump_suffix is set to avoid collision --- .../algorithms/main_algorithm.py | 37 +++-- tests/e2e/test_hotfix_collision.py | 142 ++++++++++++++++++ 2 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/test_hotfix_collision.py diff --git a/get_release_version_action/algorithms/main_algorithm.py b/get_release_version_action/algorithms/main_algorithm.py index f40bfb8..d681cd3 100644 --- a/get_release_version_action/algorithms/main_algorithm.py +++ b/get_release_version_action/algorithms/main_algorithm.py @@ -3,6 +3,7 @@ import os import git +from semver import Version from ..models import Inputs, Outputs from ..utils import create_git_tag @@ -16,6 +17,16 @@ logger = logging.getLogger('wemogy.get-release-version-action') +def _apply_suffix(version: str, suffix: str | None) -> str: + """Insert ``suffix`` before the prerelease (if any), matching the action's legacy tag naming.""" + if suffix is None: + return version + if '-' in version: + # The suffix goes before the bumping suffix, so replace the first dash. + return version.replace('-', f'-{suffix}-', 1) + return f'{version}-{suffix}' + + def main_algorithm(inputs: Inputs) -> Outputs: """The main algorithm.""" logger.debug('Inputs: %s', inputs) @@ -27,24 +38,32 @@ def main_algorithm(inputs: Inputs) -> Outputs: with git.Repo(os.getcwd()) as repo: if inputs.mode == 'semantic': - previous_version_tag_name, new_version, version_bumped = get_next_semantic_version(inputs, repo) + previous_version_tag_name, bare_version, version_bumped = get_next_semantic_version(inputs, repo) elif inputs.mode == 'hash-based': - previous_version_tag_name, new_version, version_bumped = get_next_version_hash(inputs, repo) + previous_version_tag_name, bare_version, version_bumped = get_next_version_hash(inputs, repo) else: raise ValueError(f'Expected input "mode" to be either "semantic" or "hash-based", but got "{inputs.mode}".') - if inputs.suffix is not None: - if '-' in new_version: - # The suffix should go before the bumping suffix, that's why the dash is replaced. - new_version = new_version.replace('-', f'-{inputs.suffix}-', 1) - else: - new_version += f'-{inputs.suffix}' - + new_version = _apply_suffix(bare_version, inputs.suffix) new_version_tag_name = f'{inputs.prefix}{new_version}' new_tag_needed = (version_bumped or ('0.0.0' not in new_version_tag_name and previous_version_tag_name != new_version_tag_name)) + # Collision avoidance for suffix bumps: if the computed tag name already exists (e.g. left over from a + # reverted deploy), keep bumping the prerelease counter until the name is free. This is only safe for + # ``only_bump_suffix`` mode, where "bump further" just means incrementing the hotfix counter. + if (inputs.only_bump_suffix and inputs.mode == 'semantic' and new_tag_needed): + existing_tag_names = {tag.name for tag in repo.tags} + while new_version_tag_name in existing_tag_names: + logger.warning( + 'Tag %s already exists; bumping %s to avoid a collision', + new_version_tag_name, inputs.bumping_suffix + ) + bare_version = str(Version.parse(bare_version).bump_prerelease(inputs.bumping_suffix)) + new_version = _apply_suffix(bare_version, inputs.suffix) + new_version_tag_name = f'{inputs.prefix}{new_version}' + if inputs.create_tag and new_tag_needed: if inputs.git_email is None or inputs.git_username is None: raise ValueError('git email and username are required when a tag should be created!') diff --git a/tests/e2e/test_hotfix_collision.py b/tests/e2e/test_hotfix_collision.py new file mode 100644 index 0000000..b1d1091 --- /dev/null +++ b/tests/e2e/test_hotfix_collision.py @@ -0,0 +1,142 @@ +"""Test that an already-existing tag does not cause a ``git tag`` collision in suffix-bump mode.""" +# pylint: disable=too-many-locals,too-many-statements,duplicate-code,unused-import,redefined-outer-name +from assertpy import assert_that + +from test_utils import ActionInputs, CommitMessages, logging, TestRepo, repo, run_action + + +def _fix_release_inputs() -> ActionInputs: + return ActionInputs( + git_username='wemogy IT', + git_email='it@wemogy.com', + prefix='v', + suffix='pre', + reference_version_suffix=None, + create_tag=True + ) + + +def _fix_beta_inputs() -> ActionInputs: + return ActionInputs( + git_username='wemogy IT', + git_email='it@wemogy.com', + prefix='v', + suffix='beta', + only_bump_suffix=True, + reference_version_suffix='pre', + create_tag=True + ) + + +def _fix_prod_inputs() -> ActionInputs: + return ActionInputs( + git_username='wemogy IT', + git_email='it@wemogy.com', + prefix='v', + suffix=None, + only_bump_suffix=True, + reference_version_suffix='beta', + create_tag=True + ) + + +def _hotfix_release_inputs() -> ActionInputs: + return ActionInputs( + git_username='wemogy IT', + git_email='it@wemogy.com', + prefix='v', + suffix='pre', + reference_version_suffix=None, + bumping_suffix='hotfix', + only_bump_suffix=True, + create_tag=True + ) + + +def _hotfix_beta_inputs() -> ActionInputs: + return ActionInputs( + git_username='wemogy IT', + git_email='it@wemogy.com', + prefix='v', + suffix='beta', + reference_version_suffix='pre', + bumping_suffix='hotfix', + only_bump_suffix=True, + create_tag=True + ) + + +def _hotfix_prod_inputs() -> ActionInputs: + return ActionInputs( + git_username='wemogy IT', + git_email='it@wemogy.com', + prefix='v', + suffix=None, + reference_version_suffix='beta', + bumping_suffix='hotfix', + only_bump_suffix=True, + create_tag=True + ) + + +def test_hotfix_skips_already_existing_tag(repo: TestRepo) -> None: + """ + Regression: when the tag the action would produce already exists (e.g. left over from a reverted deploy), + the action must bump further instead of failing with ``fatal: tag 'X' already exists``. + + Scenario: + + 1. A normal fix flows through release / release-beta / release-prod, producing ``v0.0.1``. + 2. A stale ``v0.0.1-hotfix.2`` tag pre-exists on an older commit (imagine it survived a reverted deploy). + 3. A first normal hotfix cycle creates ``v0.0.1-hotfix.1`` on a newer commit. This tag sorts first in the + action's commit-date-based lookup, so on the next hotfix the action computes ``v0.0.1-hotfix.2`` as + the next tag, which collides with the stale tag. + 4. A second hotfix cycle should advance past the collision, producing ``v0.0.1-hotfix.3``. + """ + # Arrange: normal fix → pre / beta / prod, producing v0.0.1 on release-prod. + repo.commit(CommitMessages.FIX) + + repo.merge('main', 'release') + run_action(_fix_release_inputs()) + + repo.merge('release', 'release-beta') + run_action(_fix_beta_inputs()) + + repo.merge('release-beta', 'release-prod') + run_action(_fix_prod_inputs()) + + # Stale tag on the oldest commit, surviving from a prior (reverted) deploy. + initial_commit = list(repo.repo.iter_commits('main'))[-1] + repo.repo.create_tag('v0.0.1-hotfix.2', ref=initial_commit, message='Release v0.0.1-hotfix.2') + + # First hotfix cycle → v0.0.1-hotfix.1 on a newer commit (newer than the stale tag target). + repo.checkout('main') + hotfix1_commit = repo.commit(CommitMessages.FIX) + + repo.cherrypick(hotfix1_commit, 'release') + run_action(_hotfix_release_inputs()) + + repo.cherrypick(hotfix1_commit, 'release-beta') + run_action(_hotfix_beta_inputs()) + + repo.cherrypick(hotfix1_commit, 'release-prod') + output_hotfix1_prod = run_action(_hotfix_prod_inputs()) + assert_that(output_hotfix1_prod.version_name).is_equal_to('v0.0.1-hotfix.1') + + # Act: second hotfix cycle; without the collision-avoidance fix, the prod action fails here + # with ``fatal: tag 'v0.0.1-hotfix.2' already exists``. + repo.checkout('main') + hotfix2_commit = repo.commit(CommitMessages.FIX) + + repo.cherrypick(hotfix2_commit, 'release') + run_action(_hotfix_release_inputs()) + + repo.cherrypick(hotfix2_commit, 'release-beta') + run_action(_hotfix_beta_inputs()) + + repo.cherrypick(hotfix2_commit, 'release-prod') + output_hotfix2_prod = run_action(_hotfix_prod_inputs()) + + # Assert: the action bumped past the stale tag. + assert_that(output_hotfix2_prod.version_name).is_equal_to('v0.0.1-hotfix.3') + assert_that(repo.get_latest_tag_name()).is_equal_to('v0.0.1-hotfix.3') \ No newline at end of file From 862f1c86a171e21eae79d01529db37e97e01a1a9 Mon Sep 17 00:00:00 2001 From: Tobias Lengsholz Date: Tue, 12 May 2026 09:16:51 +0200 Subject: [PATCH 2/2] Fix missing newline at end of test_hotfix_collision.py --- tests/e2e/test_hotfix_collision.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/test_hotfix_collision.py b/tests/e2e/test_hotfix_collision.py index b1d1091..00e00b9 100644 --- a/tests/e2e/test_hotfix_collision.py +++ b/tests/e2e/test_hotfix_collision.py @@ -139,4 +139,4 @@ def test_hotfix_skips_already_existing_tag(repo: TestRepo) -> None: # Assert: the action bumped past the stale tag. assert_that(output_hotfix2_prod.version_name).is_equal_to('v0.0.1-hotfix.3') - assert_that(repo.get_latest_tag_name()).is_equal_to('v0.0.1-hotfix.3') \ No newline at end of file + assert_that(repo.get_latest_tag_name()).is_equal_to('v0.0.1-hotfix.3')