Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Feat/verify upstream before push#1360

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Open
codejedi365 wants to merge8 commits intopython-semantic-release:master
base:master
Choose a base branch
Loading
fromcodejedi365:feat/verify-upstream-before-push
Open
Show file tree
Hide file tree
Changes fromall commits
Commits
Show all changes
8 commits
Select commitHold shift + click to select a range
98ebe5f
test(cmd-version): add e2e tests to verify upstream version check bef…
CopilotNov 3, 2025
5011c39
feat(cmd-version): adds upstream update check into workflow to preven…
CopilotNov 3, 2025
475bf23
test(git-project): adds unit tests that evaluate all error cases of v…
CopilotNov 3, 2025
38715bf
test(fixtures): add `git.fetch()` mock fixture & remote reference branch
codejedi365Nov 6, 2025
9b8dc6c
test(cmd-version): add mocking of git fetch to prevent errors from up…
codejedi365Nov 6, 2025
db25a27
docs(github-actions): removed verify upstream status step from exampl…
codejedi365Nov 3, 2025
aed31a6
docs(commands): add description of automated upstream version checkin…
codejedi365Nov 5, 2025
0c369c7
docs(uv-integration): remove verify upstream check from uv integratio…
codejedi365Nov 5, 2025
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
12 changes: 12 additions & 0 deletionsdocs/api/commands.rst
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsdocs/configuration/automatic-releases/github-actions.rst
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsrc/semantic_release/cli/commands/version.py
View file
Open in desktop
Original file line numberDiff line numberDiff 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 importGitCommandError,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 DownExpand 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 deletionssrc/semantic_release/errors.py
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssrc/semantic_release/gitproject.py
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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,
)
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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

[8]ページ先頭

©2009-2025 Movatter.jp