Skip to content

Commit d9fe62c

Browse files
authored
chore: release helper tool (#3206)
Right now, it just updates the changelog and replaces the version placeholders.
1 parent fb9b098 commit d9fe62c

File tree

5 files changed

+321
-8
lines changed

5 files changed

+321
-8
lines changed

RELEASING.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,10 @@ These are the steps for a regularly scheduled release from HEAD.
1313
### Steps
1414

1515
1. [Determine the next semantic version number](#determining-semantic-version).
16-
1. Update CHANGELOG.md: replace the `v0-0-0` and `0.0.0` with `X.Y.0`.
17-
```
18-
awk -v version=X.Y.0 'BEGIN { hv=version; gsub(/\./, "-", hv) } /END_UNRELEASED_TEMPLATE/ { found_marker = 1 } found_marker { gsub(/v0-0-0/, hv, $0); gsub(/Unreleased/, "[" version "] - " strftime("%Y-%m-%d"), $0); gsub(/0.0.0/, version, $0); } { print } ' CHANGELOG.md > /tmp/changelog && cp /tmp/changelog CHANGELOG.md
19-
```
20-
1. Replace `VERSION_NEXT_*` strings with `X.Y.0`.
21-
```
22-
grep -l --exclude=CONTRIBUTING.md --exclude=RELEASING.md --exclude-dir=.* VERSION_NEXT_ -r \
23-
| xargs sed -i -e 's/VERSION_NEXT_FEATURE/X.Y.0/' -e 's/VERSION_NEXT_PATCH/X.Y.0/'
16+
1. Update the changelog and replace the version placeholders by running the
17+
release tool:
18+
```shell
19+
bazel run //tools/private/release -- X.Y.Z
2420
```
2521
1. Send these changes for review and get them merged.
2622
1. Create a branch for the new release, named `release/X.Y`
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
load("@rules_python//python:defs.bzl", "py_test")
2+
3+
py_test(
4+
name = "release_test",
5+
srcs = ["release_test.py"],
6+
deps = ["//tools/private/release"],
7+
)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import datetime
2+
import os
3+
import pathlib
4+
import shutil
5+
import tempfile
6+
import unittest
7+
8+
from tools.private.release import release as releaser
9+
10+
_UNRELEASED_TEMPLATE = """
11+
<!--
12+
BEGIN_UNRELEASED_TEMPLATE
13+
14+
{#v0-0-0}
15+
## Unreleased
16+
17+
[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0
18+
19+
{#v0-0-0-changed}
20+
### Changed
21+
* Nothing changed.
22+
23+
{#v0-0-0-fixed}
24+
### Fixed
25+
* Nothing fixed.
26+
27+
{#v0-0-0-added}
28+
### Added
29+
* Nothing added.
30+
31+
{#v0-0-0-removed}
32+
### Removed
33+
* Nothing removed.
34+
35+
END_UNRELEASED_TEMPLATE
36+
-->
37+
"""
38+
39+
40+
class ReleaserTest(unittest.TestCase):
41+
def setUp(self):
42+
self.tmpdir = pathlib.Path(tempfile.mkdtemp())
43+
self.original_cwd = os.getcwd()
44+
self.addCleanup(shutil.rmtree, self.tmpdir)
45+
46+
os.chdir(self.tmpdir)
47+
# NOTE: On windows, this must be done before files are deleted.
48+
self.addCleanup(os.chdir, self.original_cwd)
49+
50+
def test_update_changelog(self):
51+
changelog = f"""
52+
# Changelog
53+
54+
{_UNRELEASED_TEMPLATE}
55+
56+
{{#v0-0-0}}
57+
## Unreleased
58+
59+
[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0
60+
61+
{{#v0-0-0-changed}}
62+
### Changed
63+
* Nothing changed
64+
65+
{{#v0-0-0-fixed}}
66+
### Fixed
67+
* Nothing fixed
68+
69+
{{#v0-0-0-added}}
70+
### Added
71+
* Nothing added
72+
73+
{{#v0-0-0-removed}}
74+
### Removed
75+
* Nothing removed.
76+
"""
77+
changelog_path = self.tmpdir / "CHANGELOG.md"
78+
changelog_path.write_text(changelog)
79+
80+
# Act
81+
releaser.update_changelog(
82+
"1.23.4",
83+
"2025-01-01",
84+
changelog_path=changelog_path,
85+
)
86+
87+
# Assert
88+
new_content = changelog_path.read_text()
89+
90+
self.assertIn(
91+
_UNRELEASED_TEMPLATE, new_content, msg=f"ACTUAL:\n\n{new_content}\n\n"
92+
)
93+
self.assertIn(f"## [1.23.4] - 2025-01-01", new_content)
94+
self.assertIn(
95+
f"[1.23.4]: https://github.com/bazel-contrib/rules_python/releases/tag/1.23.4",
96+
new_content,
97+
)
98+
self.assertIn("{#v1-23-4}", new_content)
99+
self.assertIn("{#v1-23-4-changed}", new_content)
100+
self.assertIn("{#v1-23-4-fixed}", new_content)
101+
self.assertIn("{#v1-23-4-added}", new_content)
102+
self.assertIn("{#v1-23-4-removed}", new_content)
103+
104+
def test_replace_version_next(self):
105+
# Arrange
106+
mock_file_content = """
107+
:::{versionadded} VERSION_NEXT_FEATURE
108+
blabla
109+
:::
110+
111+
:::{versionchanged} VERSION_NEXT_PATCH
112+
blabla
113+
:::
114+
"""
115+
(self.tmpdir / "mock_file.bzl").write_text(mock_file_content)
116+
117+
releaser.replace_version_next("0.28.0")
118+
119+
new_content = (self.tmpdir / "mock_file.bzl").read_text()
120+
121+
self.assertIn(":::{versionadded} 0.28.0", new_content)
122+
self.assertIn(":::{versionadded} 0.28.0", new_content)
123+
self.assertNotIn("VERSION_NEXT_FEATURE", new_content)
124+
self.assertNotIn("VERSION_NEXT_PATCH", new_content)
125+
126+
def test_replace_version_next_excludes_bazel_dirs(self):
127+
# Arrange
128+
mock_file_content = """
129+
:::{versionadded} VERSION_NEXT_FEATURE
130+
blabla
131+
:::
132+
"""
133+
bazel_dir = self.tmpdir / "bazel-rules_python"
134+
bazel_dir.mkdir()
135+
(bazel_dir / "mock_file.bzl").write_text(mock_file_content)
136+
137+
tools_dir = self.tmpdir / "tools" / "private" / "release"
138+
tools_dir.mkdir(parents=True)
139+
(tools_dir / "mock_file.bzl").write_text(mock_file_content)
140+
141+
tests_dir = self.tmpdir / "tests" / "tools" / "private" / "release"
142+
tests_dir.mkdir(parents=True)
143+
(tests_dir / "mock_file.bzl").write_text(mock_file_content)
144+
145+
version = "0.28.0"
146+
147+
# Act
148+
releaser.replace_version_next(version)
149+
150+
# Assert
151+
new_content = (bazel_dir / "mock_file.bzl").read_text()
152+
self.assertIn("VERSION_NEXT_FEATURE", new_content)
153+
154+
new_content = (tools_dir / "mock_file.bzl").read_text()
155+
self.assertIn("VERSION_NEXT_FEATURE", new_content)
156+
157+
new_content = (tests_dir / "mock_file.bzl").read_text()
158+
self.assertIn("VERSION_NEXT_FEATURE", new_content)
159+
160+
def test_valid_version(self):
161+
# These should not raise an exception
162+
releaser.create_parser().parse_args(["0.28.0"])
163+
releaser.create_parser().parse_args(["1.0.0"])
164+
releaser.create_parser().parse_args(["1.2.3rc4"])
165+
166+
def test_invalid_version(self):
167+
with self.assertRaises(SystemExit):
168+
releaser.create_parser().parse_args(["0.28"])
169+
with self.assertRaises(SystemExit):
170+
releaser.create_parser().parse_args(["a.b.c"])
171+
172+
173+
if __name__ == "__main__":
174+
unittest.main()

tools/private/release/BUILD.bazel

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
load("@rules_python//python:defs.bzl", "py_binary")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
py_binary(
6+
name = "release",
7+
srcs = ["release.py"],
8+
main = "release.py",
9+
)

tools/private/release/release.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""A tool to perform release steps."""
2+
3+
import argparse
4+
import datetime
5+
import fnmatch
6+
import os
7+
import pathlib
8+
import re
9+
10+
11+
def update_changelog(version, release_date, changelog_path="CHANGELOG.md"):
12+
"""Performs the version replacements in CHANGELOG.md."""
13+
14+
header_version = version.replace(".", "-")
15+
16+
changelog_path_obj = pathlib.Path(changelog_path)
17+
lines = changelog_path_obj.read_text().splitlines()
18+
19+
new_lines = []
20+
after_template = False
21+
before_already_released = True
22+
for line in lines:
23+
if "END_UNRELEASED_TEMPLATE" in line:
24+
after_template = True
25+
if re.match("#v[1-9]-", line):
26+
before_already_released = False
27+
28+
if after_template and before_already_released:
29+
line = line.replace("## Unreleased", f"## [{version}] - {release_date}")
30+
line = line.replace("v0-0-0", f"v{header_version}")
31+
line = line.replace("0.0.0", version)
32+
33+
new_lines.append(line)
34+
35+
changelog_path_obj.write_text("\n".join(new_lines))
36+
37+
38+
def replace_version_next(version):
39+
"""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+
]
51+
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)
80+
81+
82+
def _semver_type(value):
83+
if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value):
84+
raise argparse.ArgumentTypeError(
85+
f"'{value}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)"
86+
)
87+
return value
88+
89+
90+
def create_parser():
91+
"""Creates the argument parser."""
92+
parser = argparse.ArgumentParser(
93+
description="Automate release steps for rules_python."
94+
)
95+
parser.add_argument(
96+
"version",
97+
help="The new release version (e.g., 0.28.0).",
98+
type=_semver_type,
99+
)
100+
return parser
101+
102+
103+
def main():
104+
parser = create_parser()
105+
args = parser.parse_args()
106+
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+
)
111+
112+
# Change to the workspace root so the script can be run from anywhere.
113+
if "BUILD_WORKSPACE_DIRECTORY" in os.environ:
114+
os.chdir(os.environ["BUILD_WORKSPACE_DIRECTORY"])
115+
116+
print("Updating changelog ...")
117+
release_date = datetime.date.today().strftime("%Y-%m-%d")
118+
update_changelog(args.version, release_date)
119+
120+
print("Replacing VERSION_NEXT placeholders ...")
121+
replace_version_next(args.version)
122+
123+
print("Done")
124+
125+
126+
if __name__ == "__main__":
127+
main()

0 commit comments

Comments
 (0)