Skip to content

fix(algorithm): enable maintenance prereleases #864

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ on:
# default token permissions = none
permissions: {}

# If a new push is made to the branch, cancel the previous run
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:

commitlint:
Expand Down
135 changes: 75 additions & 60 deletions src/semantic_release/version/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,11 @@ def _increment_version(
Using the given versions, along with a given `level_bump`, increment to
the next version according to whether or not this is a prerelease.

`latest_version`, `latest_full_version` and `latest_full_version_in_history`
can be the same, but aren't necessarily.

`latest_version` is the most recent version released from this branch's history.
`latest_full_version` is the most recent full release (i.e. not a prerelease)
anywhere in the repository's history, including commits which aren't present on
this branch.
`latest_full_version_in_history`, correspondingly, is the latest full release which
is in this branch's history.
`latest_full_version`, the most recent full release (i.e. not a prerelease)
in this branch's history.

`latest_version` and `latest_full_version` can be the same, but aren't necessarily.
"""
local_vars = list(locals().items())
logger.log(
Expand Down Expand Up @@ -165,74 +161,93 @@ def _increment_version(

level_bump = min(level_bump, LevelBump.MINOR)

# Determine the difference between the latest version and the latest full release
diff_with_last_released_version = latest_version - latest_full_version
logger.debug(
"diff between the latest version %s and the latest full release version %s is: %s",
"prerelease=%s and the latest version %s %s prerelease",
prerelease,
latest_version,
latest_full_version,
diff_with_last_released_version,
"is a" if latest_version.is_prerelease else "is not a",
)

# Handle prerelease version bumps
if prerelease:
# 6a i) if the level_bump > the level bump introduced by any prerelease tag
# before e.g. 1.2.4-rc.3 -> 1.3.0-rc.1
if level_bump > diff_with_last_released_version:
logger.debug(
"this release has a greater bump than any change since the last full release, %s",
latest_full_version,
)
return (
latest_full_version.finalize_version()
.bump(level_bump)
.to_prerelease(token=prerelease_token)
)
if level_bump == LevelBump.NO_RELEASE:
raise ValueError("level_bump must be at least PRERELEASE_REVISION")

# 6a ii) if level_bump <= the level bump introduced by prerelease tag
logger.debug(
"there has already been at least a %s release since the last full release %s",
level_bump,
latest_full_version,
)
logger.debug("this release will increment the prerelease revision")
return latest_version.to_prerelease(
token=prerelease_token,
revision=(
1
if latest_version.prerelease_token != prerelease_token
else (latest_version.prerelease_revision or 0) + 1
),
if level_bump == LevelBump.PRERELEASE_REVISION and not latest_version.is_prerelease:
raise ValueError(
"Cannot increment a non-prerelease version with a prerelease level bump"
)

# 6b. if not prerelease
# NOTE: These can actually be condensed down to the single line
# 6b. i) if there's been a prerelease
# assume we always want to increment the version that is the latest in the branch's history
base_version = latest_version

# if the current version is a prerelease & we want a new prerelease, then
# figure out if we need to bump the prerelease revision or start a new prerelease
if latest_version.is_prerelease:
# find the change since the last full release because if the current version is a prerelease
# then we need to predict properly the next full version
diff_with_last_released_version = latest_version - latest_full_version
logger.debug(
"prerelease=false and the latest version %s is a prerelease", latest_version
"the diff b/w the latest version '%s' and the latest full release version '%s' is: %s",
latest_version,
latest_full_version,
diff_with_last_released_version,
)
if level_bump > diff_with_last_released_version:
logger.debug(
"this release has a greater bump than any change since the last full release, %s",
latest_full_version,
)
return latest_version.bump(level_bump).finalize_version()

# Since the difference is less than or equal to the level bump and we want a new prerelease,
# we can abort early and just increment the revision
if level_bump <= diff_with_last_released_version:
# 6a ii) if level_bump <= the level bump introduced by the previous tag (latest_version)
if prerelease:
logger.debug(
"there has already been at least a %s release since the last full release %s",
level_bump,
latest_full_version,
)
logger.debug("Incrementing the prerelease revision...")
new_revision = base_version.to_prerelease(
token=prerelease_token,
revision=(
1
if latest_version.prerelease_token != prerelease_token
else (latest_version.prerelease_revision or 0) + 1
),
)
logger.debug("Incremented %s to %s", base_version, new_revision)
return new_revision

# When we don't want a prerelease, but the previous version is a prerelease that
# had a greater bump than we currently are applying, choose the larger bump instead
# as it consumes this bump
logger.debug("Finalizing the prerelease version...")
return base_version.finalize_version()

# Fallthrough to handle all larger level bumps
logger.debug(
"there has already been at least a %s release since the last full release %s",
level_bump,
"this release has a greater bump than any change since the last full release, %s",
latest_full_version,
)
return latest_version.finalize_version()

# 6b. ii) If there's been no prerelease
logger.debug(
"prerelease=false and %s is not a prerelease; bumping with a %s release",
latest_version,
level_bump,
# Fallthrough, if we don't want a prerelease, or if we do but the level bump is greater
#
# because the current version is a prerelease, we must start from the last full version
# Case 1: we identified that the level bump is greater than the change since
# the last full release, this will also reset the prerelease revision
# Case 2: we don't want a prerelease, so consider only the last full version in history
base_version = latest_full_version

# From the base version, we can now increment the version according to the level bump
# regardless of the prerelease status as bump() handles the reset and pass through
logger.debug("Bumping %s with a %s bump", base_version, level_bump)
target_next_version = base_version.bump(level_bump)

# Converting to/from a prerelease if necessary
target_next_version = (
target_next_version.to_prerelease(token=prerelease_token)
if prerelease
else target_next_version.finalize_version()
)
return latest_version.bump(level_bump)

logger.debug("Incremented %s to %s", base_version, target_next_version)
return target_next_version


def next_version(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
import tomlkit
from flatdict import FlatDict
from freezegun import freeze_time

from semantic_release.cli.commands.main import main

from tests.const import (
DEFAULT_BRANCH_NAME,
MAIN_PROG_NAME,
VERSION_SUBCMD,
)
from tests.fixtures.repos.trunk_based_dev import (
repo_w_trunk_only_dual_version_spt_angular_commits,
repo_w_trunk_only_dual_version_spt_emoji_commits,
repo_w_trunk_only_dual_version_spt_scipy_commits,
)
from tests.util import assert_successful_exit_code, temporary_working_directory

if TYPE_CHECKING:
from pathlib import Path
from unittest.mock import MagicMock

from click.testing import CliRunner
from requests_mock import Mocker

from tests.e2e.cmd_version.bump_version.conftest import (
GetSanitizedMdChangelogContentFn,
GetSanitizedRstChangelogContentFn,
InitMirrorRepo4RebuildFn,
)
from tests.fixtures.example_project import ExProjectDir
from tests.fixtures.git_repo import (
BuildRepoFromDefinitionFn,
BuildSpecificRepoFn,
CommitConvention,
GetGitRepo4DirFn,
RepoActionConfigure,
RepoActionRelease,
RepoActions,
SplitRepoActionsByReleaseTagsFn,
)


@pytest.mark.parametrize(
"repo_fixture_name",
[
repo_w_trunk_only_dual_version_spt_angular_commits.__name__,
*[
pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive)
for repo_fixture_name in [
repo_w_trunk_only_dual_version_spt_emoji_commits.__name__,
repo_w_trunk_only_dual_version_spt_scipy_commits.__name__,
]
],
],
)
def test_trunk_repo_rebuild_dual_version_spt_official_releases_only(
repo_fixture_name: str,
cli_runner: CliRunner,
build_trunk_only_repo_w_dual_version_support: BuildSpecificRepoFn,
split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn,
init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn,
example_project_dir: ExProjectDir,
git_repo_for_directory: GetGitRepo4DirFn,
build_repo_from_definition: BuildRepoFromDefinitionFn,
mocked_git_push: MagicMock,
post_mocker: Mocker,
default_tag_format_str: str,
version_py_file: Path,
get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn,
get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn,
):
# build target repo into a temporary directory
target_repo_dir = example_project_dir / repo_fixture_name
commit_type: CommitConvention = (
repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment]
)
target_repo_definition = build_trunk_only_repo_w_dual_version_support(
repo_name=repo_fixture_name,
commit_type=commit_type,
dest_dir=target_repo_dir,
)
target_git_repo = git_repo_for_directory(target_repo_dir)
target_repo_pyproject_toml = FlatDict(
tomlkit.loads((target_repo_dir / "pyproject.toml").read_text(encoding="utf-8")),
delimiter=".",
)
tag_format_str: str = target_repo_pyproject_toml.get( # type: ignore[assignment]
"tool.semantic_release.tag_format",
default_tag_format_str,
)

# split repo actions by release actions
releasetags_2_steps: dict[str, list[RepoActions]] = (
split_repo_actions_by_release_tags(target_repo_definition, tag_format_str)
)
configuration_step: RepoActionConfigure = releasetags_2_steps.pop("")[0] # type: ignore[assignment]

# Create the mirror repo directory
mirror_repo_dir = init_mirror_repo_for_rebuild(
mirror_repo_dir=(example_project_dir / "mirror"),
configuration_step=configuration_step,
)
mirror_git_repo = git_repo_for_directory(mirror_repo_dir)

# rebuild repo from scratch stopping before each release tag
for curr_release_tag, steps in releasetags_2_steps.items():
# make sure mocks are clear
mocked_git_push.reset_mock()
post_mocker.reset_mock()

# Extract expected result from target repo
head_reference_name = (
curr_release_tag
if curr_release_tag != "Unreleased"
else DEFAULT_BRANCH_NAME
)
target_git_repo.git.checkout(head_reference_name, detach=True)
expected_md_changelog_content = get_sanitized_md_changelog_content(
repo_dir=target_repo_dir
)
expected_rst_changelog_content = get_sanitized_rst_changelog_content(
repo_dir=target_repo_dir
)
expected_pyproject_toml_content = (
target_repo_dir / "pyproject.toml"
).read_text()
expected_version_file_content = (target_repo_dir / version_py_file).read_text()
expected_release_commit_text = target_git_repo.head.commit.message

# In our repo env, start building the repo from the definition
build_repo_from_definition(
dest_dir=mirror_repo_dir,
repo_construction_steps=steps[:-1], # stop before the release step
)
release_action_step: RepoActionRelease = steps[-1] # type: ignore[assignment]

# Act: run PSR on the repo instead of the RELEASE step
with freeze_time(
release_action_step["details"]["datetime"]
), temporary_working_directory(mirror_repo_dir):
build_metadata_args = (
[
"--build-metadata",
release_action_step["details"]["version"].split("+", maxsplit=1)[
-1
],
]
if len(release_action_step["details"]["version"].split("+", maxsplit=1))
> 1
else []
)
cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args]
result = cli_runner.invoke(main, cli_cmd[1:])

# take measurement after running the version command
actual_release_commit_text = mirror_git_repo.head.commit.message
actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text()
actual_version_file_content = (mirror_repo_dir / version_py_file).read_text()
actual_md_changelog_content = get_sanitized_md_changelog_content(
repo_dir=mirror_repo_dir
)
actual_rst_changelog_content = get_sanitized_rst_changelog_content(
repo_dir=mirror_repo_dir
)

# Evaluate (normal release actions should have occurred as expected)
assert_successful_exit_code(result, cli_cmd)
# Make sure version file is updated
assert expected_pyproject_toml_content == actual_pyproject_toml_content
assert expected_version_file_content == actual_version_file_content
# Make sure changelog is updated
assert expected_md_changelog_content == actual_md_changelog_content
assert expected_rst_changelog_content == actual_rst_changelog_content
# Make sure commit is created
assert expected_release_commit_text == actual_release_commit_text
# Make sure tag is created
assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags]
assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag
assert post_mocker.call_count == 1 # vcs release creation occured
Loading
Loading