Skip to content

Commit 12ce31b

Browse files
authored
chore(release): auto-add RC tasks to checklist in create-rc (bazel-contrib#3894)
Modify create-rc subcommand to automatically insert missing Tag RC<N> tasks into the tracking issue checklist before Tag Final, rather than failing.
1 parent e3c8867 commit 12ce31b

5 files changed

Lines changed: 157 additions & 13 deletions

File tree

tests/tools/private/release/create_rc_test.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import argparse
12
import os
23
import pathlib
34
import tempfile
@@ -212,6 +213,46 @@ def test_create_rc_with_finished_backports(self):
212213
self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0")
213214
self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0")
214215

216+
def test_create_rc_auto_add_task(self):
217+
# Arrange
218+
args = argparse.Namespace(issue=123, remote="my-remote")
219+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
220+
self.mock_gh.get_issue_body.return_value = """
221+
## Checklist
222+
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
223+
- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12
224+
- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12
225+
- [ ] Tag Final
226+
"""
227+
self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"]
228+
self.mock_git.get_commit_sha.return_value = "1234567890"
229+
230+
# Act
231+
result = CreateRc(args, self.mock_git, self.mock_gh).run()
232+
233+
# Assert
234+
self.assertEqual(result, 0)
235+
self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0")
236+
self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1")
237+
238+
self.assertEqual(self.mock_gh.update_issue_body.call_count, 2)
239+
call1_args = self.mock_gh.update_issue_body.call_args_list[0][0]
240+
call2_args = self.mock_gh.update_issue_body.call_args_list[1][0]
241+
242+
self.assertEqual(call1_args[0], 123)
243+
self.assertIn("- [ ] Tag RC1", call1_args[1])
244+
self.assertIn(
245+
"- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12\n- [ ]"
246+
" Tag RC1\n- [ ] Tag Final",
247+
call1_args[1].strip(),
248+
)
249+
250+
self.assertEqual(call2_args[0], 123)
251+
self.assertIn(
252+
"- [x] Tag RC1 | status=done tag=2.0.0-rc1 commit= 12345678",
253+
call2_args[1],
254+
)
255+
215256

216257
if __name__ == "__main__":
217258
unittest.main()

tests/tools/private/release/process_backports_test.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,64 @@ def mock_resolve(items):
247247
self.mock_git.commit.assert_not_called()
248248
self.mock_git.push.assert_not_called()
249249

250+
@patch("tools.private.release.process_backports.datetime")
251+
def test_process_backports_add_backports_and_auto_add_rc_task(self, mock_datetime):
252+
mock_datetime.date.today.return_value = datetime.date(2026, 7, 1)
253+
args = argparse.Namespace(
254+
issue=123,
255+
remote="origin",
256+
dry_run=False,
257+
add=[124],
258+
triggering_comment=None,
259+
)
260+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
261+
self.mock_gh.get_issue_body.return_value = """
262+
## Checklist
263+
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
264+
- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12
265+
- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12
266+
- [ ] Tag Final
267+
268+
## Backports
269+
"""
270+
self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"]
271+
self.mock_git.get_commit_sha.return_value = "12345678"
272+
self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"'
273+
274+
def mock_resolve(items):
275+
for item in items:
276+
if item.pr_ref == "#124":
277+
item.commit = "abcdef12"
278+
item.status = "done"
279+
return items
280+
281+
self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve
282+
self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"]
283+
284+
result = ProcessBackports(args, self.mock_git, self.mock_gh).run()
285+
286+
self.assertEqual(result, 0)
287+
288+
# update_issue_body should be called twice:
289+
# 1. When adding backports and auto-adding Tag RC1 task.
290+
# 2. When updating the backport status to done.
291+
self.assertEqual(self.mock_gh.update_issue_body.call_count, 2)
292+
293+
call1_args = self.mock_gh.update_issue_body.call_args_list[0][0]
294+
call2_args = self.mock_gh.update_issue_body.call_args_list[1][0]
295+
296+
self.assertEqual(call1_args[0], 123)
297+
self.assertIn("- [ ] #124", call1_args[1])
298+
self.assertIn("- [ ] Tag RC1", call1_args[1])
299+
self.assertIn(
300+
"- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12\n- [ ]"
301+
" Tag RC1\n- [ ] Tag Final",
302+
call1_args[1].strip(),
303+
)
304+
305+
self.assertEqual(call2_args[0], 123)
306+
self.assertIn("- [x] #124 | status=done rc=rc1 commit= 12345678", call2_args[1])
307+
250308

251309
if __name__ == "__main__":
252310
unittest.main()

tools/private/release/create_rc.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from tools.private.release.git import Git
55
from tools.private.release.release_issue import (
66
RELEASE_TITLE_RE,
7+
add_rc_task_to_body,
78
parse_backports,
89
parse_checklist_state,
910
update_task_in_body,
@@ -77,19 +78,17 @@ def run(self) -> int:
7778
# Precheck: next RC number must exist and be unchecked in the checklist
7879
rc_tags = state.get("rc_tags", {})
7980
if next_rc_num not in rc_tags:
80-
print(
81-
f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'"
82-
f" to cut {version}-rc{next_rc_num}."
83-
)
84-
return 1
85-
86-
target_rc_task = rc_tags[next_rc_num]
87-
if target_rc_task.checked or target_rc_task.status == "done":
88-
print(
89-
f"Error: Task 'Tag RC{next_rc_num}' is already marked done in"
90-
" the checklist."
91-
)
92-
return 1
81+
print(f"Task 'Tag RC{next_rc_num}' not found in checklist. Adding it...")
82+
body = add_rc_task_to_body(body, next_rc_num)
83+
self.gh.update_issue_body(args.issue, body)
84+
else:
85+
target_rc_task = rc_tags[next_rc_num]
86+
if target_rc_task.checked or target_rc_task.status == "done":
87+
print(
88+
f"Error: Task 'Tag RC{next_rc_num}' is already marked done in"
89+
" the checklist."
90+
)
91+
return 1
9392

9493
target_ref = f"{args.remote}/{branch_name}"
9594
commit_sha = self.git.get_commit_sha(target_ref)

tools/private/release/process_backports.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from tools.private.release.release_issue import (
1111
RELEASE_TITLE_RE,
1212
add_backports_to_body,
13+
add_rc_task_to_body,
1314
parse_backports,
15+
parse_checklist_state,
1416
update_task_in_body,
1517
)
1618
from tools.private.release.utils import (
@@ -207,6 +209,19 @@ def _run_internal(self) -> int:
207209
print(f"Adding backports {args.add} to tracking issue #{args.issue}...")
208210
try:
209211
body = add_backports_to_body(body, args.add)
212+
state = parse_checklist_state(body)
213+
rc_tags = state.get("rc_tags", {})
214+
has_pending_rc = any(
215+
not task.checked and task.status != "done"
216+
for task in rc_tags.values()
217+
)
218+
next_rc_num = max(rc_tags.keys()) + 1 if rc_tags else 0
219+
if not has_pending_rc:
220+
print(
221+
f"No pending RC task found. Adding 'Tag"
222+
f" RC{next_rc_num}' to checklist..."
223+
)
224+
body = add_rc_task_to_body(body, next_rc_num)
210225
except ValueError as e:
211226
print(f"Error: {e}")
212227
return 1
@@ -219,6 +234,8 @@ def _run_internal(self) -> int:
219234
"[DRY RUN] Would update tracking issue checklist with new"
220235
" backports."
221236
)
237+
if not has_pending_rc:
238+
print(f"[DRY RUN] Would add 'Tag RC{next_rc_num}' to checklist.")
222239

223240
items = parse_backports(body)
224241

tools/private/release/release_issue.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,32 @@ def add_backports_to_body(body: str, prs: list[int]) -> str:
284284
# Replace the old section with the updated one
285285
start, end = match.span(2)
286286
return body[:start] + updated_section + body[end:]
287+
288+
289+
def add_rc_task_to_body(body: str, rc_num: int) -> str:
290+
"""Adds a new 'Tag RC<rc_num>' task to the checklist in the issue body."""
291+
body = body.replace("\r\n", "\n")
292+
lines = body.splitlines()
293+
294+
# Find the index of the last "Tag RC<M>" line
295+
last_rc_idx = -1
296+
for i, line in enumerate(lines):
297+
parsed = parse_metadata_line(line)
298+
if parsed and re.match(r"Tag RC\d+", parsed["name"], re.IGNORECASE):
299+
last_rc_idx = i
300+
301+
if last_rc_idx == -1:
302+
# If no RC task found (unexpected, but fallback to before "Tag Final")
303+
for i, line in enumerate(lines):
304+
parsed = parse_metadata_line(line)
305+
if parsed and parsed["name"].lower() == "tag final":
306+
last_rc_idx = i - 1
307+
break
308+
309+
if last_rc_idx == -1:
310+
raise ValueError("Could not find a place to insert the new RC task.")
311+
312+
new_task_line = f"- [ ] Tag RC{rc_num}"
313+
lines.insert(last_rc_idx + 1, new_task_line)
314+
315+
return "\n".join(lines)

0 commit comments

Comments
 (0)