Skip to content

Commit 1e17486

Browse files
rickeylevaignas
andauthored
chore: make release tool auto detect next version (#3219)
This makes the release tool determine the next version automatically. It does so by searching for the VERSION_NEXT strings. If VERSION_NEXT_FEATURE is found, then it increments the minor version. If only patch placeholders are found, then it increments the patch version. When the latest version is an RC, an error is raised. This is to protect against accidentally running it when we're in the middle of the RC phase. --------- Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com>
1 parent 6610fd7 commit 1e17486

File tree

5 files changed

+171
-52
lines changed

5 files changed

+171
-52
lines changed

RELEASING.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ These are the steps for a regularly scheduled release from HEAD.
1212

1313
### Steps
1414

15-
1. [Determine the next semantic version number](#determining-semantic-version).
1615
1. Update the changelog and replace the version placeholders by running the
17-
release tool:
16+
release tool. The next version number will by automatically determined
17+
based on the presence of `VERSION_NEXT_*` placeholders and git tags.
18+
1819
```shell
19-
bazel run //tools/private/release -- X.Y.Z
20+
bazel run //tools/private/release
2021
```
22+
2123
1. Send these changes for review and get them merged.
2224
1. Create a branch for the new release, named `release/X.Y`
2325
```
@@ -70,7 +72,8 @@ gh workflow run release.yml --ref <TAG> -f publish_to_pypi=false
7072
API changes and new features bump the minor, and those with only bug fixes and
7173
other minor changes bump the patch digit.
7274

73-
To find if there were any features added or incompatible changes made, review
75+
The release tool will automatically determine the next version number. To find
76+
if there were any features added or incompatible changes made, review
7477
[CHANGELOG.md](CHANGELOG.md) and the commit history. This can be done using
7578
github by going to the url:
7679
`https://github.com/bazel-contrib/rules_python/compare/<VERSION>...main`.

tests/tools/private/release/BUILD.bazel

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,8 @@ load("@rules_python//python:defs.bzl", "py_test")
33
py_test(
44
name = "release_test",
55
srcs = ["release_test.py"],
6-
deps = ["//tools/private/release"],
6+
deps = [
7+
"//tools/private/release",
8+
"@dev_pip//packaging",
9+
],
710
)

tests/tools/private/release/release_test.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import shutil
55
import tempfile
66
import unittest
7+
from unittest.mock import patch
78

89
from tools.private.release import release as releaser
910

@@ -170,5 +171,44 @@ def test_invalid_version(self):
170171
releaser.create_parser().parse_args(["a.b.c"])
171172

172173

174+
class GetLatestVersionTest(unittest.TestCase):
175+
@patch("tools.private.release.release._get_git_tags")
176+
def test_get_latest_version_success(self, mock_get_tags):
177+
mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"]
178+
self.assertEqual(releaser.get_latest_version(), "1.0.0")
179+
180+
@patch("tools.private.release.release._get_git_tags")
181+
def test_get_latest_version_rc_is_latest(self, mock_get_tags):
182+
mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"]
183+
with self.assertRaisesRegex(
184+
ValueError, "The latest version is a pre-release version: 1.1.0rc0"
185+
):
186+
releaser.get_latest_version()
187+
188+
@patch("tools.private.release.release._get_git_tags")
189+
def test_get_latest_version_no_tags(self, mock_get_tags):
190+
mock_get_tags.return_value = []
191+
with self.assertRaisesRegex(
192+
RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format."
193+
):
194+
releaser.get_latest_version()
195+
196+
@patch("tools.private.release.release._get_git_tags")
197+
def test_get_latest_version_no_matching_tags(self, mock_get_tags):
198+
mock_get_tags.return_value = ["v1.0", "latest"]
199+
with self.assertRaisesRegex(
200+
RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format."
201+
):
202+
releaser.get_latest_version()
203+
204+
@patch("tools.private.release.release._get_git_tags")
205+
def test_get_latest_version_only_rc_tags(self, mock_get_tags):
206+
mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"]
207+
with self.assertRaisesRegex(
208+
ValueError, "The latest version is a pre-release version: 1.1.0rc0"
209+
):
210+
releaser.get_latest_version()
211+
212+
173213
if __name__ == "__main__":
174214
unittest.main()

tools/private/release/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,7 @@ py_binary(
66
name = "release",
77
srcs = ["release.py"],
88
main = "release.py",
9+
deps = [
10+
"@dev_pip//packaging",
11+
],
912
)

tools/private/release/release.py

Lines changed: 117 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,100 @@
66
import os
77
import pathlib
88
import re
9+
import subprocess
10+
11+
from packaging.version import parse as parse_version
12+
13+
_EXCLUDE_PATTERNS = [
14+
"./.git/*",
15+
"./.github/*",
16+
"./.bazelci/*",
17+
"./.bcr/*",
18+
"./bazel-*/*",
19+
"./CONTRIBUTING.md",
20+
"./RELEASING.md",
21+
"./tools/private/release/*",
22+
"./tests/tools/private/release/*",
23+
]
24+
25+
26+
def _iter_version_placeholder_files():
27+
for root, dirs, files in os.walk(".", topdown=True):
28+
# Filter directories
29+
dirs[:] = [
30+
d
31+
for d in dirs
32+
if not any(
33+
fnmatch.fnmatch(os.path.join(root, d), pattern)
34+
for pattern in _EXCLUDE_PATTERNS
35+
)
36+
]
37+
38+
for filename in files:
39+
filepath = os.path.join(root, filename)
40+
if any(fnmatch.fnmatch(filepath, pattern) for pattern in _EXCLUDE_PATTERNS):
41+
continue
42+
43+
yield filepath
44+
45+
46+
def _get_git_tags():
47+
"""Runs a git command and returns the output."""
48+
return subprocess.check_output(["git", "tag"]).decode("utf-8").splitlines()
49+
50+
51+
def get_latest_version():
52+
"""Gets the latest version from git tags."""
53+
tags = _get_git_tags()
54+
# The packaging module can parse PEP440 versions, including RCs.
55+
# It has a good understanding of version precedence.
56+
versions = [
57+
(tag, parse_version(tag))
58+
for tag in tags
59+
if re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", tag.strip())
60+
]
61+
if not versions:
62+
raise RuntimeError("No git tags found matching X.Y.Z or X.Y.ZrcN format.")
63+
64+
versions.sort(key=lambda v: v[1])
65+
latest_tag, latest_version = versions[-1]
66+
67+
if latest_version.is_prerelease:
68+
raise ValueError(f"The latest version is a pre-release version: {latest_tag}")
69+
70+
# After all that, we only want to consider stable versions for the release.
71+
stable_versions = [tag for tag, version in versions if not version.is_prerelease]
72+
if not stable_versions:
73+
raise ValueError("No stable git tags found matching X.Y.Z format.")
74+
75+
# The versions are already sorted, so the last one is the latest.
76+
return stable_versions[-1]
77+
78+
79+
def should_increment_minor():
80+
"""Checks if the minor version should be incremented."""
81+
for filepath in _iter_version_placeholder_files():
82+
try:
83+
with open(filepath, "r") as f:
84+
content = f.read()
85+
except (IOError, UnicodeDecodeError):
86+
# Ignore binary files or files with read errors
87+
continue
88+
89+
if "VERSION_NEXT_FEATURE" in content:
90+
return True
91+
return False
92+
93+
94+
def determine_next_version():
95+
"""Determines the next version based on git tags and placeholders."""
96+
latest_version = get_latest_version()
97+
major, minor, patch = [int(n) for n in latest_version.split(".")]
98+
99+
if should_increment_minor():
100+
return f"{major}.{minor + 1}.0"
101+
else:
102+
return f"{major}.{minor}.{patch + 1}"
9103

10104

11105
def update_changelog(version, release_date, changelog_path="CHANGELOG.md"):
@@ -37,46 +131,19 @@ def update_changelog(version, release_date, changelog_path="CHANGELOG.md"):
37131

38132
def replace_version_next(version):
39133
"""Replaces all VERSION_NEXT_* placeholders with the new version."""
40-
exclude_patterns = [
41-
"./.git/*",
42-
"./.github/*",
43-
"./.bazelci/*",
44-
"./.bcr/*",
45-
"./bazel-*/*",
46-
"./CONTRIBUTING.md",
47-
"./RELEASING.md",
48-
"./tools/private/release/*",
49-
"./tests/tools/private/release/*",
50-
]
134+
for filepath in _iter_version_placeholder_files():
135+
try:
136+
with open(filepath, "r") as f:
137+
content = f.read()
138+
except (IOError, UnicodeDecodeError):
139+
# Ignore binary files or files with read errors
140+
continue
51141

52-
for root, dirs, files in os.walk(".", topdown=True):
53-
# Filter directories
54-
dirs[:] = [
55-
d
56-
for d in dirs
57-
if not any(
58-
fnmatch.fnmatch(os.path.join(root, d), pattern)
59-
for pattern in exclude_patterns
60-
)
61-
]
62-
63-
for filename in files:
64-
filepath = os.path.join(root, filename)
65-
if any(fnmatch.fnmatch(filepath, pattern) for pattern in exclude_patterns):
66-
continue
67-
68-
try:
69-
with open(filepath, "r") as f:
70-
content = f.read()
71-
except (IOError, UnicodeDecodeError):
72-
# Ignore binary files or files with read errors
73-
continue
74-
75-
if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content:
76-
new_content = content.replace("VERSION_NEXT_FEATURE", version)
77-
new_content = new_content.replace("VERSION_NEXT_PATCH", version)
78-
with open(filepath, "w") as f:
79-
f.write(new_content)
142+
if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content:
143+
new_content = content.replace("VERSION_NEXT_FEATURE", version)
144+
new_content = new_content.replace("VERSION_NEXT_PATCH", version)
145+
with open(filepath, "w") as f:
146+
f.write(new_content)
80147

81148

82149
def _semver_type(value):
@@ -94,8 +161,10 @@ def create_parser():
94161
)
95162
parser.add_argument(
96163
"version",
97-
help="The new release version (e.g., 0.28.0).",
164+
nargs="?",
98165
type=_semver_type,
166+
help="The new release version (e.g., 0.28.0). If not provided, "
167+
"it will be determined automatically.",
99168
)
100169
return parser
101170

@@ -104,21 +173,22 @@ def main():
104173
parser = create_parser()
105174
args = parser.parse_args()
106175

107-
if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", args.version):
108-
raise ValueError(
109-
f"Version '{args.version}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)"
110-
)
176+
version = args.version
177+
if version is None:
178+
print("No version provided, determining next version automatically...")
179+
version = determine_next_version()
180+
print(f"Determined next version: {version}")
111181

112-
# Change to the workspace root so the script can be run from anywhere.
182+
# Change to the workspace root so the script can be run using `bazel run`
113183
if "BUILD_WORKSPACE_DIRECTORY" in os.environ:
114184
os.chdir(os.environ["BUILD_WORKSPACE_DIRECTORY"])
115185

116186
print("Updating changelog ...")
117187
release_date = datetime.date.today().strftime("%Y-%m-%d")
118-
update_changelog(args.version, release_date)
188+
update_changelog(version, release_date)
119189

120190
print("Replacing VERSION_NEXT placeholders ...")
121-
replace_version_next(args.version)
191+
replace_version_next(version)
122192

123193
print("Done")
124194

0 commit comments

Comments
 (0)