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
92 changes: 48 additions & 44 deletions get_release_version_action/algorithms/semantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,61 +96,63 @@ def get_current_version(
return None


def get_new_commits(repo: git.Repo, tag: git.TagReference | None) -> list[git.Commit]:
"""Get all commits newer than the specified tag."""
max_commits = 50
commit_offset = 0
reached_starting_tag = False
new_commits: list[git.Commit] = []

while not reached_starting_tag:
try:
commits = repo.iter_commits(max_count=max_commits, skip=commit_offset)
except ValueError:
logger.warning('No commits found')
return []

i = 0

for commit in commits:
i += 1

if tag is None:
logger.debug('Commit %s was found', commit.hexsha)
new_commits.append(commit)
continue
def get_new_commits(
repo: git.Repo,
reference_tag: git.TagReference | None,
channel_tag: git.TagReference | None = None
) -> list[git.Commit]:
"""
Get all commits that are reachable from ``HEAD`` but not from the given tags.

if commit == tag.commit:
logger.debug(
'Commit %s is current version %s (%s)',
commit.hexsha, tag.name, tag.commit.hexsha
)
reached_starting_tag = True
break
The range is resolved by ancestry (``git rev-list HEAD ^<reference_tag> ^<channel_tag>``) instead of by
commit date. A branch that stays open across another release has commits that are older than the newer
tag, so a date-ordered walk reaches the tag first and never sees them (see issue #86).

logger.debug(
'Commit %s is newer than current version %s (%s)',
commit.hexsha, tag.name, tag.commit.hexsha
)
new_commits.append(commit)
:param reference_tag: The version the next version is calculated from (may live on another channel).
Commits reachable from it are already released and are excluded.
:param channel_tag: The latest version on the current channel. Commits reachable from it are already
released on this channel (e.g. cherry-picked hotfixes) and must not be counted again, even though
they are not reachable from ``reference_tag``.
"""
exclude = [
tag.commit.hexsha
for tag in (reference_tag, channel_tag)
if tag is not None
]

commit_offset += max_commits
try:
rev_list_args = [repo.head.commit.hexsha, *(f'^{sha}' for sha in exclude)]
output = repo.git.rev_list(*rev_list_args)
except ValueError as exc:
# Raised by GitPython when HEAD has no commit (e.g. an empty repository).
logger.warning('No commits found: %s', exc)
return []

if i < max_commits:
logger.debug('Reached the end of the commit history')
break
new_commits = [repo.commit(sha) for sha in output.split()]

if not exclude:
logger.debug('No current version tag, analysing all %d commit(s) of the history', len(new_commits))
else:
logger.debug(
'Found %d commit(s) that are not reachable from already released tags (%s)',
len(new_commits), ', '.join(exclude)
)

for commit in new_commits:
logger.debug('Commit %s will be analysed', commit.hexsha)

return new_commits


def analyze_commits(
repo: git.Repo,
current_version_tag: git.TagReference | None,
current_version: str | None
reference_version_tag: git.TagReference | None,
current_version: str | None,
channel_version_tag: git.TagReference | None = None
) -> tuple[str, bool]:
"""Determine the next version."""
# 1. Add all commits to a list until the commit with the current_version_tag is reached
new_commits = get_new_commits(repo, current_version_tag)
# 1. Collect all commits that are reachable from HEAD but not from an already released tag
new_commits = get_new_commits(repo, reference_version_tag, channel_version_tag)

# 2. Apply conventional commits to the list
commit_parser = AngularCommitParser(AngularParserOptions())
Expand Down Expand Up @@ -227,7 +229,9 @@ def get_next_version(inputs: Inputs, repo: git.Repo) -> GetNextVersionOutput:
if inputs.suffix is not None:
reference_version = reference_version.replace(f'-{inputs.suffix}', '', 1)

next_version, version_bumped = analyze_commits(repo, reference_version_tag, reference_version)
next_version, version_bumped = analyze_commits(
repo, reference_version_tag, reference_version, current_version_tag
)

# No change that requires a semantic version increase
if not version_bumped:
Expand Down
117 changes: 117 additions & 0 deletions tests/e2e/test_long_running_branch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Test that commits from a branch that stayed open across another release are still analysed."""
# 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, ActionOutputs, CommitMessages, logging, TestRepo, repo, run_action


def _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 _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 _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 test_breaking_change_older_than_previous_tag(repo: TestRepo) -> None:
"""
Regression: a ``feat!:`` commit made on a branch that stayed open across another release must still
be analysed, even though its commit date is older than the tag of that other release.

Scenario:

1. Branch ``feature`` off ``main`` and commit ``feat!:`` there.
2. While ``feature`` is still open, release a ``fix:`` from ``main``, producing ``v0.0.1``.
Its tag is therefore newer than the breaking commit.
3. Merge ``feature`` into ``main`` and release again. The breaking commit is in
``v0.0.1..HEAD`` by ancestry, so the version must become ``v1.0.0``.
"""
# Arrange: a breaking change on a branch that is not released yet.
repo.create_branch('feature', 'main')
repo.commit(CommitMessages.BREAKING_FEATURE)

# A fix is released while the feature branch is open, so its tag is newer than the breaking commit.
repo.checkout('main')
repo.commit(CommitMessages.FIX)

repo.merge('main', 'release')
run_action(_release_inputs())

repo.merge('release', 'release-beta')
run_action(_beta_inputs())

repo.merge('release-beta', 'release-prod')
output_fix_prod = run_action(_prod_inputs())
assert_that(output_fix_prod.version_name).is_equal_to('v0.0.1')

# Act: the feature branch lands and gets released.
repo.merge('feature', 'main')

repo.merge('main', 'release')
actual_output_release = run_action(_release_inputs())
tag_release = repo.get_latest_tag_name()

repo.merge('release', 'release-beta')
actual_output_beta = run_action(_beta_inputs())
tag_beta = repo.get_latest_tag_name()

repo.merge('release-beta', 'release-prod')
actual_output_prod = run_action(_prod_inputs())
tag_prod = repo.get_latest_tag_name()

# Assert: the breaking change is picked up, so the major version is bumped.
# Without the ancestry-based commit range, the walk stops at the v0.0.1-pre commit and the
# version stays at 0.0.1.
assert_that(actual_output_release).is_equal_to(ActionOutputs(
version='1.0.0-pre',
version_name='v1.0.0-pre',
previous_version='0.0.1-pre',
previous_version_name='v0.0.1-pre',
tag_created=True
))
assert_that(tag_release).is_equal_to('v1.0.0-pre')

assert_that(actual_output_beta).is_equal_to(ActionOutputs(
version='1.0.0-beta',
version_name='v1.0.0-beta',
previous_version='0.0.1-beta',
previous_version_name='v0.0.1-beta',
tag_created=True
))
assert_that(tag_beta).is_equal_to('v1.0.0-beta')

assert_that(actual_output_prod).is_equal_to(ActionOutputs(
version='1.0.0',
version_name='v1.0.0',
previous_version='0.0.1',
previous_version_name='v0.0.1',
tag_created=True
))
assert_that(tag_prod).is_equal_to('v1.0.0')
Loading