Skip to content

Commit 03a2c23

Browse files
authored
workflows: auto-create patch release tracking issue on /backport (#4046)
Previously, commenting `/backport` on a PR failed if no release tracking issue was open, requiring maintainers to manually create one first. When auto-discovering tracking issues finds no open release issue, automatically create a new patch release tracking issue for the next patch version and add the requested backports to it. Also centralize release tracking template loading and RC task stripping for patch releases into a shared helper function, prevent RC tasks from being added to patch release tracking issues, and format workflow log messages with GitHub Actions annotations.
1 parent ed2c1bd commit 03a2c23

10 files changed

Lines changed: 195 additions & 76 deletions

File tree

RELEASING.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,10 @@ methods:
9696
### Method A: Comment on the PR
9797

9898
Comment `/backport` on the PR you wish to backport. This will automatically
99-
add the PR to the active release's backports checklist. Once the PR is merged,
100-
the backports will be automatically processed.
99+
add the PR to the active release's backports checklist, or automatically create
100+
a patch release tracking issue for the next patch version if no release tracking
101+
issue currently exists. Once the PR is merged, the backports will be
102+
automatically processed.
101103
102104
> [!NOTE]
103105
> Commenting `/backport` on an open PR will block further release publishing

tests/tools/private/release/add_backports_test.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,50 @@ def test_add_backports_auto_discover_success(mock_gh):
5353
assert "- [ ] #124" in updated_body
5454

5555

56-
def test_add_backports_auto_discover_no_issues(mock_gh):
56+
def test_add_backports_auto_discover_no_issues_creates_patch_release(
57+
mock_gh, mock_git, release_tool_env
58+
):
59+
mock_git.get_tags.return_value = ["1.0.0", "1.2.0"]
60+
mock_git.get_current_branch.return_value = "main"
61+
5762
args = argparse.Namespace(issue=None, prs=["124"])
5863

64+
result = AddBackports(args, mock_gh, mock_git).run()
65+
66+
assert result == 0
67+
open_issues = mock_gh.get_open_tracking_issues()
68+
assert len(open_issues) == 1
69+
issue = open_issues[0]
70+
assert issue["title"] == "Release 1.2.1"
71+
body = issue["body"]
72+
assert "- [ ] #124" in body
73+
assert "- [ ] Sync Changelog #124" in body
74+
assert "Tag RC" not in body
75+
76+
77+
def test_add_backports_patch_release_no_rc_added(mock_gh):
78+
args = argparse.Namespace(issue=123, prs=["124"])
79+
mock_gh.issues[123] = {
80+
"title": "Release 1.2.1",
81+
"body": """
82+
## Checklist
83+
- [ ] Prepare Release
84+
- [ ] Create Release branch
85+
- [ ] Tag Final
86+
87+
## Backports
88+
""",
89+
"labels": ["type: release"],
90+
"number": 123,
91+
"url": "https://github.com/bazel-contrib/rules_python/issues/123",
92+
}
5993
result = AddBackports(args, mock_gh).run()
6094

61-
assert result == 1
95+
assert result == 0
96+
updated_body = mock_gh.get_issue_body(123)
97+
assert "- [ ] #124" in updated_body
98+
assert "- [ ] Sync Changelog #124" in updated_body
99+
assert "Tag RC" not in updated_body
62100

63101

64102
def test_add_backports_auto_discover_multiple_issues(mock_gh):

tests/tools/private/release/release_issue_test.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
add_backports_to_body,
33
add_sync_changelog_task_to_body,
44
format_metadata_line,
5+
load_release_tracking_template,
56
parse_checklist_state,
67
parse_metadata_line,
78
)
@@ -155,3 +156,35 @@ def test_parse_checklist_state_with_sync_changelogs():
155156
assert not task_126.checked
156157
assert task_126.status is None
157158
assert task_126.pr is None
159+
160+
161+
def test_load_release_tracking_template(tmp_path):
162+
template_file = tmp_path / "template.md"
163+
template_file.write_text("""## Checklist
164+
- [ ] Prepare Release
165+
- [ ] Create Release branch
166+
- [ ] Tag RC0
167+
- [ ] Tag RC1
168+
- [ ] Tag Final
169+
170+
## Backports
171+
""")
172+
173+
# No version specified (defaults to full template)
174+
default_template = load_release_tracking_template(template_path=template_file)
175+
assert "- [ ] Tag RC0" in default_template
176+
177+
# Minor release version (keeps RC tasks)
178+
full_template = load_release_tracking_template(
179+
version="1.2.0", template_path=template_file
180+
)
181+
assert "- [ ] Tag RC0" in full_template
182+
assert "- [ ] Tag RC1" in full_template
183+
184+
# Patch release version (strips RC tasks)
185+
patch_template = load_release_tracking_template(
186+
version="1.2.1", template_path=template_file
187+
)
188+
assert "Tag RC" not in patch_template
189+
assert "- [ ] Prepare Release" in patch_template
190+
assert "- [ ] Tag Final" in patch_template

tests/tools/private/release/utils_test.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,3 +311,18 @@ def test_determine_next_version_ignores_agents_markers(mocker, release_tool_env)
311311
next_version = utils.determine_next_version()
312312

313313
assert next_version == "1.2.4"
314+
315+
316+
def test_determine_next_version_on_main_with_is_patch(mocker, release_tool_env):
317+
mocker.patch(
318+
"tools.private.release.git.Git.get_current_branch", return_value="main"
319+
)
320+
mocker.patch("tools.private.release.utils.get_latest_version", return_value="1.2.3")
321+
(release_tool_env.git_root / "mock_file.bzl").write_text(
322+
":::{versionadded} VERSION_NEXT_FEATURE"
323+
)
324+
325+
# Without is_patch, feature marker causes minor bump
326+
assert utils.determine_next_version(is_patch=False) == "1.3.0"
327+
# With is_patch=True, it produces a patch bump
328+
assert utils.determine_next_version(is_patch=True) == "1.2.4"

tools/private/release/add_backports.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
1-
"""Subcommand to add PRs to the release tracking issue backports checklist."""
1+
import os
22

33
from tools.private.release.gh import GitHub
4+
from tools.private.release.git import Git
45
from tools.private.release.release_issue import (
6+
RELEASE_TITLE_RE,
57
add_backports_to_body,
68
add_rc_task_to_body,
79
add_sync_changelog_task_to_body,
10+
load_release_tracking_template,
811
parse_checklist_state,
912
)
13+
from tools.private.release.utils import determine_next_version
1014

1115

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

15-
def __init__(self, args, gh: GitHub):
19+
def __init__(self, args, gh: GitHub, git: Git | None = None):
1620
self.args = args
1721
self.gh = gh
22+
self.git = git or Git(os.getcwd())
1823

1924
def run(self) -> int:
2025
"""Executes the add-backports subcommand."""
@@ -28,21 +33,40 @@ def run(self) -> int:
2833
)
2934
try:
3035
open_issues = self.gh.get_open_tracking_issues()
31-
if not open_issues:
32-
print("Error: No open release tracking issues found.")
33-
return 1
3436
if len(open_issues) > 1:
3537
print(
36-
"Error: Multiple open release tracking issues found."
38+
"::error::Multiple open release tracking issues found."
3739
" Cannot determine active one:"
3840
)
3941
for issue in open_issues:
4042
print(f"- #{issue['number']}: {issue['title']}")
4143
return 1
42-
issue_num = open_issues[0]["number"]
43-
print(f"Auto-discovered active release tracking issue: #{issue_num}")
44+
elif len(open_issues) == 1:
45+
issue_num = open_issues[0]["number"]
46+
print(
47+
f"Auto-discovered active release tracking issue: #{issue_num}"
48+
)
49+
else:
50+
print(
51+
"No open release tracking issue found. Creating a new"
52+
" patch release tracking issue..."
53+
)
54+
patch_version = determine_next_version(git=self.git, is_patch=True)
55+
template_content = load_release_tracking_template(
56+
version=patch_version
57+
)
58+
59+
issue_num = self.gh.create_release_tracking_issue(
60+
patch_version, template_content
61+
)
62+
print(
63+
f"::notice::Created patch release tracking issue #{issue_num} for"
64+
f" v{patch_version}"
65+
)
4466
except Exception as e:
45-
print(f"Error auto-discovering tracking issue: {e}")
67+
print(
68+
f"::error::Error auto-discovering or creating tracking issue: {e}"
69+
)
4670
return 1
4771

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

5781
print(
@@ -69,24 +93,34 @@ def run(self) -> int:
6993
not task.checked and task.status != "done" for task in rc_tags.values()
7094
)
7195
next_rc_num = max(rc_tags.keys()) + 1 if rc_tags else 0
72-
if not has_pending_rc:
96+
97+
issue_title = self.gh.get_issue_title(issue_num)
98+
version_match = RELEASE_TITLE_RE.search(issue_title)
99+
is_patch = False
100+
if version_match:
101+
version = version_match.group(1)
102+
is_patch = not version.endswith(".0")
103+
104+
if not has_pending_rc and (rc_tags or not is_patch):
73105
print(
74106
f"No pending RC task found. Adding 'Tag"
75107
f" RC{next_rc_num}' to checklist..."
76108
)
77109
body = add_rc_task_to_body(body, next_rc_num)
78110
except ValueError as e:
79-
print(f"Error: {e}")
111+
print(f"::error::{e}")
80112
return 1
81113
except Exception as e:
82-
print(f"Failed to update tracking issue: {e}")
114+
print(f"::error::Failed to update tracking issue: {e}")
83115
return 1
84116

85117
try:
86118
self.gh.update_issue_body(issue_num, body)
87-
print("Successfully updated tracking issue checklist.")
119+
print(
120+
f"::notice::Successfully updated tracking issue #{issue_num} checklist."
121+
)
88122
except Exception as e:
89-
print(f"Failed to update tracking issue body: {e}")
123+
print(f"::error::Failed to update tracking issue body: {e}")
90124
return 1
91125

92126
return 0
@@ -115,4 +149,5 @@ def add_parser(cls, subparsers):
115149
def run_from_args(cls, args):
116150
"""Instantiates and runs the command from parsed args."""
117151
gh = GitHub()
118-
return cls(args, gh).run()
152+
git = Git(os.getcwd())
153+
return cls(args, gh, git).run()

tools/private/release/backport_create_releases.py

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Subcommand to initiate releases for verified backports."""
22

33
import argparse
4-
import pathlib
54
import re
65
from dataclasses import dataclass
76

@@ -10,6 +9,7 @@
109
from tools.private.release.release_issue import (
1110
add_backports_to_body,
1211
add_sync_changelog_task_to_body,
12+
load_release_tracking_template,
1313
parse_metadata_line,
1414
update_task_in_body,
1515
)
@@ -70,14 +70,6 @@ def is_release_eligible(version, target_minors, verify_statuses):
7070
return True, "Eligible"
7171

7272

73-
def _load_release_template() -> str:
74-
"""Loads the release tracking issue template."""
75-
template_path = pathlib.Path(".github/ISSUE_TEMPLATE/release_tracking_template.md")
76-
if not template_path.exists():
77-
raise FileNotFoundError(f"Template file not found at {template_path}")
78-
return template_path.read_text(encoding="utf-8")
79-
80-
8173
class BackportCreateReleases:
8274
"""Class to initiate releases for verified backports."""
8375

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

115-
# We need the templates for release issues
116-
template_content = _load_release_template()
117-
118107
updated_body = body
119108
changes_made = False
120109

@@ -144,17 +133,7 @@ def run(self) -> int:
144133
)
145134
else:
146135
# Create the issue
147-
is_first_release = version.endswith(".0")
148-
if is_first_release:
149-
issue_template = template_content
150-
else:
151-
lines = template_content.splitlines()
152-
lines = [
153-
line for line in lines if not re.search(r"Tag RC\d+", line)
154-
]
155-
issue_template = "\n".join(lines)
156-
if template_content.endswith("\n"):
157-
issue_template += "\n"
136+
issue_template = load_release_tracking_template(version=version)
158137

159138
if args.dry_run:
160139
print(

tools/private/release/create_release_issue.py

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
"""Subcommand to create a release tracking issue."""
22

3-
import pathlib
4-
import re
5-
63
from tools.private.release.gh import GitHub
4+
from tools.private.release.release_issue import load_release_tracking_template
75
from tools.private.release.utils import determine_next_version, semver_type
86

97

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

31-
template_path = pathlib.Path(
32-
".github/ISSUE_TEMPLATE/release_tracking_template.md"
33-
)
34-
if not template_path.exists():
35-
raise FileNotFoundError(f"Template file not found at {template_path}")
36-
template_content = template_path.read_text(encoding="utf-8")
37-
38-
is_first_release = version.endswith(".0")
39-
if not is_first_release:
40-
# Patch release: remove RC tasks
41-
lines = template_content.splitlines()
42-
lines = [line for line in lines if not re.search(r"Tag RC\d+", line)]
43-
template_content = "\n".join(lines)
29+
template_content = load_release_tracking_template(version=version)
4430

4531
issue_num = self.gh.create_release_tracking_issue(version, template_content)
4632
print(f"Created tracking issue #{issue_num} for v{version}")

tools/private/release/prepare.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import argparse
44
import datetime
5-
import pathlib
65

76
from tools.private.release import changelog_news
87
from tools.private.release.gh import (
@@ -13,6 +12,7 @@
1312
)
1413
from tools.private.release.git import Git
1514
from tools.private.release.release_issue import (
15+
load_release_tracking_template,
1616
parse_checklist_state,
1717
update_task_in_body,
1818
)
@@ -68,14 +68,7 @@ def run(self) -> int:
6868
return 1
6969
except NoTrackingIssueError:
7070
# Not found, we need the template
71-
template_path = pathlib.Path(
72-
".github/ISSUE_TEMPLATE/release_tracking_template.md"
73-
)
74-
if not template_path.exists():
75-
raise FileNotFoundError(
76-
f"Template file not found at {template_path}"
77-
)
78-
template_content = template_path.read_text(encoding="utf-8")
71+
template_content = load_release_tracking_template(version=version)
7972

8073
if args.dry_run:
8174
print(

0 commit comments

Comments
 (0)