diff --git a/src/devx/ci/release.py b/src/devx/ci/release.py index 59ef14e..a864591 100644 --- a/src/devx/ci/release.py +++ b/src/devx/ci/release.py @@ -38,6 +38,7 @@ from __future__ import annotations import os import re import sys +import time import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] @@ -709,9 +710,39 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None: click.echo(_("Created release commit.")) # Pull --rebase before push to handle the case where master # advanced between checkout and commit (e.g., another merge). - run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False) - # Use refs/heads/master to avoid ambiguity with a 'master' tag - run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"]) + # Retry up to 3 times to handle concurrent pushes. + push_succeeded = False + for attempt in range(3): + rebase = run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False) + if rebase.returncode != 0: + # Rebase failed (likely conflicts). Abort and retry. + click.echo( + _( + "Rebase attempt {n}/3 failed: {err}", + n=attempt + 1, + err=rebase.stderr.strip() if rebase.stderr else rebase.stdout.strip(), + ) + ) + run_cmd(["git", "rebase", "--abort"], check=False) + # Brief delay before retry to let concurrent pushes settle. + time.sleep(5) + continue + push = run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"], check=False) + if push.returncode == 0: + push_succeeded = True + break + click.echo( + _( + "Push attempt {n}/3 failed: {err}", + n=attempt + 1, + err=push.stderr.strip() if push.stderr else push.stdout.strip(), + ) + ) + time.sleep(5) + if not push_succeeded: + raise click.ClickException( + _("Failed to push release commit after 3 attempts. Manual intervention required.") + ) click.echo(_("Pushed release commit to master.")) else: click.echo(_("Skipping commit push — no staged changes.")) diff --git a/src/devx/translations.json b/src/devx/translations.json index 4f2719b..93444e4 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -2886,5 +2886,29 @@ "pl": "{separator}", "ru": "{separator}", "zh": "{separator}" + }, + "Failed to push release commit after 3 attempts. Manual intervention required.": { + "bg": "Failed to push release commit after 3 attempts. Manual intervention required.", + "de": "Failed to push release commit after 3 attempts. Manual intervention required.", + "en": "Failed to push release commit after 3 attempts. Manual intervention required.", + "pl": "Failed to push release commit after 3 attempts. Manual intervention required.", + "ru": "Failed to push release commit after 3 attempts. Manual intervention required.", + "zh": "Failed to push release commit after 3 attempts. Manual intervention required." + }, + "Push attempt {n}/3 failed: {err}": { + "bg": "Push attempt {n}/3 failed: {err}", + "de": "Push attempt {n}/3 failed: {err}", + "en": "Push attempt {n}/3 failed: {err}", + "pl": "Push attempt {n}/3 failed: {err}", + "ru": "Push attempt {n}/3 failed: {err}", + "zh": "Push attempt {n}/3 failed: {err}" + }, + "Rebase attempt {n}/3 failed: {err}": { + "bg": "Rebase attempt {n}/3 failed: {err}", + "de": "Rebase attempt {n}/3 failed: {err}", + "en": "Rebase attempt {n}/3 failed: {err}", + "pl": "Rebase attempt {n}/3 failed: {err}", + "ru": "Rebase attempt {n}/3 failed: {err}", + "zh": "Rebase attempt {n}/3 failed: {err}" } } diff --git a/tests/unit/test_release.py b/tests/unit/test_release.py index ba975c6..9519824 100644 --- a/tests/unit/test_release.py +++ b/tests/unit/test_release.py @@ -1234,6 +1234,153 @@ class TestMain: assert "already existed" in result.output mock_tag.assert_called_once_with("0.2.0", "changelog", False) + @patch.dict("os.environ", {}) + @patch("devx.ci.release.verify_tag_consistency", return_value=[]) + @patch("devx.ci.release.fetch_tags") + @patch("devx.ci.release.has_user_facing_changes", return_value=True) + @patch("devx.ci.release.run_tests") + @patch("devx.ci.release.create_and_push_tag", return_value=True) + @patch("devx.ci.release.commit_release_changes", return_value=True) + @patch("devx.ci.release.update_changelog") + @patch("devx.ci.release.update_init_version") + @patch("devx.ci.release.get_changelog", return_value="changelog") + @patch("devx.ci.release.get_latest_tag", return_value="v0.1.0") + @patch("devx.ci.release.get_bumped_version", return_value="0.2.0") + @patch("devx.ci.release.has_unreleased_changes", return_value=True) + @patch("devx.ci.release.time.sleep") + @patch("devx.ci.release.run_cmd") + def test_push_retry_succeeds_after_rebase_failure( + self, + mock_run_cmd: MagicMock, + mock_sleep: MagicMock, + mock_has: MagicMock, + mock_bumped: MagicMock, + mock_latest: MagicMock, + mock_changelog: MagicMock, + mock_update_init: MagicMock, + mock_update_changelog: MagicMock, + mock_commit: MagicMock, + mock_tag: MagicMock, + mock_run_tests: MagicMock, + mock_user: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, + ) -> None: + """Push should retry after rebase failure and succeed on second attempt.""" + ok = MagicMock(returncode=0, stdout="master\n", stderr="") + rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict") + rebase_abort = MagicMock(returncode=0, stdout="", stderr="") + push_ok = MagicMock(returncode=0, stdout="", stderr="") + # git rev-parse → ok, git log -1 → ok (non-release msg) + # pull --rebase → fail, rebase --abort → ok + # pull --rebase → ok, push → ok + mock_run_cmd.side_effect = [ok, ok, rebase_fail, rebase_abort, ok, push_ok] + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert "Rebase attempt 1/3 failed" in result.output + assert "Pushed release commit to master" in result.output + + @patch.dict("os.environ", {}) + @patch("devx.ci.release.verify_tag_consistency", return_value=[]) + @patch("devx.ci.release.fetch_tags") + @patch("devx.ci.release.has_user_facing_changes", return_value=True) + @patch("devx.ci.release.run_tests") + @patch("devx.ci.release.create_and_push_tag", return_value=True) + @patch("devx.ci.release.commit_release_changes", return_value=True) + @patch("devx.ci.release.update_changelog") + @patch("devx.ci.release.update_init_version") + @patch("devx.ci.release.get_changelog", return_value="changelog") + @patch("devx.ci.release.get_latest_tag", return_value="v0.1.0") + @patch("devx.ci.release.get_bumped_version", return_value="0.2.0") + @patch("devx.ci.release.has_unreleased_changes", return_value=True) + @patch("devx.ci.release.time.sleep") + @patch("devx.ci.release.run_cmd") + def test_push_fails_after_all_retries( + self, + mock_run_cmd: MagicMock, + mock_sleep: MagicMock, + mock_has: MagicMock, + mock_bumped: MagicMock, + mock_latest: MagicMock, + mock_changelog: MagicMock, + mock_update_init: MagicMock, + mock_update_changelog: MagicMock, + mock_commit: MagicMock, + mock_tag: MagicMock, + mock_run_tests: MagicMock, + mock_user: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, + ) -> None: + """Push should fail after 3 unsuccessful rebase attempts.""" + ok = MagicMock(returncode=0, stdout="master\n", stderr="") + rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict") + rebase_abort = MagicMock(returncode=0, stdout="", stderr="") + # git rev-parse → ok, git log -1 → ok + # 3 attempts: pull --rebase → fail, rebase --abort → ok + mock_run_cmd.side_effect = [ + ok, + ok, + rebase_fail, + rebase_abort, # attempt 1 + rebase_fail, + rebase_abort, # attempt 2 + rebase_fail, + rebase_abort, # attempt 3 + ] + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code != 0 + assert "Failed to push release commit after 3 attempts" in result.output + + @patch.dict("os.environ", {}) + @patch("devx.ci.release.verify_tag_consistency", return_value=[]) + @patch("devx.ci.release.fetch_tags") + @patch("devx.ci.release.has_user_facing_changes", return_value=True) + @patch("devx.ci.release.run_tests") + @patch("devx.ci.release.create_and_push_tag", return_value=True) + @patch("devx.ci.release.commit_release_changes", return_value=True) + @patch("devx.ci.release.update_changelog") + @patch("devx.ci.release.update_init_version") + @patch("devx.ci.release.get_changelog", return_value="changelog") + @patch("devx.ci.release.get_latest_tag", return_value="v0.1.0") + @patch("devx.ci.release.get_bumped_version", return_value="0.2.0") + @patch("devx.ci.release.has_unreleased_changes", return_value=True) + @patch("devx.ci.release.time.sleep") + @patch("devx.ci.release.run_cmd") + def test_push_retry_succeeds_after_push_failure( + self, + mock_run_cmd: MagicMock, + mock_sleep: MagicMock, + mock_has: MagicMock, + mock_bumped: MagicMock, + mock_latest: MagicMock, + mock_changelog: MagicMock, + mock_update_init: MagicMock, + mock_update_changelog: MagicMock, + mock_commit: MagicMock, + mock_tag: MagicMock, + mock_run_tests: MagicMock, + mock_user: MagicMock, + mock_ft: MagicMock, + mock_vtc: MagicMock, + ) -> None: + """Push should retry after push rejection and succeed on second attempt.""" + ok = MagicMock(returncode=0, stdout="master\n", stderr="") + rebase_ok = MagicMock(returncode=0, stdout="", stderr="") + push_fail = MagicMock(returncode=1, stdout="", stderr="non-fast-forward") + push_ok = MagicMock(returncode=0, stdout="", stderr="") + # git rev-parse → ok, git log -1 → ok + # attempt 1: pull --rebase → ok, push → fail + # attempt 2: pull --rebase → ok, push → ok + mock_run_cmd.side_effect = [ok, ok, rebase_ok, push_fail, rebase_ok, push_ok] + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert "Push attempt 1/3 failed" in result.output + assert "Pushed release commit to master" in result.output + @patch.dict("os.environ", {}) @patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.fetch_tags")