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
37 changes: 28 additions & 9 deletions get_release_version_action/algorithms/main_algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os

import git
from semver import Version

from ..models import Inputs, Outputs
from ..utils import create_git_tag
Expand All @@ -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)
Expand All @@ -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!')
Expand Down
142 changes: 142 additions & 0 deletions tests/e2e/test_hotfix_collision.py
Original file line number Diff line number Diff line change
@@ -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='[email protected]',
prefix='v',
suffix='pre',
reference_version_suffix=None,
create_tag=True
)


def _fix_beta_inputs() -> ActionInputs:
return ActionInputs(
git_username='wemogy IT',
git_email='[email protected]',
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='[email protected]',
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='[email protected]',
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='[email protected]',
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='[email protected]',
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')
Loading