Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,10 @@ These are the steps for a regularly scheduled release from HEAD.
### 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`
Expand Down
7 changes: 7 additions & 0 deletions tests/tools/private/release/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"],
)
174 changes: 174 additions & 0 deletions tests/tools/private/release/release_test.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 9 additions & 0 deletions tools/private/release/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
)
127 changes: 127 additions & 0 deletions tools/private/release/release.py
Original file line number Diff line number Diff line change
@@ -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()