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)