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
6 changes: 4 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,10 @@ methods:
### Method A: Comment on the PR

Comment `/backport` on the PR you wish to backport. This will automatically
add the PR to the active release's backports checklist. Once the PR is merged,
the backports will be automatically processed.
add the PR to the active release's backports checklist, or automatically create
a patch release tracking issue for the next patch version if no release tracking
issue currently exists. Once the PR is merged, the backports will be
automatically processed.

> [!NOTE]
> Commenting `/backport` on an open PR will block further release publishing
Expand Down
42 changes: 40 additions & 2 deletions tests/tools/private/release/add_backports_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,50 @@ def test_add_backports_auto_discover_success(mock_gh):
assert "- [ ] #124" in updated_body


def test_add_backports_auto_discover_no_issues(mock_gh):
def test_add_backports_auto_discover_no_issues_creates_patch_release(
mock_gh, mock_git, release_tool_env
):
mock_git.get_tags.return_value = ["1.0.0", "1.2.0"]
mock_git.get_current_branch.return_value = "main"

args = argparse.Namespace(issue=None, prs=["124"])

result = AddBackports(args, mock_gh, mock_git).run()

assert result == 0
open_issues = mock_gh.get_open_tracking_issues()
assert len(open_issues) == 1
issue = open_issues[0]
assert issue["title"] == "Release 1.2.1"
body = issue["body"]
assert "- [ ] #124" in body
assert "- [ ] Sync Changelog #124" in body
assert "Tag RC" not in body


def test_add_backports_patch_release_no_rc_added(mock_gh):
args = argparse.Namespace(issue=123, prs=["124"])
mock_gh.issues[123] = {
"title": "Release 1.2.1",
"body": """
## Checklist
- [ ] Prepare Release
- [ ] Create Release branch
- [ ] Tag Final

## Backports
""",
"labels": ["type: release"],
"number": 123,
"url": "https://github.com/bazel-contrib/rules_python/issues/123",
}
result = AddBackports(args, mock_gh).run()

assert result == 1
assert result == 0
updated_body = mock_gh.get_issue_body(123)
assert "- [ ] #124" in updated_body
assert "- [ ] Sync Changelog #124" in updated_body
assert "Tag RC" not in updated_body


def test_add_backports_auto_discover_multiple_issues(mock_gh):
Expand Down
33 changes: 33 additions & 0 deletions tests/tools/private/release/release_issue_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
add_backports_to_body,
add_sync_changelog_task_to_body,
format_metadata_line,
load_release_tracking_template,
parse_checklist_state,
parse_metadata_line,
)
Expand Down Expand Up @@ -155,3 +156,35 @@ def test_parse_checklist_state_with_sync_changelogs():
assert not task_126.checked
assert task_126.status is None
assert task_126.pr is None


def test_load_release_tracking_template(tmp_path):
template_file = tmp_path / "template.md"
template_file.write_text("""## Checklist
- [ ] Prepare Release
- [ ] Create Release branch
- [ ] Tag RC0
- [ ] Tag RC1
- [ ] Tag Final

## Backports
""")

# No version specified (defaults to full template)
default_template = load_release_tracking_template(template_path=template_file)
assert "- [ ] Tag RC0" in default_template

# Minor release version (keeps RC tasks)
full_template = load_release_tracking_template(
version="1.2.0", template_path=template_file
)
assert "- [ ] Tag RC0" in full_template
assert "- [ ] Tag RC1" in full_template

# Patch release version (strips RC tasks)
patch_template = load_release_tracking_template(
version="1.2.1", template_path=template_file
)
assert "Tag RC" not in patch_template
assert "- [ ] Prepare Release" in patch_template
assert "- [ ] Tag Final" in patch_template
15 changes: 15 additions & 0 deletions tests/tools/private/release/utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,18 @@ def test_determine_next_version_ignores_agents_markers(mocker, release_tool_env)
next_version = utils.determine_next_version()

assert next_version == "1.2.4"


def test_determine_next_version_on_main_with_is_patch(mocker, release_tool_env):
mocker.patch(
"tools.private.release.git.Git.get_current_branch", return_value="main"
)
mocker.patch("tools.private.release.utils.get_latest_version", return_value="1.2.3")
(release_tool_env.git_root / "mock_file.bzl").write_text(
":::{versionadded} VERSION_NEXT_FEATURE"
)

# Without is_patch, feature marker causes minor bump
assert utils.determine_next_version(is_patch=False) == "1.3.0"
# With is_patch=True, it produces a patch bump
assert utils.determine_next_version(is_patch=True) == "1.2.4"
67 changes: 51 additions & 16 deletions tools/private/release/add_backports.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
"""Subcommand to add PRs to the release tracking issue backports checklist."""
import os

from tools.private.release.gh import GitHub
from tools.private.release.git import Git
from tools.private.release.release_issue import (
RELEASE_TITLE_RE,
add_backports_to_body,
add_rc_task_to_body,
add_sync_changelog_task_to_body,
load_release_tracking_template,
parse_checklist_state,
)
from tools.private.release.utils import determine_next_version


class AddBackports:
"""Class to add PRs to the release tracking issue."""

def __init__(self, args, gh: GitHub):
def __init__(self, args, gh: GitHub, git: Git | None = None):
self.args = args
self.gh = gh
self.git = git or Git(os.getcwd())

def run(self) -> int:
"""Executes the add-backports subcommand."""
Expand All @@ -28,21 +33,40 @@ def run(self) -> int:
)
try:
open_issues = self.gh.get_open_tracking_issues()
if not open_issues:
print("Error: No open release tracking issues found.")
return 1
if len(open_issues) > 1:
print(
"Error: Multiple open release tracking issues found."
"::error::Multiple open release tracking issues found."
" Cannot determine active one:"
)
for issue in open_issues:
print(f"- #{issue['number']}: {issue['title']}")
return 1
issue_num = open_issues[0]["number"]
print(f"Auto-discovered active release tracking issue: #{issue_num}")
elif len(open_issues) == 1:
issue_num = open_issues[0]["number"]
print(
f"Auto-discovered active release tracking issue: #{issue_num}"
)
else:
print(
"No open release tracking issue found. Creating a new"
" patch release tracking issue..."
)
patch_version = determine_next_version(git=self.git, is_patch=True)
template_content = load_release_tracking_template(
version=patch_version
)

issue_num = self.gh.create_release_tracking_issue(
patch_version, template_content
)
print(
f"::notice::Created patch release tracking issue #{issue_num} for"
f" v{patch_version}"
)
except Exception as e:
print(f"Error auto-discovering tracking issue: {e}")
print(
f"::error::Error auto-discovering or creating tracking issue: {e}"
)
return 1

resolved_prs = []
Expand All @@ -51,7 +75,7 @@ def run(self) -> int:
pr_num = self.gh.resolve_pr_number(pr_ref)
resolved_prs.append(pr_num)
except Exception as e:
print(f"Error resolving PR ref '{pr_ref}': {e}")
print(f"::error::Error resolving PR ref '{pr_ref}': {e}")
return 1

print(
Expand All @@ -69,24 +93,34 @@ def run(self) -> int:
not task.checked and task.status != "done" for task in rc_tags.values()
)
next_rc_num = max(rc_tags.keys()) + 1 if rc_tags else 0
if not has_pending_rc:

issue_title = self.gh.get_issue_title(issue_num)
version_match = RELEASE_TITLE_RE.search(issue_title)
is_patch = False
if version_match:
version = version_match.group(1)
is_patch = not version.endswith(".0")

if not has_pending_rc and (rc_tags or not is_patch):
print(
f"No pending RC task found. Adding 'Tag"
f" RC{next_rc_num}' to checklist..."
)
body = add_rc_task_to_body(body, next_rc_num)
except ValueError as e:
print(f"Error: {e}")
print(f"::error::{e}")
return 1
except Exception as e:
print(f"Failed to update tracking issue: {e}")
print(f"::error::Failed to update tracking issue: {e}")
return 1

try:
self.gh.update_issue_body(issue_num, body)
print("Successfully updated tracking issue checklist.")
print(
f"::notice::Successfully updated tracking issue #{issue_num} checklist."
)
except Exception as e:
print(f"Failed to update tracking issue body: {e}")
print(f"::error::Failed to update tracking issue body: {e}")
return 1

return 0
Expand Down Expand Up @@ -115,4 +149,5 @@ def add_parser(cls, subparsers):
def run_from_args(cls, args):
"""Instantiates and runs the command from parsed args."""
gh = GitHub()
return cls(args, gh).run()
git = Git(os.getcwd())
return cls(args, gh, git).run()
25 changes: 2 additions & 23 deletions tools/private/release/backport_create_releases.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Subcommand to initiate releases for verified backports."""

import argparse
import pathlib
import re
from dataclasses import dataclass

Expand All @@ -10,6 +9,7 @@
from tools.private.release.release_issue import (
add_backports_to_body,
add_sync_changelog_task_to_body,
load_release_tracking_template,
parse_metadata_line,
update_task_in_body,
)
Expand Down Expand Up @@ -70,14 +70,6 @@ def is_release_eligible(version, target_minors, verify_statuses):
return True, "Eligible"


def _load_release_template() -> str:
"""Loads the release tracking issue template."""
template_path = pathlib.Path(".github/ISSUE_TEMPLATE/release_tracking_template.md")
if not template_path.exists():
raise FileNotFoundError(f"Template file not found at {template_path}")
return template_path.read_text(encoding="utf-8")


class BackportCreateReleases:
"""Class to initiate releases for verified backports."""

Expand Down Expand Up @@ -112,9 +104,6 @@ def run(self) -> int:
list(verify_statuses.keys()), key=lambda m: [int(x) for x in m.split(".")]
)

# We need the templates for release issues
template_content = _load_release_template()

updated_body = body
changes_made = False

Expand Down Expand Up @@ -144,17 +133,7 @@ def run(self) -> int:
)
else:
# Create the issue
is_first_release = version.endswith(".0")
if is_first_release:
issue_template = template_content
else:
lines = template_content.splitlines()
lines = [
line for line in lines if not re.search(r"Tag RC\d+", line)
]
issue_template = "\n".join(lines)
if template_content.endswith("\n"):
issue_template += "\n"
issue_template = load_release_tracking_template(version=version)

if args.dry_run:
print(
Expand Down
18 changes: 2 additions & 16 deletions tools/private/release/create_release_issue.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
"""Subcommand to create a release tracking issue."""

import pathlib
import re

from tools.private.release.gh import GitHub
from tools.private.release.release_issue import load_release_tracking_template
from tools.private.release.utils import determine_next_version, semver_type


Expand All @@ -28,19 +26,7 @@ def run(self) -> int:
print(f"- {issue['title']}: {issue['url']}")
return 1

template_path = pathlib.Path(
".github/ISSUE_TEMPLATE/release_tracking_template.md"
)
if not template_path.exists():
raise FileNotFoundError(f"Template file not found at {template_path}")
template_content = template_path.read_text(encoding="utf-8")

is_first_release = version.endswith(".0")
if not is_first_release:
# Patch release: remove RC tasks
lines = template_content.splitlines()
lines = [line for line in lines if not re.search(r"Tag RC\d+", line)]
template_content = "\n".join(lines)
template_content = load_release_tracking_template(version=version)

issue_num = self.gh.create_release_tracking_issue(version, template_content)
print(f"Created tracking issue #{issue_num} for v{version}")
Expand Down
11 changes: 2 additions & 9 deletions tools/private/release/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import argparse
import datetime
import pathlib

from tools.private.release import changelog_news
from tools.private.release.gh import (
Expand All @@ -13,6 +12,7 @@
)
from tools.private.release.git import Git
from tools.private.release.release_issue import (
load_release_tracking_template,
parse_checklist_state,
update_task_in_body,
)
Expand Down Expand Up @@ -68,14 +68,7 @@ def run(self) -> int:
return 1
except NoTrackingIssueError:
# Not found, we need the template
template_path = pathlib.Path(
".github/ISSUE_TEMPLATE/release_tracking_template.md"
)
if not template_path.exists():
raise FileNotFoundError(
f"Template file not found at {template_path}"
)
template_content = template_path.read_text(encoding="utf-8")
template_content = load_release_tracking_template(version=version)

if args.dry_run:
print(
Expand Down
Loading