Skip to content

Commit 16b0f38

Browse files
committed
chore: release helper tool
Right now, it just updates the changelog and replaces the version placeholders.
1 parent 24146a4 commit 16b0f38

File tree

5 files changed

+352
-8
lines changed

5 files changed

+352
-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: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Copyright 2025 The Bazel Authors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import datetime
16+
import os
17+
import pathlib
18+
import shutil
19+
import tempfile
20+
import unittest
21+
22+
from tools.private.release import release as releaser
23+
24+
_UNRELEASED_TEMPLATE = """
25+
<!--
26+
BEGIN_UNRELEASED_TEMPLATE
27+
28+
{#v0-0-0}
29+
## Unreleased
30+
31+
[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0
32+
33+
{#v0-0-0-changed}
34+
### Changed
35+
* Nothing changed.
36+
37+
{#v0-0-0-fixed}
38+
### Fixed
39+
* Nothing fixed.
40+
41+
{#v0-0-0-added}
42+
### Added
43+
* Nothing added.
44+
45+
{#v0-0-0-removed}
46+
### Removed
47+
* Nothing removed.
48+
49+
END_UNRELEASED_TEMPLATE
50+
-->
51+
"""
52+
53+
54+
class ReleaserTest(unittest.TestCase):
55+
def setUp(self):
56+
self.tmpdir = pathlib.Path(tempfile.mkdtemp())
57+
self.original_cwd = os.getcwd()
58+
os.chdir(self.tmpdir)
59+
self.addCleanup(os.chdir, self.original_cwd)
60+
self.addCleanup(shutil.rmtree, self.tmpdir)
61+
62+
def test_update_changelog(self):
63+
changelog = f"""
64+
# Changelog
65+
66+
{_UNRELEASED_TEMPLATE}
67+
68+
{{#v0-0-0}}
69+
## Unreleased
70+
71+
[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0
72+
73+
{{#v0-0-0-changed}}
74+
### Changed
75+
* Nothing changed
76+
77+
{{#v0-0-0-fixed}}
78+
### Fixed
79+
* Nothing fixed
80+
81+
{{#v0-0-0-added}}
82+
### Added
83+
* Nothing added
84+
85+
{{#v0-0-0-removed}}
86+
### Removed
87+
* Nothing removed.
88+
"""
89+
changelog_path = self.tmpdir / "CHANGELOG.md"
90+
changelog_path.write_text(changelog)
91+
92+
# Act
93+
releaser.update_changelog(
94+
"1.23.4",
95+
"2025-01-01",
96+
changelog_path=changelog_path,
97+
)
98+
99+
# Assert
100+
new_content = changelog_path.read_text()
101+
102+
self.assertIn(
103+
_UNRELEASED_TEMPLATE, new_content, msg=f"ACTUAL:\n\n{new_content}\n\n"
104+
)
105+
self.assertIn(f"## [1.23.4] - 2025-01-01", new_content)
106+
self.assertIn(
107+
f"[1.23.4]: https://github.com/bazel-contrib/rules_python/releases/tag/1.23.4",
108+
new_content,
109+
)
110+
self.assertIn("{#v1-23-4}", new_content)
111+
self.assertIn("{#v1-23-4-changed}", new_content)
112+
self.assertIn("{#v1-23-4-fixed}", new_content)
113+
self.assertIn("{#v1-23-4-added}", new_content)
114+
self.assertIn("{#v1-23-4-removed}", new_content)
115+
116+
def test_replace_version_next(self):
117+
# Arrange
118+
mock_file_content = """
119+
:::{versionadded} VERSION_NEXT_FEATURE
120+
blabla
121+
:::
122+
123+
:::{versionchanged} VERSION_NEXT_PATCH
124+
blabla
125+
:::
126+
"""
127+
(self.tmpdir / "mock_file.bzl").write_text(mock_file_content)
128+
129+
releaser.replace_version_next("0.28.0")
130+
131+
new_content = (self.tmpdir / "mock_file.bzl").read_text()
132+
133+
self.assertIn(":::{versionadded} 0.28.0", new_content)
134+
self.assertIn(":::{versionadded} 0.28.0", new_content)
135+
self.assertNotIn("VERSION_NEXT_FEATURE", new_content)
136+
self.assertNotIn("VERSION_NEXT_PATCH", new_content)
137+
138+
def test_replace_version_next_excludes_bazel_dirs(self):
139+
# Arrange
140+
mock_file_content = """
141+
:::{versionadded} VERSION_NEXT_FEATURE
142+
blabla
143+
:::
144+
"""
145+
bazel_dir = self.tmpdir / "bazel-rules_python"
146+
bazel_dir.mkdir()
147+
(bazel_dir / "mock_file.bzl").write_text(mock_file_content)
148+
149+
tools_dir = self.tmpdir / "tools" / "private" / "release"
150+
tools_dir.mkdir(parents=True)
151+
(tools_dir / "mock_file.bzl").write_text(mock_file_content)
152+
153+
tests_dir = self.tmpdir / "tests" / "tools" / "private" / "release"
154+
tests_dir.mkdir(parents=True)
155+
(tests_dir / "mock_file.bzl").write_text(mock_file_content)
156+
157+
version = "0.28.0"
158+
159+
# Act
160+
releaser.replace_version_next(version)
161+
162+
# Assert
163+
new_content = (bazel_dir / "mock_file.bzl").read_text()
164+
self.assertIn("VERSION_NEXT_FEATURE", new_content)
165+
166+
new_content = (tools_dir / "mock_file.bzl").read_text()
167+
self.assertIn("VERSION_NEXT_FEATURE", new_content)
168+
169+
new_content = (tests_dir / "mock_file.bzl").read_text()
170+
self.assertIn("VERSION_NEXT_FEATURE", new_content)
171+
172+
def test_valid_version(self):
173+
# These should not raise an exception
174+
releaser.create_parser().parse_args(["0.28.0"])
175+
releaser.create_parser().parse_args(["1.0.0"])
176+
releaser.create_parser().parse_args(["1.2.3rc4"])
177+
178+
def test_invalid_version(self):
179+
with self.assertRaises(SystemExit):
180+
releaser.create_parser().parse_args(["0.28"])
181+
with self.assertRaises(SystemExit):
182+
releaser.create_parser().parse_args(["a.b.c"])
183+
184+
185+
if __name__ == "__main__":
186+
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: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2025 The Bazel Authors. All rights reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""A tool to perform release steps.
17+
18+
This tool automates steps 2 and 3 of the release process described in
19+
RELEASING.md.
20+
"""
21+
22+
import argparse
23+
import datetime
24+
import fnmatch
25+
import os
26+
import pathlib
27+
import re
28+
29+
30+
def update_changelog(version, release_date, changelog_path="CHANGELOG.md"):
31+
"""Performs the version replacements in CHANGELOG.md."""
32+
33+
header_version = version.replace(".", "-")
34+
35+
changelog_path_obj = pathlib.Path(changelog_path)
36+
lines = changelog_path_obj.read_text().splitlines()
37+
38+
new_lines = []
39+
after_template = False
40+
before_already_released = True
41+
for line in lines:
42+
if "END_UNRELEASED_TEMPLATE" in line:
43+
after_template = True
44+
if re.match("#v[1-9]-", line):
45+
before_already_released = False
46+
47+
if after_template and before_already_released:
48+
line = line.replace("## Unreleased", f"## [{version}] - {release_date}")
49+
line = line.replace("v0-0-0", f"v{header_version}")
50+
line = line.replace("0.0.0", version)
51+
52+
new_lines.append(line)
53+
54+
changelog_path_obj.write_text("\n".join(new_lines))
55+
56+
57+
def replace_version_next(version):
58+
"""Replaces all VERSION_NEXT_* placeholders with the new version."""
59+
exclude_patterns = [
60+
"./.git/*",
61+
"./.github/*",
62+
"./.bazelci/*",
63+
"./.bcr/*",
64+
"./bazel-*/*",
65+
"./CONTRIBUTING.md",
66+
"./RELEASING.md",
67+
"./tools/private/release/*",
68+
"./tests/tools/private/release/*",
69+
]
70+
71+
for root, dirs, files in os.walk(".", topdown=True):
72+
# Filter directories
73+
dirs[:] = [
74+
d
75+
for d in dirs
76+
if not any(
77+
fnmatch.fnmatch(os.path.join(root, d), pattern)
78+
for pattern in exclude_patterns
79+
)
80+
]
81+
82+
for filename in files:
83+
filepath = os.path.join(root, filename)
84+
if any(fnmatch.fnmatch(filepath, pattern) for pattern in exclude_patterns):
85+
continue
86+
87+
try:
88+
with open(filepath, "r") as f:
89+
content = f.read()
90+
except (IOError, UnicodeDecodeError):
91+
# Ignore binary files or files with read errors
92+
continue
93+
94+
if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content:
95+
new_content = content.replace("VERSION_NEXT_FEATURE", version)
96+
new_content = new_content.replace("VERSION_NEXT_PATCH", version)
97+
with open(filepath, "w") as f:
98+
f.write(new_content)
99+
100+
101+
def _semver_type(value):
102+
if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value):
103+
raise argparse.ArgumentTypeError(
104+
f"'{value}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)"
105+
)
106+
return value
107+
108+
109+
def create_parser():
110+
"""Creates the argument parser."""
111+
parser = argparse.ArgumentParser(
112+
description="Automate release steps for rules_python."
113+
)
114+
parser.add_argument(
115+
"version",
116+
help="The new release version (e.g., 0.28.0).",
117+
type=_semver_type,
118+
)
119+
return parser
120+
121+
122+
def main():
123+
parser = create_parser()
124+
args = parser.parse_args()
125+
126+
if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", args.version):
127+
raise ValueError(
128+
f"Version '{args.version}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)"
129+
)
130+
131+
# Change to the workspace root so the script can be run from anywhere.
132+
if "BUILD_WORKSPACE_DIRECTORY" in os.environ:
133+
os.chdir(os.environ["BUILD_WORKSPACE_DIRECTORY"])
134+
135+
print("Updating changelog ...")
136+
release_date = datetime.date.today().strftime("%Y-%m-%d")
137+
update_changelog(args.version, release_date)
138+
139+
print("Replacing VERSION_NEXT placeholders ...")
140+
replace_version_next(args.version)
141+
142+
print("Done")
143+
144+
145+
if __name__ == "__main__":
146+
main()

0 commit comments

Comments
 (0)