Skip to content

feat(cmd-version): add support for partial tags #1115

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

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
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
24 changes: 24 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,30 @@ from the :ref:`remote.name <config-remote-name>` location of your git repository

----

.. _config-add_partial_tags:

``add_partial``
""""""""""""""

**Type:** ``bool``

Specify if partial version tags should be handled when creating a new version. If set to
``true``, a major and a major.minor tag will be created or updated, using the format
specified in :ref:`tag_format`. If version has build metadata, a major.minor.patch tag
will also be created or updated.

For example, with tag format ``v{version}`` and ``add_partial_tags`` set to ``true``, when
creating version ``1.2.3``, the tags ``v1`` and ``v1.2`` will be created or updated and
will point to the same commit as the ``v1.2.3`` tag. When creating version ``1.2.3+build.1234``,
the tags ``v1``, ``v1.2`` and ``v1.2.3`` will be created or updated and will point to the
same commit as the ``v1.2.3+build.1234`` tag.

The partial version tags will not be created or updated if the version is a pre-release.

**Default:** ``false``

----

.. _config-tag_format:

``tag_format``
Expand Down
35 changes: 32 additions & 3 deletions src/semantic_release/cli/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,13 @@ def is_forced_prerelease(
)


def last_released(repo_dir: Path, tag_format: str) -> tuple[Tag, Version] | None:
def last_released(
repo_dir: Path, tag_format: str, add_partial_tags: bool = False
) -> tuple[Tag, Version] | None:
with Repo(str(repo_dir)) as git_repo:
ts_and_vs = tags_and_versions(
git_repo.tags, VersionTranslator(tag_format=tag_format)
git_repo.tags,
VersionTranslator(tag_format=tag_format, add_partial_tags=add_partial_tags),
)

return ts_and_vs[0] if ts_and_vs else None
Expand Down Expand Up @@ -451,7 +454,11 @@ def version( # noqa: C901
if print_last_released or print_last_released_tag:
# TODO: get tag format a better way
if not (
last_release := last_released(config.repo_dir, tag_format=config.tag_format)
last_release := last_released(
config.repo_dir,
tag_format=config.tag_format,
add_partial_tags=config.add_partial_tags,
)
):
log.warning("No release tags found.")
return
Expand All @@ -472,6 +479,7 @@ def version( # noqa: C901
major_on_zero = runtime.major_on_zero
no_verify = runtime.no_git_verify
opts = runtime.global_cli_options
add_partial_tags = config.add_partial_tags
gha_output = VersionGitHubActionsOutput(released=False)

forced_level_bump = None if not force_level else LevelBump.from_string(force_level)
Expand Down Expand Up @@ -703,6 +711,27 @@ def version( # noqa: C901
tag=new_version.as_tag(),
noop=opts.noop,
)
# Create or update partial tags for releases
if add_partial_tags and not prerelease:
partial_tags = [new_version.as_major_tag(), new_version.as_minor_tag()]
# If build metadata is set, also retag the version without the metadata
if build_metadata:
partial_tags.append(new_version.as_patch_tag())

for partial_tag in partial_tags:
project.git_tag(
tag_name=partial_tag,
message=f"{partial_tag} is {new_version.as_tag()}",
isotimestamp=commit_date.isoformat(),
noop=opts.noop,
force=True,
)
project.git_push_tag(
remote_url=remote_url,
tag=partial_tag,
noop=opts.noop,
force=True,
)

# Update GitHub Actions output value now that release has occurred
gha_output.released = True
Expand Down
5 changes: 4 additions & 1 deletion src/semantic_release/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ class RawConfig(BaseModel):
remote: RemoteConfig = RemoteConfig()
no_git_verify: bool = False
tag_format: str = "v{version}"
add_partial_tags: bool = False
publish: PublishConfig = PublishConfig()
version_toml: Optional[Tuple[str, ...]] = None
version_variables: Optional[Tuple[str, ...]] = None
Expand Down Expand Up @@ -826,7 +827,9 @@ def from_raw_config( # noqa: C901

# version_translator
version_translator = VersionTranslator(
tag_format=raw.tag_format, prerelease_token=branch_config.prerelease_token
tag_format=raw.tag_format,
prerelease_token=branch_config.prerelease_token,
add_partial_tags=raw.add_partial_tags,
)

build_cmd_env = {}
Expand Down
51 changes: 31 additions & 20 deletions src/semantic_release/gitproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,12 @@ def git_commit(
raise GitCommitError("Failed to commit changes") from err

def git_tag(
self, tag_name: str, message: str, isotimestamp: str, noop: bool = False
self,
tag_name: str,
message: str,
isotimestamp: str,
force: bool = False,
noop: bool = False,
) -> None:
try:
datetime.fromisoformat(isotimestamp)
Expand All @@ -207,21 +212,25 @@ def git_tag(
if noop:
command = str.join(
" ",
[
f"GIT_COMMITTER_DATE={isotimestamp}",
*(
[
f"GIT_AUTHOR_NAME={self._commit_author.name}",
f"GIT_AUTHOR_EMAIL={self._commit_author.email}",
f"GIT_COMMITTER_NAME={self._commit_author.name}",
f"GIT_COMMITTER_EMAIL={self._commit_author.email}",
]
if self._commit_author
else [""]
),
f"git tag -a {tag_name} -m '{message}'",
],
)
filter(
None,
[
f"GIT_COMMITTER_DATE={isotimestamp}",
*(
[
f"GIT_AUTHOR_NAME={self._commit_author.name}",
f"GIT_AUTHOR_EMAIL={self._commit_author.email}",
f"GIT_COMMITTER_NAME={self._commit_author.name}",
f"GIT_COMMITTER_EMAIL={self._commit_author.email}",
]
if self._commit_author
else [""]
),
f"git tag -a {tag_name} -m '{message}'",
"--force" if force else "",
],
),
).strip()

noop_report(
indented(
Expand All @@ -238,7 +247,7 @@ def git_tag(
{"GIT_COMMITTER_DATE": isotimestamp},
):
try:
repo.git.tag("-a", tag_name, m=message)
repo.git.tag(tag_name, a=True, m=message, force=force)
except GitCommandError as err:
self.logger.exception(str(err))
raise GitTagError(f"Failed to create tag ({tag_name})") from err
Expand All @@ -264,21 +273,23 @@ def git_push_branch(self, remote_url: str, branch: str, noop: bool = False) -> N
f"Failed to push branch ({branch}) to remote"
) from err

def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None:
def git_push_tag(
self, remote_url: str, tag: str, noop: bool = False, force: bool = False
) -> None:
if noop:
noop_report(
indented(
f"""\
would have run:
git push {self._cred_masker.mask(remote_url)} tag {tag}
git push {self._cred_masker.mask(remote_url)} tag {tag} {"--force" if force else ""}
""" # noqa: E501
)
)
return

with Repo(str(self.project_root)) as repo:
try:
repo.git.push(remote_url, "tag", tag)
repo.git.push(remote_url, "tag", tag, force=force)
except GitCommandError as err:
self.logger.exception(str(err))
raise GitPushError(f"Failed to push tag ({tag}) to remote") from err
10 changes: 10 additions & 0 deletions src/semantic_release/version/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,17 @@ def __init__(
self,
tag_format: str = "v{version}",
prerelease_token: str = "rc", # noqa: S107
add_partial_tags: bool = False,
) -> None:
check_tag_format(tag_format)
self.tag_format = tag_format
self.prerelease_token = prerelease_token
self.add_partial_tags = add_partial_tags
self.from_tag_re = self._invert_tag_format_to_re(self.tag_format)
self.partial_tag_re = re.compile(
tag_format.replace(r"{version}", r"[0-9]+(\.(0|[1-9][0-9]*))?$"),
flags=re.VERBOSE,
)

def from_string(self, version_str: str) -> Version:
"""
Expand All @@ -71,6 +77,10 @@ def from_tag(self, tag: str) -> Version | None:
tag_match = self.from_tag_re.match(tag)
if not tag_match:
return None
if self.add_partial_tags:
partial_tag_match = self.partial_tag_re.match(tag)
if partial_tag_match:
return None
raw_version_str = tag_match.group("version")
return self.from_string(raw_version_str)

Expand Down
9 changes: 9 additions & 0 deletions src/semantic_release/version/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,15 @@ def __repr__(self) -> str:
def as_tag(self) -> str:
return self.tag_format.format(version=str(self))

def as_major_tag(self) -> str:
return self.tag_format.format(version=f"{self.major}")

def as_minor_tag(self) -> str:
return self.tag_format.format(version=f"{self.major}.{self.minor}")

def as_patch_tag(self) -> str:
return self.tag_format.format(version=f"{self.major}.{self.minor}.{self.patch}")

def as_semver_tag(self) -> str:
return f"v{self!s}"

Expand Down
Loading