chore(release): add dry-run to release tool prepare command (#3869)

To enable safe testing of the release pipeline, this change introduces a
--dry-run flag to the prepare command that simulates all repository and
GitHub actions.

This also fixes a few issues with the prepare command not fully working
end-to-end.

Along the way, factor out the code a bit. release.py was over 1000 lines
and
becoming unwieldly.
diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml
index 7d5e93a..b080f12 100644
--- a/.github/workflows/prepare_release.yml
+++ b/.github/workflows/prepare_release.yml
@@ -31,6 +31,6 @@
       - name: Run Release Preparation Pipeline
         run: |
           # Manual trigger: run full preparation
-          bazel run //tools/private/release -- prepare
+          bazel run //tools/private/release -- prepare --no-dry-run
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py
index 4ec7de5..534aa2a 100644
--- a/tests/tools/private/release/release_test.py
+++ b/tests/tools/private/release/release_test.py
@@ -5,19 +5,48 @@
 import unittest
 from unittest.mock import MagicMock, patch
 
-from tools.private.release import changelog_news, release as releaser
+from tools.private.release import changelog_news, release as releaser, utils
+from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError
 
 
-class ReleaserTest(unittest.TestCase):
+def _mock_git_and_gh(test_case):
+    mock_git = MagicMock()
+    mock_gh = MagicMock()
+    test_case.mock_git = mock_git
+    test_case.mock_gh = mock_gh
+
+    # Patch bindings in modules that import them at module level
+    patch("tools.private.release.release.git", new=mock_git).start()
+    patch("tools.private.release.prepare.git", new=mock_git).start()
+    patch("tools.private.release.utils.git", new=mock_git).start()
+
+    patch("tools.private.release.release.gh", new=mock_gh).start()
+    patch("tools.private.release.prepare.gh", new=mock_gh).start()
+    mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError
+    mock_gh.NoTrackingIssueError = NoTrackingIssueError
+
+    test_case.addCleanup(patch.stopall)
+
+    # Apply safe defaults
+    mock_git.get_current_branch.return_value = None
+    mock_git.get_tags.return_value = []
+    mock_git.get_tags_at_head.return_value = []
+    mock_git.status.return_value = ""
+    mock_git.branch_exists.return_value = False
+    mock_git.tag_exists.return_value = False
+    mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError("Not found")
+
+
+class TempDirTestCase(unittest.TestCase):
     def setUp(self):
         self.tmpdir = pathlib.Path(tempfile.mkdtemp())
         self.original_cwd = os.getcwd()
         self.addCleanup(shutil.rmtree, self.tmpdir)
-
         os.chdir(self.tmpdir)
-        # NOTE: On windows, this must be done before files are deleted.
         self.addCleanup(os.chdir, self.original_cwd)
 
+
+class ReleaserTest(TempDirTestCase):
     def test_update_changelog_with_news(self):
         # Arrange
         changelog = """# Changelog
@@ -431,7 +460,7 @@
 """
         (self.tmpdir / "mock_file.bzl").write_text(mock_file_content)
 
-        releaser.replace_version_next("0.28.0")
+        utils.replace_version_next("0.28.0")
 
         new_content = (self.tmpdir / "mock_file.bzl").read_text()
 
@@ -462,7 +491,7 @@
         version = "0.28.0"
 
         # Act
-        releaser.replace_version_next(version)
+        utils.replace_version_next(version)
 
         # Assert
         new_content = (bazel_dir / "mock_file.bzl").read_text()
@@ -490,56 +519,56 @@
 
 
 class GetLatestVersionTest(unittest.TestCase):
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_tags")
     def test_get_latest_version_success(self, mock_get_tags):
         mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"]
-        self.assertEqual(releaser.get_latest_version(), "1.0.0")
+        self.assertEqual(utils.get_latest_version(), "1.0.0")
 
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_tags")
     def test_get_latest_version_rc_is_latest(self, mock_get_tags):
         mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"]
         with self.assertRaisesRegex(
             ValueError, "The latest version is a pre-release version: 1.1.0rc0"
         ):
-            releaser.get_latest_version()
+            utils.get_latest_version()
 
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_tags")
     def test_get_latest_version_no_tags(self, mock_get_tags):
         mock_get_tags.return_value = []
         with self.assertRaisesRegex(
             RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format."
         ):
-            releaser.get_latest_version()
+            utils.get_latest_version()
 
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_tags")
     def test_get_latest_version_no_matching_tags(self, mock_get_tags):
         mock_get_tags.return_value = ["v1.0", "latest"]
         with self.assertRaisesRegex(
             RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format."
         ):
-            releaser.get_latest_version()
+            utils.get_latest_version()
 
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_tags")
     def test_get_latest_version_only_rc_tags(self, mock_get_tags):
         mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"]
         with self.assertRaisesRegex(
             ValueError, "The latest version is a pre-release version: 1.1.0rc0"
         ):
-            releaser.get_latest_version()
+            utils.get_latest_version()
 
 
 class GetLatestRcTagTest(unittest.TestCase):
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_tags")
     def test_get_latest_rc_tag_no_tags(self, mock_get_tags):
         mock_get_tags.return_value = []
-        self.assertIsNone(releaser.get_latest_rc_tag("2.0.0"))
+        self.assertIsNone(utils.get_latest_rc_tag("2.0.0"))
 
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_tags")
     def test_get_latest_rc_tag_no_matching_tags(self, mock_get_tags):
         mock_get_tags.return_value = ["1.0.0", "2.0.0", "v2.0.0-rc0", "2.1.0-rc0"]
-        self.assertIsNone(releaser.get_latest_rc_tag("2.0.0"))
+        self.assertIsNone(utils.get_latest_rc_tag("2.0.0"))
 
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_tags")
     def test_get_latest_rc_tag_success(self, mock_get_tags):
         mock_get_tags.return_value = [
             "2.0.0-rc0",
@@ -547,26 +576,19 @@
             "2.0.0-rc1",
             "2.1.0-rc0",
         ]
-        self.assertEqual(releaser.get_latest_rc_tag("2.0.0"), "2.0.0-rc2")
+        self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc2")
 
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_tags")
     def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags):
         mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"]
-        self.assertEqual(releaser.get_latest_rc_tag("2.0.0"), "2.0.0-rc1")
+        self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1")
 
 
-class DetermineNextVersionTest(unittest.TestCase):
+class DetermineNextVersionTest(TempDirTestCase):
     def setUp(self):
-        self.tmpdir = pathlib.Path(tempfile.mkdtemp())
-        self.original_cwd = os.getcwd()
-        self.addCleanup(shutil.rmtree, self.tmpdir)
-
-        os.chdir(self.tmpdir)
-        # NOTE: On windows, this must be done before files are deleted.
-        self.addCleanup(os.chdir, self.original_cwd)
-
+        super().setUp()
         self.mock_get_latest_version = patch(
-            "tools.private.release.release.get_latest_version"
+            "tools.private.release.utils.get_latest_version"
         ).start()
         self.addCleanup(patch.stopall)
 
@@ -574,7 +596,7 @@
         (self.tmpdir / "mock_file.bzl").write_text("no markers here")
         self.mock_get_latest_version.return_value = "1.2.3"
 
-        next_version = releaser.determine_next_version()
+        next_version = utils.determine_next_version()
 
         self.assertEqual(next_version, "1.2.4")
 
@@ -584,7 +606,7 @@
         )
         self.mock_get_latest_version.return_value = "1.2.3"
 
-        next_version = releaser.determine_next_version()
+        next_version = utils.determine_next_version()
 
         self.assertEqual(next_version, "1.2.4")
 
@@ -594,7 +616,7 @@
         )
         self.mock_get_latest_version.return_value = "1.2.3"
 
-        next_version = releaser.determine_next_version()
+        next_version = utils.determine_next_version()
 
         self.assertEqual(next_version, "1.3.0")
 
@@ -607,36 +629,36 @@
         )
         self.mock_get_latest_version.return_value = "1.2.3"
 
-        next_version = releaser.determine_next_version()
+        next_version = utils.determine_next_version()
 
         self.assertEqual(next_version, "1.3.0")
 
-    @patch("tools.private.release.release.git.get_current_branch")
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_current_branch")
+    @patch("tools.private.release.git.get_tags")
     def test_determine_next_version_on_release_branch_with_existing_tags(
         self, mock_get_tags, mock_get_branch
     ):
         mock_get_branch.return_value = "release/0.37"
         mock_get_tags.return_value = ["0.37.0", "0.37.1", "0.36.0"]
 
-        next_version = releaser.determine_next_version()
+        next_version = utils.determine_next_version()
 
         self.assertEqual(next_version, "0.37.2")
 
-    @patch("tools.private.release.release.git.get_current_branch")
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_current_branch")
+    @patch("tools.private.release.git.get_tags")
     def test_determine_next_version_on_release_branch_no_tags(
         self, mock_get_tags, mock_get_branch
     ):
         mock_get_branch.return_value = "release/0.38"
         mock_get_tags.return_value = ["0.37.0"]  # No 0.38.x tags
 
-        next_version = releaser.determine_next_version()
+        next_version = utils.determine_next_version()
 
         self.assertEqual(next_version, "0.38.0")
 
-    @patch("tools.private.release.release.git.get_current_branch")
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_current_branch")
+    @patch("tools.private.release.git.get_tags")
     def test_determine_next_version_on_release_branch_with_active_rc(
         self, mock_get_tags, mock_get_branch
     ):
@@ -644,13 +666,13 @@
         # 0.37.0-rc0 and rc1 exist, but no stable 0.37.0 yet
         mock_get_tags.return_value = ["0.37.0-rc0", "0.37.0-rc1", "0.36.0"]
 
-        next_version = releaser.determine_next_version()
+        next_version = utils.determine_next_version()
 
         # Should target 0.37.0, not 0.37.1
         self.assertEqual(next_version, "0.37.0")
 
-    @patch("tools.private.release.release.git.get_current_branch")
-    @patch("tools.private.release.release.git.get_tags")
+    @patch("tools.private.release.git.get_current_branch")
+    @patch("tools.private.release.git.get_tags")
     def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc(
         self, mock_get_tags, mock_get_branch
     ):
@@ -658,39 +680,36 @@
         # 0.37.0 stable exists, and 0.37.1-rc0 exists (but no stable 0.37.1 yet)
         mock_get_tags.return_value = ["0.37.0", "0.37.1-rc0", "0.36.0"]
 
-        next_version = releaser.determine_next_version()
+        next_version = utils.determine_next_version()
 
         # Should target 0.37.1, not 0.37.2
         self.assertEqual(next_version, "0.37.1")
 
-    @patch("tools.private.release.release.git.get_current_branch")
+    @patch("tools.private.release.git.get_current_branch")
     def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch):
         mock_get_branch.return_value = "main"
         # Should fallback to default behavior (which uses mock_get_latest_version from setUp)
         self.mock_get_latest_version.return_value = "1.2.3"
         (self.tmpdir / "mock_file.bzl").write_text("no markers here")
 
-        next_version = releaser.determine_next_version()
+        next_version = utils.determine_next_version()
 
         self.assertEqual(next_version, "1.2.4")
 
 
-class CmdPrepareTest(unittest.TestCase):
+class CmdPrepareTest(TempDirTestCase):
     def setUp(self):
-        self.mock_git = patch("tools.private.release.release.git").start()
-        self.mock_gh = patch("tools.private.release.release.gh").start()
-        self.addCleanup(patch.stopall)
+        super().setUp()
+        _mock_git_and_gh(self)
 
-    @patch("tools.private.release.release.pathlib.Path")
-    @patch("tools.private.release.release.changelog_news")
-    @patch("tools.private.release.release.replace_version_next")
-    def test_prepare_success_existing_issue(
-        self, mock_replace, mock_changelog, mock_path
-    ):
+    @patch("tools.private.release.prepare.changelog_news")
+    @patch("tools.private.release.prepare.replace_version_next")
+    def test_prepare_success_existing_issue(self, mock_replace, mock_changelog):
         # Arrange
-        args = MagicMock(version="2.0.0", issue=None)
+        args = MagicMock(version="2.0.0", issue=None, dry_run=False)
         self.mock_git.status.side_effect = ["", "M  foo"]
         self.mock_git.branch_exists.return_value = False
+        self.mock_gh.get_release_tracking_issue.side_effect = None
         self.mock_gh.get_release_tracking_issue.return_value = 123
         self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456"
         self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release"
@@ -702,28 +721,28 @@
         self.assertEqual(result, 0)
         self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0")
         self.mock_gh.create_tracking_issue.assert_not_called()
-        self.mock_gh.create_pr.assert_called_once_with("2.0.0", "prepare-2.0.0", 123)
+        self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123)
+        self.mock_git.add_modified_and_deleted.assert_called_once()
 
-    @patch("tools.private.release.release.pathlib.Path")
-    @patch("tools.private.release.release.changelog_news")
-    @patch("tools.private.release.release.replace_version_next")
-    def test_prepare_success_create_issue(
-        self, mock_replace, mock_changelog, mock_path
-    ):
+    @patch("tools.private.release.prepare.changelog_news")
+    @patch("tools.private.release.prepare.replace_version_next")
+    def test_prepare_success_create_issue(self, mock_replace, mock_changelog):
         # Arrange
-        args = MagicMock(version="2.0.0", issue=None)
+        template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE"
+        template_dir.mkdir(parents=True, exist_ok=True)
+        template_file = template_dir / "release_tracking_template.md"
+        template_file.write_text("dummy template content")
+
+        args = MagicMock(version="2.0.0", issue=None, dry_run=False)
         self.mock_git.status.side_effect = ["", "M  foo"]
         self.mock_git.branch_exists.return_value = False
-        self.mock_gh.get_release_tracking_issue.side_effect = ValueError("Not found")
+        self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError(
+            "Not found"
+        )
         self.mock_gh.create_tracking_issue.return_value = 123
         self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456"
         self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release"
 
-        mock_template = MagicMock()
-        mock_template.exists.return_value = True
-        mock_template.read_text.return_value = "template content"
-        mock_path.return_value = mock_template
-
         # Act
         result = releaser.cmd_prepare(args)
 
@@ -731,20 +750,20 @@
         self.assertEqual(result, 0)
         self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0")
         self.mock_gh.create_tracking_issue.assert_called_once_with(
-            "2.0.0", "template content"
+            "2.0.0", "dummy template content"
         )
-        self.mock_gh.create_pr.assert_called_once_with("2.0.0", "prepare-2.0.0", 123)
+        self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123)
+        self.mock_git.add_modified_and_deleted.assert_called_once()
 
-    @patch("tools.private.release.release.pathlib.Path")
-    @patch("tools.private.release.release.changelog_news")
-    @patch("tools.private.release.release.replace_version_next")
-    def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog, mock_path):
+    @patch("tools.private.release.prepare.changelog_news")
+    @patch("tools.private.release.prepare.replace_version_next")
+    def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog):
         # Arrange
-        args = MagicMock(version="2.0.0", issue=None)
+        args = MagicMock(version="2.0.0", issue=None, dry_run=False)
         self.mock_git.status.side_effect = ["", "M  foo"]
         self.mock_git.branch_exists.return_value = False
-        self.mock_gh.get_release_tracking_issue.side_effect = ValueError(
-            "Multiple open tracking issues"
+        self.mock_gh.get_release_tracking_issue.side_effect = (
+            MultipleTrackingIssuesError("Multiple open tracking issues")
         )
 
         # Act
@@ -755,13 +774,60 @@
         self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0")
         self.mock_gh.create_tracking_issue.assert_not_called()
         self.mock_gh.create_pr.assert_not_called()
+        self.mock_git.add_modified_and_deleted.assert_not_called()
+
+    @patch("tools.private.release.prepare.changelog_news")
+    @patch("tools.private.release.prepare.replace_version_next")
+    def test_prepare_dry_run(self, mock_replace, mock_changelog):
+        # Arrange
+        args = MagicMock(version="2.0.0", issue=None, dry_run=True)
+        self.mock_git.status.side_effect = [""]
+        self.mock_gh.get_release_tracking_issue.side_effect = None
+        self.mock_gh.get_release_tracking_issue.return_value = 123
+
+        # Act
+        result = releaser.cmd_prepare(args)
+
+        # Assert
+        self.assertEqual(result, 0)
+        self.mock_git.checkout.assert_not_called()
+        self.mock_git.commit.assert_not_called()
+        self.mock_git.push.assert_not_called()
+        self.mock_gh.create_pr.assert_not_called()
+        self.mock_gh.update_issue_body.assert_not_called()
+        self.mock_git.fetch.assert_called_once()
+        self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0")
+        self.mock_git.add_modified_and_deleted.assert_not_called()
+
+    @patch("tools.private.release.prepare.changelog_news")
+    @patch("tools.private.release.prepare.replace_version_next")
+    def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog):
+        # Arrange
+        template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE"
+        template_dir.mkdir(parents=True, exist_ok=True)
+        template_file = template_dir / "release_tracking_template.md"
+        template_file.write_text("dummy template content")
+
+        args = MagicMock(version="2.0.0", issue=None, dry_run=True)
+        self.mock_git.status.side_effect = [""]
+        self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError(
+            "Not found"
+        )
+
+        # Act
+        result = releaser.cmd_prepare(args)
+
+        # Assert
+        self.assertEqual(result, 0)
+        self.mock_git.checkout.assert_not_called()
+        self.mock_gh.create_tracking_issue.assert_not_called()
+        self.mock_gh.create_pr.assert_not_called()
+        self.mock_git.add_modified_and_deleted.assert_not_called()
 
 
 class CmdCreateRcTest(unittest.TestCase):
     def setUp(self):
-        self.mock_git = patch("tools.private.release.release.git").start()
-        self.mock_gh = patch("tools.private.release.release.gh").start()
-        self.addCleanup(patch.stopall)
+        _mock_git_and_gh(self)
 
     def test_create_rc_success_first_rc(self):
         # Arrange
@@ -848,9 +914,7 @@
 
 class CmdPromoteRcTest(unittest.TestCase):
     def setUp(self):
-        self.mock_git = patch("tools.private.release.release.git").start()
-        self.mock_gh = patch("tools.private.release.release.gh").start()
-        self.addCleanup(patch.stopall)
+        _mock_git_and_gh(self)
 
     def test_promote_rc_success(self):
         # Arrange
@@ -893,6 +957,7 @@
         args = MagicMock(version="2.0.0", issue=None, dry_run=False)
         self.mock_git.get_tags.return_value = ["2.0.0-rc1"]
         self.mock_git.tag_exists.return_value = False
+        self.mock_gh.get_release_tracking_issue.side_effect = None
         self.mock_gh.get_release_tracking_issue.return_value = 123
         self.mock_git.get_commit_sha.return_value = "abcdef123456"
         initial_body = "- [ ] Tag Final"
@@ -1004,7 +1069,9 @@
         args = MagicMock(version="2.0.0", issue=None)
         self.mock_git.get_tags.return_value = ["2.0.0-rc1"]
         self.mock_git.tag_exists.return_value = False
-        self.mock_gh.get_release_tracking_issue.side_effect = ValueError("Not found")
+        self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError(
+            "Not found"
+        )
 
         # Act
         result = releaser.cmd_promote_rc(args)
diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel
index 747cb74..96f8641 100644
--- a/tools/private/release/BUILD.bazel
+++ b/tools/private/release/BUILD.bazel
@@ -12,7 +12,10 @@
     srcs = [
         "gh.py",
         "git.py",
+        "prepare.py",
         "release.py",
+        "release_issue.py",
+        "shell.py",
         "utils.py",
     ],
     main = "release.py",
diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py
index 09a0d3a..f37d87f 100644
--- a/tools/private/release/gh.py
+++ b/tools/private/release/gh.py
@@ -4,12 +4,24 @@
 import os
 import tempfile
 
-from tools.private.release.utils import run_cmd
+from tools.private.release.shell import run_cmd
 
 _REPO = "bazel-contrib/rules_python"
 _LABEL = "type: release"
 
 
+class MultipleTrackingIssuesError(ValueError):
+    """Raised when multiple open tracking issues are found for a version."""
+
+    pass
+
+
+class NoTrackingIssueError(ValueError):
+    """Raised when no open tracking issue is found for a version."""
+
+    pass
+
+
 def list_issues(*, fields, label=None, state=None, search=None):
     """Helper to list issues using gh CLI."""
     cmd = ["gh", "issue", "list", f"--repo={_REPO}"]
@@ -50,13 +62,13 @@
             exact_matches.append(issue)
 
     if not exact_matches:
-        raise ValueError(
+        raise NoTrackingIssueError(
             f"No open tracking issue found matching 'Release {version}' "
             f"in repo {_REPO} with label '{_LABEL}'"
         )
     if len(exact_matches) > 1:
         urls = [issue["url"] for issue in exact_matches]
-        raise ValueError(
+        raise MultipleTrackingIssuesError(
             f"Multiple open tracking issues found for version {version} "
             f"in repo {_REPO} with label '{_LABEL}':\n" + "\n".join(urls)
         )
@@ -138,7 +150,7 @@
             os.unlink(temp_path)
 
 
-def create_pr(version, branch, issue_num):
+def create_pr(version, issue_num):
     """Creates a pull request for release preparation."""
     return run_cmd(
         "gh",
@@ -146,9 +158,7 @@
         "create",
         f"--title=Prepare release v{version}",
         f"--body=Work towards #{issue_num}",
-        f"--head={branch}",
         "--base=main",
-        "--label=release-prepared",
     )
 
 
diff --git a/tools/private/release/git.py b/tools/private/release/git.py
index 4e623e2..9c2662d 100644
--- a/tools/private/release/git.py
+++ b/tools/private/release/git.py
@@ -2,7 +2,7 @@
 
 import subprocess
 
-from tools.private.release.utils import run_cmd
+from tools.private.release.shell import run_cmd
 
 
 def get_tags():
@@ -24,6 +24,11 @@
     run_cmd("git", "add", *files, capture_output=False)
 
 
+def add_modified_and_deleted():
+    """Stages all modified and deleted tracked files."""
+    run_cmd("git", "add", "--update", capture_output=False)
+
+
 def commit(message, amend=False, no_edit=False):
     """Commits staged changes, optionally amending the previous commit."""
     cmd = ["git", "commit"]
@@ -36,9 +41,13 @@
     run_cmd(*cmd, capture_output=False)
 
 
-def push(remote, ref):
+def push(remote, ref, set_upstream=False):
     """Pushes a reference to a remote repository."""
-    run_cmd("git", "push", remote, ref, capture_output=False)
+    cmd = ["git", "push"]
+    if set_upstream:
+        cmd.append("-u")
+    cmd.extend([remote, ref])
+    run_cmd(*cmd, capture_output=False)
 
 
 def fetch(remote="origin", tags=False, force=False):
diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py
new file mode 100644
index 0000000..1d946bd
--- /dev/null
+++ b/tools/private/release/prepare.py
@@ -0,0 +1,144 @@
+import datetime
+import pathlib
+
+from tools.private.release import changelog_news, gh, git
+from tools.private.release.release_issue import update_task_in_body
+from tools.private.release.utils import (
+    determine_next_version,
+    replace_version_next,
+)
+
+
+def cmd_prepare(args):
+    """Executes the prepare subcommand."""
+    print("Fetching upstream to verify fresh release history...")
+    git.fetch(tags=True, force=True)
+
+    # Run pre-check: verify there are no local edits
+    status = git.status()
+    if status:
+        print(
+            "Error: Local edits detected. Workspace must be completely clean"
+            " before running release preparation."
+        )
+        for line in status.splitlines():
+            print(f"  {line}")
+        return 1
+    print("Pre-check passed: Workspace is clean.")
+
+    version = args.version
+    if version is None:
+        version = determine_next_version()
+
+    print(f"Running preparation pipeline for {version}...")
+
+    # 1. Find or create tracking issue (EARLY)
+    # We do this before any write operations (branch creation, commit, push)
+    issue_num = args.issue
+
+    if not issue_num:
+        try:
+            issue_num = gh.get_release_tracking_issue(version)
+            print(f"Tracking issue: #{issue_num}")
+        except gh.MultipleTrackingIssuesError as e:
+            print(f"Error: {e}")
+            return 1
+        except gh.NoTrackingIssueError:
+            # Not found, we need the template
+            template_path = pathlib.Path(
+                ".github/ISSUE_TEMPLATE/release_tracking_template.md"
+            )
+            if not template_path.exists():
+                raise FileNotFoundError(f"Template file not found at {template_path}")
+            template_content = template_path.read_text(encoding="utf-8")
+
+            if args.dry_run:
+                print(
+                    f"[DRY RUN] No active tracking issue found for {version}. Would create a new one."
+                )
+                print(f"[DRY RUN] Title: Release {version}\n{template_content}")
+                issue_num = None  # Keep it None for dry-run prints later
+            else:
+                print(
+                    f"No active tracking issue found for {version}. Creating a new one..."
+                )
+                issue_num = gh.create_tracking_issue(version, template_content)
+                print(f"Tracking issue: #{issue_num}")
+    else:
+        print(f"Tracking issue: #{issue_num}")
+
+    branch_name = f"prepare-{version}"
+
+    # 2. Interleaved git and write operations
+
+    # --- Branch selection/creation ---
+    if git.branch_exists(branch_name):
+        if args.dry_run:
+            print(
+                f"[DRY RUN] Branch {branch_name} already exists. Would checkout existing branch."
+            )
+        else:
+            print(f"Branch {branch_name} already exists. Checking it out...")
+            git.checkout(branch_name)
+    else:
+        if args.dry_run:
+            print(f"[DRY RUN] Would create and checkout branch {branch_name}")
+        else:
+            git.checkout(branch_name, create_branch=True)
+
+    # --- Update files ---
+    if args.dry_run:
+        print(
+            f"[DRY RUN] Would update CHANGELOG.md and version placeholders for {version}"
+        )
+    else:
+        print("Updating changelog and placeholders...")
+        release_date = datetime.date.today().strftime("%Y-%m-%d")
+        changelog_news.update_changelog(version, release_date)
+        replace_version_next(version)
+
+    # --- Commit and Push ---
+    if args.dry_run:
+        print(f"[DRY RUN] Would push branch {branch_name} to origin")
+    else:
+        modified_files = git.status()
+        if not modified_files:
+            print("No files modified by the release tool. Nothing to commit.")
+            return 0
+
+        # Stage all modified and deleted tracked files
+        git.add_modified_and_deleted()
+
+        git.commit(f"Prepare release {version}")
+        git.push("origin", branch_name, set_upstream=True)
+
+    # --- Create PR ---
+    if args.dry_run:
+        target_issue = f"#{issue_num}" if issue_num else "<NEW_ISSUE>"
+        print(
+            f"[DRY RUN] Would create Pull Request for branch {branch_name} targeting issue {target_issue}"
+        )
+    else:
+        pr_url = gh.create_pr(version, issue_num)
+        pr_num = pr_url.split("/")[-1]
+        print(f"Created Pull Request: {pr_url} (PR #{pr_num})")
+
+    # --- Update checklist ---
+    if args.dry_run:
+        target_issue = f"#{issue_num}" if issue_num else "<NEW_ISSUE>"
+        print(
+            f"[DRY RUN] Would update tracking issue {target_issue} checklist 'Prepare Release' task status to PENDING"
+        )
+    else:
+        print(
+            f"Updating tracking issue #{issue_num} checklist 'Prepare Release' task status to PENDING..."
+        )
+        body = gh.get_issue_body(issue_num)
+        metadata = {"status": "pending", "pr": f"#{pr_num}"}
+        updated_body = update_task_in_body(
+            body, "Prepare Release", checked=False, metadata=metadata
+        )
+        gh.update_issue_body(issue_num, updated_body)
+        print("Preparation pipeline completed successfully!")
+
+    return 0
diff --git a/tools/private/release/release.py b/tools/private/release/release.py
index 3c2eed8..d9a225d 100644
--- a/tools/private/release/release.py
+++ b/tools/private/release/release.py
@@ -2,167 +2,26 @@
 
 import argparse
 import datetime
-import fnmatch
 import os
 import pathlib
 import re
 import sys
 
-from packaging.version import parse as parse_version
-
 from tools.private.release import changelog_news, gh, git
-
-_REPO_URL = "https://github.com/bazel-contrib/rules_python"
-
-_EXCLUDE_PATTERNS = [
-    "./.git/*",
-    "./.github/*",
-    "./.bazelci/*",
-    "./.bcr/*",
-    "./bazel-*/*",
-    "./CONTRIBUTING.md",
-    "./RELEASING.md",
-    "./tools/private/release/*",
-    "./tests/tools/private/release/*",
-]
+from tools.private.release.prepare import cmd_prepare
+from tools.private.release.release_issue import (
+    parse_metadata_line,
+    update_task_in_body,
+)
+from tools.private.release.utils import (
+    _REPO_URL,
+    determine_next_version,
+    get_latest_rc_tag,
+)
 
 _RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE)
 
 
-def _iter_version_placeholder_files():
-    for root, dirs, files in os.walk(".", topdown=True):
-        # Filter directories
-        dirs[:] = [
-            d
-            for d in dirs
-            if not any(
-                fnmatch.fnmatch(os.path.join(root, d), pattern)
-                for pattern in _EXCLUDE_PATTERNS
-            )
-        ]
-
-        for filename in files:
-            filepath = os.path.join(root, filename)
-            if any(fnmatch.fnmatch(filepath, pattern) for pattern in _EXCLUDE_PATTERNS):
-                continue
-
-            yield filepath
-
-
-def get_latest_version():
-    """Gets the latest version from git tags."""
-    tags = git.get_tags()
-    versions = [
-        (tag, parse_version(tag))
-        for tag in tags
-        if re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", tag.strip())
-    ]
-    if not versions:
-        raise RuntimeError("No git tags found matching X.Y.Z or X.Y.ZrcN format.")
-
-    versions.sort(key=lambda v: v[1])
-    latest_tag, latest_version = versions[-1]
-
-    if latest_version.is_prerelease:
-        raise ValueError(f"The latest version is a pre-release version: {latest_tag}")
-
-    stable_versions = [tag for tag, version in versions if not version.is_prerelease]
-    if not stable_versions:
-        raise ValueError("No stable git tags found matching X.Y.Z format.")
-
-    return stable_versions[-1]
-
-
-def get_latest_rc_tag(version):
-    """Queries git tags and returns the highest RC tag for the version."""
-    tags = git.get_tags()
-    pattern = rf"^{re.escape(version)}-rc\d+$"
-    rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())]
-    if not rc_tags:
-        return None
-    rc_tags.sort(key=parse_version)
-    return rc_tags[-1]
-
-
-def should_increment_minor():
-    """Checks if the minor version should be incremented."""
-    for filepath in _iter_version_placeholder_files():
-        try:
-            with open(filepath, "r") as f:
-                content = f.read()
-        except (IOError, UnicodeDecodeError):
-            continue
-
-        if "VERSION_NEXT_FEATURE" in content:
-            return True
-    return False
-
-
-def determine_next_version(branch_name=None):
-    """Determines the next version based on git tags and the current branch."""
-    if branch_name is None:
-        branch_name = git.get_current_branch()
-
-    if branch_name:
-        release_match = re.match(r"^release/(\d+)\.(\d+)$", branch_name)
-        if release_match:
-            branch_major = int(release_match.group(1))
-            branch_minor = int(release_match.group(2))
-            print(
-                f"Detected release branch: {branch_name} (targeting"
-                f" {branch_major}.{branch_minor}.x)"
-            )
-
-            tags = git.get_tags()
-            matching_patches = []
-            for tag in tags:
-                tag = tag.strip()
-                m = re.match(rf"^{branch_major}\.{branch_minor}\.(\d+)$", tag)
-                if m:
-                    matching_patches.append(int(m.group(1)))
-
-            if matching_patches:
-                latest_patch = max(matching_patches)
-                next_version = f"{branch_major}.{branch_minor}.{latest_patch + 1}"
-                print(
-                    f"Latest tag on this branch is"
-                    f" {branch_major}.{branch_minor}.{latest_patch}. Next"
-                    f" version: {next_version}"
-                )
-                return next_version
-            else:
-                next_version = f"{branch_major}.{branch_minor}.0"
-                print(
-                    f"No stable tags found for {branch_major}.{branch_minor}.x."
-                    f" Next version: {next_version}"
-                )
-                return next_version
-
-    latest_version = get_latest_version()
-    major, minor, patch = [int(n) for n in latest_version.split(".")]
-
-    if should_increment_minor():
-        return f"{major}.{minor + 1}.0"
-    else:
-        return f"{major}.{minor}.{patch + 1}"
-
-
-def replace_version_next(version):
-    """Replaces all VERSION_NEXT_* placeholders with the new version."""
-    for filepath in _iter_version_placeholder_files():
-        try:
-            with open(filepath, "r") as f:
-                content = f.read()
-        except (IOError, UnicodeDecodeError):
-            continue
-
-        if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content:
-            new_content = content.replace("VERSION_NEXT_FEATURE", version)
-            new_content = new_content.replace("VERSION_NEXT_PATCH", version)
-            with open(filepath, "w") as f:
-                f.write(new_content)
-
-
 def _semver_type(value):
     if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value):
         raise argparse.ArgumentTypeError(
@@ -176,65 +35,6 @@
 # ==============================================================================
 
 
-def parse_metadata_line(line):
-    """Parses a checklist line with optional | key=value metadata."""
-    match = re.match(r"^\s*-\s*\[([ xX])\]\s+([^|]+)(?:\s*\|\s*(.*))?$", line)
-    if not match:
-        return None
-
-    checked = match.group(1).lower() == "x"
-    name = match.group(2).strip()
-    metadata_str = match.group(3)
-
-    metadata = {}
-    if metadata_str:
-        pairs = metadata_str.strip().split()
-        for pair in pairs:
-            if "=" in pair:
-                k, v = pair.split("=", 1)
-                metadata[k] = v
-
-    return {
-        "checked": checked,
-        "name": name,
-        "metadata": metadata,
-        "original_line": line,
-    }
-
-
-def format_metadata_line(checked, name, metadata):
-    """Formats a checklist line with space-separated key=value metadata."""
-    check_str = "x" if checked else " "
-    if not metadata:
-        return f"- [{check_str}] {name}"
-
-    metadata_str = " ".join(f"{k}={v}" for k, v in metadata.items())
-    return f"- [{check_str}] {name} | {metadata_str}"
-
-
-def update_task_in_body(body, task_name, checked, metadata):
-    """Updates a specific task's checked state and metadata in the issue body."""
-    lines = body.splitlines()
-    updated_lines = []
-    found = False
-
-    for line in lines:
-        parsed = parse_metadata_line(line)
-        if parsed and parsed["name"].lower() == task_name.lower():
-            updated_lines.append(format_metadata_line(checked, task_name, metadata))
-            found = True
-        else:
-            updated_lines.append(line)
-
-    if not found:
-        raise ValueError(
-            f"Task '{task_name}' not found in issue body. "
-            f"Expected format: '- [ ] {task_name}' or '- [x] {task_name}' (optionally followed by '| key=value')"
-        )
-
-    return "\n".join(updated_lines)
-
-
 def parse_checklist_state(body):
     """Parses the main checklist tasks and their metadata."""
     state = {
@@ -366,91 +166,6 @@
     return 0
 
 
-def cmd_prepare(args):
-    """Executes the prepare subcommand."""
-    print("Fetching upstream to verify fresh release history...")
-    git.fetch(tags=True, force=True)
-
-    # Run pre-check: verify there are no local edits
-    status = git.status()
-    if status:
-        print(
-            "Error: Local edits detected. Workspace must be completely clean"
-            " before running release preparation."
-        )
-        for line in status.splitlines():
-            print(f"  {line}")
-        return 1
-    print("Pre-check passed: Workspace is clean.")
-
-    version = args.version
-    if version is None:
-        version = determine_next_version()
-
-    print(f"Running preparation pipeline for v{version}...")
-
-    branch_name = f"prepare-{version}"
-    if git.branch_exists(branch_name):
-        print(f"Branch {branch_name} already exists. Checking it out...")
-        git.checkout(branch_name)
-    else:
-        git.checkout(branch_name, create_branch=True)
-
-    print("Updating changelog and placeholders...")
-    release_date = datetime.date.today().strftime("%Y-%m-%d")
-    changelog_news.update_changelog(version, release_date)
-    replace_version_next(version)
-
-    modified_files = git.status()
-    if not modified_files:
-        print("No files modified by the release tool. Nothing to commit.")
-        return 0
-
-    # Stage only modified files
-    for line in modified_files.splitlines():
-        file_path = line.strip().split()[-1]
-        git.add(file_path)
-
-    git.commit(f"Prepare release {version}")
-    git.push("origin", branch_name)
-
-    issue_num = args.issue
-    if not issue_num:
-        try:
-            issue_num = gh.get_release_tracking_issue(version)
-            print(f"Found active tracking issue #{issue_num} for v{version}")
-        except ValueError as e:
-            if "Multiple open tracking issues" in str(e):
-                print(f"Error: {e}")
-                return 1
-            print(
-                f"No active tracking issue found for v{version}. Creating a new one..."
-            )
-            template_path = pathlib.Path(
-                ".github/ISSUE_TEMPLATE/release_tracking_template.md"
-            )
-            if not template_path.exists():
-                raise FileNotFoundError(f"Template file not found at {template_path}")
-            template_content = template_path.read_text(encoding="utf-8")
-            issue_num = gh.create_tracking_issue(version, template_content)
-
-    print(f"Using tracking issue #{issue_num}")
-
-    pr_url = gh.create_pr(version, branch_name, issue_num)
-    pr_num = pr_url.split("/")[-1]
-    print(f"Created Pull Request: {pr_url} (PR #{pr_num})")
-
-    print(f"Updating tracking issue #{issue_num} checklist status to PENDING...")
-    body = gh.get_issue_body(issue_num)
-    metadata = {"status": "pending", "pr": f"#{pr_num}"}
-    updated_body = update_task_in_body(
-        body, "Prepare Release", checked=False, metadata=metadata
-    )
-    gh.update_issue_body(issue_num, updated_body)
-    print("Preparation pipeline completed successfully!")
-    return 0
-
-
 def cmd_complete_prepare(args):
     """Executes the complete-prepare subcommand (Phase 2 PR merged)."""
     print(f"Completing preparation for PR #{args.pr}...")
@@ -896,6 +611,12 @@
         type=int,
         help="The tracking issue number (optional, triggers automated branch/PR pipeline).",
     )
+    prepare_parser.add_argument(
+        "--dry-run",
+        action=argparse.BooleanOptionalAction,
+        default=True,
+        help="Perform a dry run (default: True). Use --no-dry-run to actually execute.",
+    )
 
     # Subcommand: complete-prepare
     complete_prep_parser = subparsers.add_parser(
@@ -998,6 +719,9 @@
             exit_code = cmd_promote_rc(args)
     except Exception as e:
         print(f"Fatal error executing {args.command}: {e}", file=sys.stderr)
+        if hasattr(e, "__notes__"):
+            for note in e.__notes__:
+                print(note, file=sys.stderr)
         sys.exit(1)
 
     sys.exit(exit_code if exit_code is not None else 0)
diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py
new file mode 100644
index 0000000..27bcf3d
--- /dev/null
+++ b/tools/private/release/release_issue.py
@@ -0,0 +1,62 @@
+"""Helper functions for managing release tracking issues and checklists."""
+
+import re
+
+
+def parse_metadata_line(line):
+    """Parses a checklist line with optional | key=value metadata."""
+    match = re.match(r"^\s*-\s*\[([ xX])\]\s+([^|]+)(?:\s*\|\s*(.*))?$", line)
+    if not match:
+        return None
+
+    checked = match.group(1).lower() == "x"
+    name = match.group(2).strip()
+    metadata_str = match.group(3)
+
+    metadata = {}
+    if metadata_str:
+        pairs = metadata_str.strip().split()
+        for pair in pairs:
+            if "=" in pair:
+                k, v = pair.split("=", 1)
+                metadata[k] = v
+
+    return {
+        "checked": checked,
+        "name": name,
+        "metadata": metadata,
+        "original_line": line,
+    }
+
+
+def format_metadata_line(checked, name, metadata):
+    """Formats a checklist line with space-separated key=value metadata."""
+    check_str = "x" if checked else " "
+    if not metadata:
+        return f"- [{check_str}] {name}"
+
+    metadata_str = " ".join(f"{k}={v}" for k, v in metadata.items())
+    return f"- [{check_str}] {name} | {metadata_str}"
+
+
+def update_task_in_body(body, task_name, checked, metadata):
+    """Updates a specific task's checked state and metadata in the issue body."""
+    lines = body.splitlines()
+    updated_lines = []
+    found = False
+
+    for line in lines:
+        parsed = parse_metadata_line(line)
+        if parsed and parsed["name"].lower() == task_name.lower():
+            updated_lines.append(format_metadata_line(checked, task_name, metadata))
+            found = True
+        else:
+            updated_lines.append(line)
+
+    if not found:
+        raise ValueError(
+            f"Task '{task_name}' not found in issue body. "
+            f"Expected format: '- [ ] {task_name}' or '- [x] {task_name}' (optionally followed by '| key=value')"
+        )
+
+    return "\n".join(updated_lines)
diff --git a/tools/private/release/shell.py b/tools/private/release/shell.py
new file mode 100644
index 0000000..cfff53f
--- /dev/null
+++ b/tools/private/release/shell.py
@@ -0,0 +1,29 @@
+"""Shell utility functions for the release tool."""
+
+import shlex
+import subprocess
+
+
+def run_cmd(*args, check=True, capture_output=True):
+    """Runs a command as a subprocess with separate arguments (prints command).
+
+    If the command fails, it raises the CalledProcessError after attaching
+    a detailed note explaining the failure to preserve the stack trace.
+    """
+    cmd = [str(arg) for arg in args]
+    print(f"Running: {shlex.join(cmd)}")
+    try:
+        result = subprocess.run(
+            cmd,
+            check=check,
+            stdout=subprocess.PIPE if capture_output else None,
+            stderr=subprocess.PIPE if capture_output else None,
+            universal_newlines=True,
+        )
+        return result.stdout.strip() if capture_output else None
+    except subprocess.CalledProcessError as e:
+        note = f"Error running command: {shlex.join(cmd)}"
+        if capture_output:
+            note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}"
+        e.add_note(note)
+        raise
diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py
index 5eb7c43..83ecbb9 100644
--- a/tools/private/release/utils.py
+++ b/tools/private/release/utils.py
@@ -1,29 +1,157 @@
 """Utility functions for the release tool."""
 
-import shlex
-import subprocess
+import fnmatch
+import os
+import re
+
+from packaging.version import parse as parse_version
+
+from tools.private.release import git
+
+_REPO_URL = "https://github.com/bazel-contrib/rules_python"
+
+_EXCLUDE_PATTERNS = [
+    "./.git/*",
+    "./.github/*",
+    "./.bazelci/*",
+    "./.bcr/*",
+    "./bazel-*/*",
+    "./CONTRIBUTING.md",
+    "./RELEASING.md",
+    "./tools/private/release/*",
+    "./tests/tools/private/release/*",
+]
 
 
-def run_cmd(*args, check=True, capture_output=True):
-    """Runs a command as a subprocess with separate arguments (prints command).
+def _iter_version_placeholder_files():
+    for root, dirs, files in os.walk(".", topdown=True):
+        # Filter directories
+        dirs[:] = [
+            d
+            for d in dirs
+            if not any(
+                fnmatch.fnmatch(os.path.join(root, d), pattern)
+                for pattern in _EXCLUDE_PATTERNS
+            )
+        ]
 
-    If the command fails, it raises the CalledProcessError after attaching
-    a detailed note explaining the failure to preserve the stack trace.
-    """
-    cmd = [str(arg) for arg in args]
-    print(f"Running: {shlex.join(cmd)}")
-    try:
-        result = subprocess.run(
-            cmd,
-            check=check,
-            stdout=subprocess.PIPE if capture_output else None,
-            stderr=subprocess.PIPE if capture_output else None,
-            universal_newlines=True,
-        )
-        return result.stdout.strip() if capture_output else None
-    except subprocess.CalledProcessError as e:
-        note = f"Error running command: {shlex.join(cmd)}"
-        if capture_output:
-            note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}"
-        e.add_note(note)
-        raise
+        for filename in files:
+            filepath = os.path.join(root, filename)
+            if any(fnmatch.fnmatch(filepath, pattern) for pattern in _EXCLUDE_PATTERNS):
+                continue
+
+            yield filepath
+
+
+def get_latest_version():
+    """Gets the latest version from git tags."""
+    tags = git.get_tags()
+    versions = [
+        (tag, parse_version(tag))
+        for tag in tags
+        if re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", tag.strip())
+    ]
+    if not versions:
+        raise RuntimeError("No git tags found matching X.Y.Z or X.Y.ZrcN format.")
+
+    versions.sort(key=lambda v: v[1])
+    latest_tag, latest_version = versions[-1]
+
+    if latest_version.is_prerelease:
+        raise ValueError(f"The latest version is a pre-release version: {latest_tag}")
+
+    stable_versions = [tag for tag, version in versions if not version.is_prerelease]
+    if not stable_versions:
+        raise ValueError("No stable git tags found matching X.Y.Z format.")
+
+    return stable_versions[-1]
+
+
+def get_latest_rc_tag(version):
+    """Queries git tags and returns the highest RC tag for the version."""
+    tags = git.get_tags()
+    pattern = rf"^{re.escape(version)}-rc\d+$"
+    rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())]
+    if not rc_tags:
+        return None
+    rc_tags.sort(key=parse_version)
+    return rc_tags[-1]
+
+
+def should_increment_minor():
+    """Checks if the minor version should be incremented."""
+    for filepath in _iter_version_placeholder_files():
+        try:
+            with open(filepath, "r") as f:
+                content = f.read()
+        except (IOError, UnicodeDecodeError):
+            continue
+
+        if "VERSION_NEXT_FEATURE" in content:
+            return True
+    return False
+
+
+def determine_next_version(branch_name=None):
+    """Determines the next version based on git tags and the current branch."""
+    if branch_name is None:
+        branch_name = git.get_current_branch()
+
+    if branch_name:
+        release_match = re.match(r"^release/(\d+)\.(\d+)$", branch_name)
+        if release_match:
+            branch_major = int(release_match.group(1))
+            branch_minor = int(release_match.group(2))
+            print(
+                f"Detected release branch: {branch_name} (targeting"
+                f" {branch_major}.{branch_minor}.x)"
+            )
+
+            tags = git.get_tags()
+            matching_patches = []
+            for tag in tags:
+                tag = tag.strip()
+                m = re.match(rf"^{branch_major}\.{branch_minor}\.(\d+)$", tag)
+                if m:
+                    matching_patches.append(int(m.group(1)))
+
+            if matching_patches:
+                latest_patch = max(matching_patches)
+                next_version = f"{branch_major}.{branch_minor}.{latest_patch + 1}"
+                print(
+                    f"Latest tag on this branch is"
+                    f" {branch_major}.{branch_minor}.{latest_patch}. Next"
+                    f" version: {next_version}"
+                )
+                return next_version
+            else:
+                next_version = f"{branch_major}.{branch_minor}.0"
+                print(
+                    f"No stable tags found for {branch_major}.{branch_minor}.x."
+                    f" Next version: {next_version}"
+                )
+                return next_version
+
+    latest_version = get_latest_version()
+    major, minor, patch = [int(n) for n in latest_version.split(".")]
+
+    if should_increment_minor():
+        return f"{major}.{minor + 1}.0"
+    else:
+        return f"{major}.{minor}.{patch + 1}"
+
+
+def replace_version_next(version):
+    """Replaces all VERSION_NEXT_* placeholders with the new version."""
+    for filepath in _iter_version_placeholder_files():
+        try:
+            with open(filepath, "r") as f:
+                content = f.read()
+        except (IOError, UnicodeDecodeError):
+            continue
+
+        if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content:
+            new_content = content.replace("VERSION_NEXT_FEATURE", version)
+            new_content = new_content.replace("VERSION_NEXT_PATCH", version)
+            with open(filepath, "w") as f:
+                f.write(new_content)