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
46 changes: 46 additions & 0 deletions docs/configuration/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,52 @@ from the :ref:`remote.name <config-remote-name>` location of your git repository

----

.. _config-add_partial_tags:

``add_partial_tags``
""""""""""""""""""""

**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.

Partial version tags are **disabled** for pre-release versions.

**Example**

.. code-block:: toml

[semantic_release]
tag_format = "v{version}"
add_partial_tags = true

This configuration with the next version of ``1.2.3`` will result in:

.. code-block:: bash

git log --decorate --oneline --graph --all
# * 4d4cb0a (tag: v1.2.3, tag: v1.2, tag: v1, origin/main, main) 1.2.3
# * 3a2b1c0 fix: some bug
# * 2b1c0a9 (tag: v1.2.2) 1.2.2
# ...

If build-metadata is used, the next version of ``1.2.3+20251109`` will result in:

.. code-block:: bash

git log --decorate --oneline --graph --all
# * 4d4cb0a (tag: v1.2.3+20251109, tag: v1.2.3, tag: v1.2, tag: v1, origin/main, main) 1.2.3+20251109
# * 3a2b1c0 chore: add partial tags to PSR configuration
# * 2b1c0a9 (tag: v1.2.3+20251031) 1.2.3+20251031
# ...

**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 @@ -80,10 +80,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 @@ -454,7 +457,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,
)
):
logger.warning("No release tags found.")
return
Expand All @@ -475,6 +482,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(
gh_client=hvcs_client if isinstance(hvcs_client, Github) else None,
mode=(
Expand Down Expand Up @@ -777,6 +785,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} => {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 @@ -366,6 +366,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 @@ -827,7 +828,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 @@ -238,7 +238,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 @@ -248,21 +253,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 @@ -279,7 +288,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 @@ -305,21 +314,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
Expand Down
12 changes: 12 additions & 0 deletions src/semantic_release/version/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,19 @@ 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 = regexp(
regex_escape(tag_format).replace(
regex_escape(r"{version}"), r"[0-9]+(\.(0|[1-9][0-9]*))?$"
),
flags=VERBOSE,
)

def from_string(self, version_str: str) -> Version:
"""
Expand All @@ -75,6 +83,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 @@ -203,6 +203,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
Loading