From b28a0104f399086295e478875053b499d10e68b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20K=C3=BCsters?= Date: Tue, 25 Aug 2026 12:29:05 +0200 Subject: [PATCH 1/2] fix: resolve semantic commit range by ancestry instead of commit date get_new_commits() walked history in reverse commit-date order and stopped at the commit the previous tag pointed at. A branch that stays open across another release has commits older than the newer tag, so the date-ordered walk reached the tag first and never analysed them - silently dropping feat!:/BREAKING CHANGE: commits from the version calculation (issue #86). Resolve the range by ancestry via `git rev-list HEAD ^` instead. To keep the hotfix model working, also exclude commits reachable from the current channel's own latest tag, so cherry-picked hotfixes that are already released on this channel are not counted again. Fixes #86 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../algorithms/semantic.py | 90 +++++++------- tests/e2e/test_long_running_branch.py | 117 ++++++++++++++++++ 2 files changed, 163 insertions(+), 44 deletions(-) create mode 100644 tests/e2e/test_long_running_branch.py diff --git a/get_release_version_action/algorithms/semantic.py b/get_release_version_action/algorithms/semantic.py index 4658031..2997d14 100644 --- a/get_release_version_action/algorithms/semantic.py +++ b/get_release_version_action/algorithms/semantic.py @@ -96,61 +96,61 @@ 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 ^ ^``) 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 + ] + rev_list_args = [repo.head.commit.hexsha, *(f'^{sha}' for sha in exclude)] + + try: + output = repo.git.rev_list(*rev_list_args) + new_commits = [repo.commit(sha) for sha in output.split()] + except (ValueError, git.GitCommandError) as exc: + logger.warning('No commits found: %s', exc) + return [] - commit_offset += max_commits + 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) + ) - if i < max_commits: - logger.debug('Reached the end of the commit history') - break + 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()) @@ -227,7 +227,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: diff --git a/tests/e2e/test_long_running_branch.py b/tests/e2e/test_long_running_branch.py new file mode 100644 index 0000000..2ca6dde --- /dev/null +++ b/tests/e2e/test_long_running_branch.py @@ -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='it@wemogy.com', + prefix='v', + suffix='pre', + reference_version_suffix=None, + create_tag=True + ) + + +def _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 _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 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') From 16e72ec9022d21c10b033e486d5cb3492004cf1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20K=C3=BCsters?= Date: Tue, 25 Aug 2026 13:10:57 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20prese?= =?UTF-8?q?rve=20empty-repo=20handling=20and=20don't=20swallow=20rev-list?= =?UTF-8?q?=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move `repo.head.commit` resolution inside the try so an empty repository (no HEAD commit) still logs and returns [] instead of raising. - Only catch ValueError (missing HEAD). Let git.GitCommandError from `git rev-list` propagate, so a genuine command failure is not silently reported as "no semantic changes". Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- get_release_version_action/algorithms/semantic.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/get_release_version_action/algorithms/semantic.py b/get_release_version_action/algorithms/semantic.py index 2997d14..054682f 100644 --- a/get_release_version_action/algorithms/semantic.py +++ b/get_release_version_action/algorithms/semantic.py @@ -119,15 +119,17 @@ def get_new_commits( for tag in (reference_tag, channel_tag) if tag is not None ] - rev_list_args = [repo.head.commit.hexsha, *(f'^{sha}' for sha in exclude)] try: + rev_list_args = [repo.head.commit.hexsha, *(f'^{sha}' for sha in exclude)] output = repo.git.rev_list(*rev_list_args) - new_commits = [repo.commit(sha) for sha in output.split()] - except (ValueError, git.GitCommandError) as exc: + 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 [] + 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: