chore: release helper tool (#3206)
Right now, it just updates the changelog and replaces the version
placeholders.
diff --git a/RELEASING.md b/RELEASING.md
index c9d46c3..a99b7d8 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -13,14 +13,10 @@
### Steps
1. [Determine the next semantic version number](#determining-semantic-version).
-1. Update CHANGELOG.md: replace the `v0-0-0` and `0.0.0` with `X.Y.0`.
- ```
- 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
- ```
-1. Replace `VERSION_NEXT_*` strings with `X.Y.0`.
- ```
- grep -l --exclude=CONTRIBUTING.md --exclude=RELEASING.md --exclude-dir=.* VERSION_NEXT_ -r \
- | xargs sed -i -e 's/VERSION_NEXT_FEATURE/X.Y.0/' -e 's/VERSION_NEXT_PATCH/X.Y.0/'
+1. Update the changelog and replace the version placeholders by running the
+ release tool:
+ ```shell
+ bazel run //tools/private/release -- X.Y.Z
```
1. Send these changes for review and get them merged.
1. Create a branch for the new release, named `release/X.Y`
diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel
new file mode 100644
index 0000000..3c9db2d
--- /dev/null
+++ b/tests/tools/private/release/BUILD.bazel
@@ -0,0 +1,7 @@
+load("@rules_python//python:defs.bzl", "py_test")
+
+py_test(
+ name = "release_test",
+ srcs = ["release_test.py"],
+ deps = ["//tools/private/release"],
+)
diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py
new file mode 100644
index 0000000..5f04464
--- /dev/null
+++ b/tests/tools/private/release/release_test.py
@@ -0,0 +1,174 @@
+import datetime
+import os
+import pathlib
+import shutil
+import tempfile
+import unittest
+
+from tools.private.release import release as releaser
+
+_UNRELEASED_TEMPLATE = """
+<!--
+BEGIN_UNRELEASED_TEMPLATE
+
+{#v0-0-0}
+## Unreleased
+
+[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0
+
+{#v0-0-0-changed}
+### Changed
+* Nothing changed.
+
+{#v0-0-0-fixed}
+### Fixed
+* Nothing fixed.
+
+{#v0-0-0-added}
+### Added
+* Nothing added.
+
+{#v0-0-0-removed}
+### Removed
+* Nothing removed.
+
+END_UNRELEASED_TEMPLATE
+-->
+"""
+
+
+class ReleaserTest(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)
+
+ def test_update_changelog(self):
+ changelog = f"""
+# Changelog
+
+{_UNRELEASED_TEMPLATE}
+
+{{#v0-0-0}}
+## Unreleased
+
+[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0
+
+{{#v0-0-0-changed}}
+### Changed
+* Nothing changed
+
+{{#v0-0-0-fixed}}
+### Fixed
+* Nothing fixed
+
+{{#v0-0-0-added}}
+### Added
+* Nothing added
+
+{{#v0-0-0-removed}}
+### Removed
+* Nothing removed.
+"""
+ changelog_path = self.tmpdir / "CHANGELOG.md"
+ changelog_path.write_text(changelog)
+
+ # Act
+ releaser.update_changelog(
+ "1.23.4",
+ "2025-01-01",
+ changelog_path=changelog_path,
+ )
+
+ # Assert
+ new_content = changelog_path.read_text()
+
+ self.assertIn(
+ _UNRELEASED_TEMPLATE, new_content, msg=f"ACTUAL:\n\n{new_content}\n\n"
+ )
+ self.assertIn(f"## [1.23.4] - 2025-01-01", new_content)
+ self.assertIn(
+ f"[1.23.4]: https://github.com/bazel-contrib/rules_python/releases/tag/1.23.4",
+ new_content,
+ )
+ self.assertIn("{#v1-23-4}", new_content)
+ self.assertIn("{#v1-23-4-changed}", new_content)
+ self.assertIn("{#v1-23-4-fixed}", new_content)
+ self.assertIn("{#v1-23-4-added}", new_content)
+ self.assertIn("{#v1-23-4-removed}", new_content)
+
+ def test_replace_version_next(self):
+ # Arrange
+ mock_file_content = """
+:::{versionadded} VERSION_NEXT_FEATURE
+blabla
+:::
+
+:::{versionchanged} VERSION_NEXT_PATCH
+blabla
+:::
+"""
+ (self.tmpdir / "mock_file.bzl").write_text(mock_file_content)
+
+ releaser.replace_version_next("0.28.0")
+
+ new_content = (self.tmpdir / "mock_file.bzl").read_text()
+
+ self.assertIn(":::{versionadded} 0.28.0", new_content)
+ self.assertIn(":::{versionadded} 0.28.0", new_content)
+ self.assertNotIn("VERSION_NEXT_FEATURE", new_content)
+ self.assertNotIn("VERSION_NEXT_PATCH", new_content)
+
+ def test_replace_version_next_excludes_bazel_dirs(self):
+ # Arrange
+ mock_file_content = """
+:::{versionadded} VERSION_NEXT_FEATURE
+blabla
+:::
+"""
+ bazel_dir = self.tmpdir / "bazel-rules_python"
+ bazel_dir.mkdir()
+ (bazel_dir / "mock_file.bzl").write_text(mock_file_content)
+
+ tools_dir = self.tmpdir / "tools" / "private" / "release"
+ tools_dir.mkdir(parents=True)
+ (tools_dir / "mock_file.bzl").write_text(mock_file_content)
+
+ tests_dir = self.tmpdir / "tests" / "tools" / "private" / "release"
+ tests_dir.mkdir(parents=True)
+ (tests_dir / "mock_file.bzl").write_text(mock_file_content)
+
+ version = "0.28.0"
+
+ # Act
+ releaser.replace_version_next(version)
+
+ # Assert
+ new_content = (bazel_dir / "mock_file.bzl").read_text()
+ self.assertIn("VERSION_NEXT_FEATURE", new_content)
+
+ new_content = (tools_dir / "mock_file.bzl").read_text()
+ self.assertIn("VERSION_NEXT_FEATURE", new_content)
+
+ new_content = (tests_dir / "mock_file.bzl").read_text()
+ self.assertIn("VERSION_NEXT_FEATURE", new_content)
+
+ def test_valid_version(self):
+ # These should not raise an exception
+ releaser.create_parser().parse_args(["0.28.0"])
+ releaser.create_parser().parse_args(["1.0.0"])
+ releaser.create_parser().parse_args(["1.2.3rc4"])
+
+ def test_invalid_version(self):
+ with self.assertRaises(SystemExit):
+ releaser.create_parser().parse_args(["0.28"])
+ with self.assertRaises(SystemExit):
+ releaser.create_parser().parse_args(["a.b.c"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel
new file mode 100644
index 0000000..9cd8ec2
--- /dev/null
+++ b/tools/private/release/BUILD.bazel
@@ -0,0 +1,9 @@
+load("@rules_python//python:defs.bzl", "py_binary")
+
+package(default_visibility = ["//visibility:public"])
+
+py_binary(
+ name = "release",
+ srcs = ["release.py"],
+ main = "release.py",
+)
diff --git a/tools/private/release/release.py b/tools/private/release/release.py
new file mode 100644
index 0000000..f37a5ff
--- /dev/null
+++ b/tools/private/release/release.py
@@ -0,0 +1,127 @@
+"""A tool to perform release steps."""
+
+import argparse
+import datetime
+import fnmatch
+import os
+import pathlib
+import re
+
+
+def update_changelog(version, release_date, changelog_path="CHANGELOG.md"):
+ """Performs the version replacements in CHANGELOG.md."""
+
+ header_version = version.replace(".", "-")
+
+ changelog_path_obj = pathlib.Path(changelog_path)
+ lines = changelog_path_obj.read_text().splitlines()
+
+ new_lines = []
+ after_template = False
+ before_already_released = True
+ for line in lines:
+ if "END_UNRELEASED_TEMPLATE" in line:
+ after_template = True
+ if re.match("#v[1-9]-", line):
+ before_already_released = False
+
+ if after_template and before_already_released:
+ line = line.replace("## Unreleased", f"## [{version}] - {release_date}")
+ line = line.replace("v0-0-0", f"v{header_version}")
+ line = line.replace("0.0.0", version)
+
+ new_lines.append(line)
+
+ changelog_path_obj.write_text("\n".join(new_lines))
+
+
+def replace_version_next(version):
+ """Replaces all VERSION_NEXT_* placeholders with the new version."""
+ exclude_patterns = [
+ "./.git/*",
+ "./.github/*",
+ "./.bazelci/*",
+ "./.bcr/*",
+ "./bazel-*/*",
+ "./CONTRIBUTING.md",
+ "./RELEASING.md",
+ "./tools/private/release/*",
+ "./tests/tools/private/release/*",
+ ]
+
+ 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
+
+ try:
+ with open(filepath, "r") as f:
+ content = f.read()
+ except (IOError, UnicodeDecodeError):
+ # Ignore binary files or files with read errors
+ 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(
+ f"'{value}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)"
+ )
+ return value
+
+
+def create_parser():
+ """Creates the argument parser."""
+ parser = argparse.ArgumentParser(
+ description="Automate release steps for rules_python."
+ )
+ parser.add_argument(
+ "version",
+ help="The new release version (e.g., 0.28.0).",
+ type=_semver_type,
+ )
+ return parser
+
+
+def main():
+ parser = create_parser()
+ args = parser.parse_args()
+
+ if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", args.version):
+ raise ValueError(
+ f"Version '{args.version}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)"
+ )
+
+ # Change to the workspace root so the script can be run from anywhere.
+ if "BUILD_WORKSPACE_DIRECTORY" in os.environ:
+ os.chdir(os.environ["BUILD_WORKSPACE_DIRECTORY"])
+
+ print("Updating changelog ...")
+ release_date = datetime.date.today().strftime("%Y-%m-%d")
+ update_changelog(args.version, release_date)
+
+ print("Replacing VERSION_NEXT placeholders ...")
+ replace_version_next(args.version)
+
+ print("Done")
+
+
+if __name__ == "__main__":
+ main()