From b0e8f446185337813d308ebbf3cd2e4d47ed3a36 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 14 Aug 2026 17:45:58 +0000 Subject: [PATCH] workflows: auto-create patch release tracking issue on /backport Allow commenting `/backport` on a pending PR to automatically create a patch release tracking issue if no active release tracking issue exists. Refactor release template loading into a reusable helper and update add-backports to use GitHub Actions workflow notice/error formatting. --- RELEASING.md | 6 +- .../private/release/add_backports_test.py | 42 +++++++++++- .../private/release/release_issue_test.py | 33 +++++++++ tests/tools/private/release/utils_test.py | 15 +++++ tools/private/release/add_backports.py | 67 ++++++++++++++----- .../release/backport_create_releases.py | 25 +------ tools/private/release/create_release_issue.py | 18 +---- tools/private/release/prepare.py | 11 +-- tools/private/release/release_issue.py | 35 ++++++++++ tools/private/release/utils.py | 19 +++--- 10 files changed, 195 insertions(+), 76 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index f16b13e582..6749e2f10b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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 diff --git a/tests/tools/private/release/add_backports_test.py b/tests/tools/private/release/add_backports_test.py index c1c7b5cc0a..b4f2efc583 100644 --- a/tests/tools/private/release/add_backports_test.py +++ b/tests/tools/private/release/add_backports_test.py @@ -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): diff --git a/tests/tools/private/release/release_issue_test.py b/tests/tools/private/release/release_issue_test.py index 869a303ebf..64fa2b5c0c 100644 --- a/tests/tools/private/release/release_issue_test.py +++ b/tests/tools/private/release/release_issue_test.py @@ -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, ) @@ -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 diff --git a/tests/tools/private/release/utils_test.py b/tests/tools/private/release/utils_test.py index d39b5e108e..48abbad8d4 100644 --- a/tests/tools/private/release/utils_test.py +++ b/tests/tools/private/release/utils_test.py @@ -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" diff --git a/tools/private/release/add_backports.py b/tools/private/release/add_backports.py index 63c70af7d7..a24e02d567 100644 --- a/tools/private/release/add_backports.py +++ b/tools/private/release/add_backports.py @@ -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.""" @@ -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 = [] @@ -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( @@ -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 @@ -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() diff --git a/tools/private/release/backport_create_releases.py b/tools/private/release/backport_create_releases.py index a6d2beabdf..1a9faafc3d 100644 --- a/tools/private/release/backport_create_releases.py +++ b/tools/private/release/backport_create_releases.py @@ -1,7 +1,6 @@ """Subcommand to initiate releases for verified backports.""" import argparse -import pathlib import re from dataclasses import dataclass @@ -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, ) @@ -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.""" @@ -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 @@ -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( diff --git a/tools/private/release/create_release_issue.py b/tools/private/release/create_release_issue.py index 72b71aa716..0777941f2a 100644 --- a/tools/private/release/create_release_issue.py +++ b/tools/private/release/create_release_issue.py @@ -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 @@ -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}") diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index 00412a8600..28783cb927 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -2,7 +2,6 @@ import argparse import datetime -import pathlib from tools.private.release import changelog_news from tools.private.release.gh import ( @@ -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, ) @@ -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( diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 9c73d653d5..c8f2f9d1ff 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -1,7 +1,42 @@ +import pathlib import re from typing import Any +def load_release_tracking_template( + version: str | None = None, + template_path: pathlib.Path | None = None, +) -> str: + """Loads the release tracking issue template, stripping RC tasks for patch releases. + + Args: + version: Optional version string (e.g. '1.2.1'). If provided and represents a + patch release (i.e. does not end in '.0'), strips Tag RC tasks from the template. + template_path: Optional path to the template file. Defaults to + .github/ISSUE_TEMPLATE/release_tracking_template.md. + + Returns: + The template content string. + """ + if template_path is None: + 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_patch = version is not None and not version.endswith(".0") + if is_patch: + lines = template_content.splitlines() + lines = [line for line in lines if not re.search(r"Tag RC\d+", line)] + template_content = "\n".join(lines) + if not template_content.endswith("\n"): + template_content += "\n" + + return template_content + + class BackportTask: """Represents a backport task from the tracking issue checklist.""" diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index ab1e0acef3..244138062a 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -55,9 +55,10 @@ def _iter_version_placeholder_files(): yield filepath -def get_latest_version(): +def get_latest_version(git=None): """Gets the latest version from git tags.""" - git = Git(".") + if git is None: + git = Git(os.getcwd()) tags = git.get_tags() versions = [ (tag, parse_version(tag)) @@ -80,9 +81,10 @@ def get_latest_version(): return stable_versions[-1] -def get_latest_rc_tag(version, remote=None): +def get_latest_rc_tag(version, remote=None, git=None): """Queries git tags and returns the highest RC tag for the version.""" - git = Git(".") + if git is None: + git = Git(os.getcwd()) if remote: tags = git.get_remote_tags(remote) else: @@ -109,9 +111,10 @@ def should_increment_minor(): return False -def determine_next_version(branch_name=None): +def determine_next_version(branch_name=None, git=None, is_patch=False): """Determines the next version based on git tags and the current branch.""" - git = Git(".") + if git is None: + git = Git(os.getcwd()) if branch_name is None: branch_name = git.get_current_branch() @@ -150,10 +153,10 @@ def determine_next_version(branch_name=None): ) return next_version - latest_version = get_latest_version() + latest_version = get_latest_version(git=git) major, minor, patch = [int(n) for n in latest_version.split(".")] - if should_increment_minor(): + if not is_patch and should_increment_minor(): return f"{major}.{minor + 1}.0" else: return f"{major}.{minor}.{patch + 1}"