Skip to content
Merged
12 changes: 12 additions & 0 deletions docs/api/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ By default (in order):

#. Create a release in the remote VCS for this tag (if supported)

.. note::

Before pushing changes to the remote (step 6), Python Semantic Release automatically
verifies that the upstream branch has not changed since the commit that triggered
the release. This prevents push conflicts when another commit was made to the
upstream branch while the release was being prepared. If the upstream branch has
changed, the command will exit with an error, and you will need to pull the latest
changes and run the command again.

This verification only occurs when committing changes (``--commit``). If you are
running with ``--no-commit``, the verification will not be performed.

All of these steps can be toggled on or off using the command line options
described below. Some of the steps rely on others, so some options may implicitly
disable others.
Expand Down
49 changes: 5 additions & 44 deletions docs/configuration/automatic-releases/github-actions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -891,45 +891,6 @@ to the GitHub Release Assets as well.
run: |
git reset --hard ${{ github.sha }}

- name: Evaluate | Verify upstream has NOT changed
# Last chance to abort before causing an error as another PR/push was applied to
# the upstream branch while this workflow was running. This is important
# because we are committing a version change (--commit). You may omit this step
# if you have 'commit: false' in your configuration.
#
# You may consider moving this to a repo script and call it from this step instead
# of writing it in-line.
shell: bash
run: |
set +o pipefail

UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | awk -F '\\.\\.\\.' '{print $2}' | cut -d ' ' -f1)"
printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME"

set -o pipefail

if [ -z "$UPSTREAM_BRANCH_NAME" ]; then
printf >&2 '%s\n' "::error::Unable to determine upstream branch name!"
exit 1
fi

git fetch "${UPSTREAM_BRANCH_NAME%%/*}"

if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then
printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!"
exit 1
fi

HEAD_SHA="$(git rev-parse HEAD)"

if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then
printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]"
printf >&2 '%s\n' "::error::Upstream has changed, aborting release..."
exit 1
fi

printf '%s\n' "Verified upstream branch has not changed, continuing with release..."

- name: Action | Semantic Version Release
id: release
# Adjust tag with desired version if applicable.
Expand Down Expand Up @@ -998,11 +959,6 @@ to the GitHub Release Assets as well.
one release job in the case if there are multiple pushes to ``main`` in a short period
of time.

Secondly the *Evaluate | Verify upstream has NOT changed* step is used to ensure that the
upstream branch has not changed while the workflow was running. This is important because
we are committing a version change (``commit: true``) and there might be a push collision
that would cause undesired behavior. Review Issue `#1201`_ for more detailed information.

.. warning::
You must set ``fetch-depth`` to 0 when using ``actions/checkout@v4``, since
Python Semantic Release needs access to the full history to build a changelog
Expand All @@ -1018,6 +974,11 @@ to the GitHub Release Assets as well.
case, you will also need to pass the new token to ``actions/checkout`` (as
the ``token`` input) in order to gain push access.

.. note::
As of $NEW_RELEASE_TAG, the verify upstream step is no longer required as it has been
integrated into PSR directly. If you are using an older version of PSR, you will need
to review the older documentation for that step. See Issue `#1201`_ for more details.

.. _#1201: https://github.com/python-semantic-release/python-semantic-release/issues/1201
.. _concurrency: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idconcurrency

Expand Down
1 change: 0 additions & 1 deletion docs/configuration/configuration-guides/uv_integration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,6 @@ look like this:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
bash .github/workflows/verify_upstream.sh
uv run semantic-release -v --strict version --skip-build
uv run semantic-release publish

Expand Down
30 changes: 29 additions & 1 deletion src/semantic_release/cli/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import click
import shellingham # type: ignore[import]
from click_option_group import MutuallyExclusiveOptionGroup, optgroup
from git import Repo
from git import GitCommandError, Repo
from requests import HTTPError

from semantic_release.changelog.release_history import ReleaseHistory
Expand All @@ -27,9 +27,14 @@
from semantic_release.enums import LevelBump
from semantic_release.errors import (
BuildDistributionsError,
DetachedHeadGitError,
GitCommitEmptyIndexError,
GitFetchError,
InternalError,
LocalGitError,
UnexpectedResponse,
UnknownUpstreamBranchError,
UpstreamBranchChangedError,
)
from semantic_release.gitproject import GitProject
from semantic_release.globals import logger
Expand Down Expand Up @@ -727,6 +732,29 @@ def version( # noqa: C901
)

if commit_changes:
# Verify that the upstream branch has not changed before pushing
# This prevents conflicts if another commit was pushed while we were preparing the release
# We check HEAD~1 because we just made a release commit
try:
project.verify_upstream_unchanged(local_ref="HEAD~1", noop=opts.noop)
except UpstreamBranchChangedError as exc:
click.echo(str(exc), err=True)
click.echo(
"Upstream branch has changed. Please pull the latest changes and try again.",
err=True,
)
ctx.exit(1)
except (
DetachedHeadGitError,
GitCommandError,
UnknownUpstreamBranchError,
GitFetchError,
LocalGitError,
) as exc:
click.echo(str(exc), err=True)
click.echo("Unable to verify upstream due to error!", err=True)
ctx.exit(1)

# TODO: integrate into push branch
with Repo(str(runtime.repo_dir)) as git_repo:
active_branch = git_repo.active_branch.name
Expand Down
16 changes: 16 additions & 0 deletions src/semantic_release/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,19 @@ class GitTagError(SemanticReleaseBaseError):

class GitPushError(SemanticReleaseBaseError):
"""Raised when there is a failure to push to the git remote."""


class GitFetchError(SemanticReleaseBaseError):
"""Raised when there is a failure to fetch from the git remote."""


class LocalGitError(SemanticReleaseBaseError):
"""Raised when there is a failure with local git operations."""


class UnknownUpstreamBranchError(SemanticReleaseBaseError):
"""Raised when the upstream branch cannot be determined."""


class UpstreamBranchChangedError(SemanticReleaseBaseError):
"""Raised when the upstream branch has changed before pushing."""
96 changes: 96 additions & 0 deletions src/semantic_release/gitproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@
from semantic_release.cli.masking_filter import MaskingFilter
from semantic_release.cli.util import indented, noop_report
from semantic_release.errors import (
DetachedHeadGitError,
GitAddError,
GitCommitEmptyIndexError,
GitCommitError,
GitFetchError,
GitPushError,
GitTagError,
LocalGitError,
UnknownUpstreamBranchError,
UpstreamBranchChangedError,
)
from semantic_release.globals import logger

Expand Down Expand Up @@ -282,3 +287,94 @@ def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None:
except GitCommandError as err:
self.logger.exception(str(err))
raise GitPushError(f"Failed to push tag ({tag}) to remote") from err

def verify_upstream_unchanged(
self, local_ref: str = "HEAD", noop: bool = False
) -> None:
"""
Verify that the upstream branch has not changed since the given local reference.

:param local_ref: The local reference to compare against upstream (default: HEAD)
:param noop: Whether to skip the actual verification (for dry-run mode)

:raises UpstreamBranchChangedError: If the upstream branch has changed
"""
if noop:
noop_report(
indented(
"""\
would have verified that upstream branch has not changed
"""
)
)
return

with Repo(str(self.project_root)) as repo:
# Get the current active branch
try:
active_branch = repo.active_branch
except TypeError:
# When in detached HEAD state, active_branch raises TypeError
err_msg = (
"Repository is in detached HEAD state, cannot verify upstream state"
)
raise DetachedHeadGitError(err_msg) from None

# Get the tracking branch (upstream branch)
if (tracking_branch := active_branch.tracking_branch()) is None:
err_msg = f"No upstream branch found for '{active_branch.name}'; cannot verify upstream state!"
raise UnknownUpstreamBranchError(err_msg)

upstream_full_ref_name = tracking_branch.name
self.logger.info("Upstream branch name: %s", upstream_full_ref_name)

# Extract the remote name from the tracking branch
# tracking_branch.name is in the format "remote/branch"
remote_name, remote_branch_name = upstream_full_ref_name.split(
"/", maxsplit=1
)
remote_ref_obj = repo.remotes[remote_name]

# Fetch the latest changes from the remote
self.logger.info("Fetching latest changes from remote '%s'", remote_name)
try:
remote_ref_obj.fetch()
except GitCommandError as err:
self.logger.exception(str(err))
err_msg = f"Failed to fetch from remote '{remote_name}'"
raise GitFetchError(err_msg) from err

# Get the SHA of the upstream branch
try:
upstream_commit_ref = remote_ref_obj.refs[remote_branch_name].commit
upstream_sha = upstream_commit_ref.hexsha
except AttributeError as err:
self.logger.exception(str(err))
err_msg = f"Unable to determine upstream branch SHA for '{upstream_full_ref_name}'"
raise GitFetchError(err_msg) from err

# Get the SHA of the specified ref (default: HEAD)
try:
local_commit = repo.commit(repo.git.rev_parse(local_ref))
except GitCommandError as err:
self.logger.exception(str(err))
err_msg = f"Unable to determine the SHA for local ref '{local_ref}'"
raise LocalGitError(err_msg) from err

# Compare the two SHAs
if local_commit.hexsha != upstream_sha and not any(
commit.hexsha == upstream_sha for commit in local_commit.iter_parents()
):
err_msg = str.join(
"\n",
(
f"[LOCAL SHA] {local_commit.hexsha} != {upstream_sha} [UPSTREAM SHA].",
f"Upstream branch '{upstream_full_ref_name}' has changed!",
),
)
raise UpstreamBranchChangedError(err_msg)

self.logger.info(
"Verified upstream branch '%s' has not changed",
upstream_full_ref_name,
)
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_1_channel(
example_project_dir: ExProjectDir,
git_repo_for_directory: GetGitRepo4DirFn,
build_repo_from_definition: BuildRepoFromDefinitionFn,
mocked_git_fetch: MagicMock,
mocked_git_push: MagicMock,
post_mocker: Mocker,
version_py_file: Path,
Expand Down Expand Up @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_1_channel(
curr_release_tag = curr_version.as_tag()

# make sure mocks are clear
mocked_git_fetch.reset_mock()
mocked_git_push.reset_mock()
post_mocker.reset_mock()

Expand Down Expand Up @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_1_channel(
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_fetch.call_count == 1
) # fetch called to check for remote changes
assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag
assert post_mocker.call_count == 1 # vcs release creation occurred
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_2_channels(
example_project_dir: ExProjectDir,
git_repo_for_directory: GetGitRepo4DirFn,
build_repo_from_definition: BuildRepoFromDefinitionFn,
mocked_git_fetch: MagicMock,
mocked_git_push: MagicMock,
post_mocker: Mocker,
version_py_file: Path,
Expand Down Expand Up @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_2_channels(
curr_release_tag = curr_version.as_tag()

# make sure mocks are clear
mocked_git_fetch.reset_mock()
mocked_git_push.reset_mock()
post_mocker.reset_mock()

Expand Down Expand Up @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_2_channels(
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_fetch.call_count == 1
) # fetch called to check for remote changes
assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag
assert post_mocker.call_count == 1 # vcs release creation occurred
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def test_gitflow_repo_rebuild_3_channels(
example_project_dir: ExProjectDir,
git_repo_for_directory: GetGitRepo4DirFn,
build_repo_from_definition: BuildRepoFromDefinitionFn,
mocked_git_fetch: MagicMock,
mocked_git_push: MagicMock,
post_mocker: Mocker,
version_py_file: Path,
Expand Down Expand Up @@ -105,6 +106,7 @@ def test_gitflow_repo_rebuild_3_channels(
curr_release_tag = curr_version.as_tag()

# make sure mocks are clear
mocked_git_fetch.reset_mock()
mocked_git_push.reset_mock()
post_mocker.reset_mock()

Expand Down Expand Up @@ -162,5 +164,8 @@ def test_gitflow_repo_rebuild_3_channels(
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_fetch.call_count == 1
) # fetch called to check for remote changes
assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag
assert post_mocker.call_count == 1 # vcs release creation occurred
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_4_channels(
example_project_dir: ExProjectDir,
git_repo_for_directory: GetGitRepo4DirFn,
build_repo_from_definition: BuildRepoFromDefinitionFn,
mocked_git_fetch: MagicMock,
mocked_git_push: MagicMock,
post_mocker: Mocker,
version_py_file: Path,
Expand Down Expand Up @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_4_channels(
curr_release_tag = curr_version.as_tag()

# make sure mocks are clear
mocked_git_fetch.reset_mock()
mocked_git_push.reset_mock()
post_mocker.reset_mock()

Expand Down Expand Up @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_4_channels(
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_fetch.call_count == 1
) # fetch called to check for remote changes
assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag
assert post_mocker.call_count == 1 # vcs release creation occurred
Loading
Loading