Skip to content

Commit cc799f1

Browse files
authored
chore(release): refactor process-backports and various release tool fixes (bazel-contrib#3881)
This PR refactors the `process-backports` subcommand, improves its dry-run validation, and fixes a bug in `create-rc`. ### Why the changes are made - The `process-backports` logic was previously embedded in the main `release.py` script, making it difficult to maintain and extend. - `process-backports` lacked a robust dry-run validation to safely verify backport applicability without leaving the workspace dirty. - `process-backports` could fail with checkout ambiguity if the release branch existed on multiple remotes. - `create-rc` crashed with a fatal subscripting error when trying to access `BackportTask` objects as dictionaries after their recent refactoring. - `create-rc` tracking issue comments lacked a direct link to the BCR entry for the new version.
1 parent 30a0583 commit cc799f1

8 files changed

Lines changed: 724 additions & 169 deletions

File tree

tests/tools/private/release/release_test.py

Lines changed: 335 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import datetime
12
import os
23
import pathlib
34
import shutil
45
import tempfile
56
import unittest
67
from unittest.mock import MagicMock, call, patch
78

8-
from tools.private.release import changelog_news, release as releaser, utils
9+
from tools.private.release import changelog_news, git, release as releaser, utils
910
from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError
1011

1112

@@ -20,12 +21,14 @@ def _mock_git_and_gh(test_case):
2021
patch("tools.private.release.prepare.git", new=mock_git).start()
2122
patch("tools.private.release.create_release_branch.git", new=mock_git).start()
2223
patch("tools.private.release.create_rc.git", new=mock_git).start()
24+
patch("tools.private.release.process_backports.git", new=mock_git).start()
2325
patch("tools.private.release.utils.git", new=mock_git).start()
2426

2527
patch("tools.private.release.release.gh", new=mock_gh).start()
2628
patch("tools.private.release.prepare.gh", new=mock_gh).start()
2729
patch("tools.private.release.create_release_branch.gh", new=mock_gh).start()
2830
patch("tools.private.release.create_rc.gh", new=mock_gh).start()
31+
patch("tools.private.release.process_backports.gh", new=mock_gh).start()
2932
mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError
3033
mock_gh.NoTrackingIssueError = NoTrackingIssueError
3134

@@ -984,7 +987,19 @@ def test_create_rc_success_first_rc(self):
984987
comment_call_args[1],
985988
)
986989
self.assertIn(
987-
"- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)",
990+
"- [Github Release 2.0.0-rc0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc0)",
991+
comment_call_args[1],
992+
)
993+
self.assertIn(
994+
"- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)",
995+
comment_call_args[1],
996+
)
997+
self.assertIn(
998+
"- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)",
999+
comment_call_args[1],
1000+
)
1001+
self.assertIn(
1002+
"- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)",
9881003
comment_call_args[1],
9891004
)
9901005
self.assertNotIn("🚀", comment_call_args[1])
@@ -1029,11 +1044,68 @@ def test_create_rc_success_next_rc(self):
10291044
comment_call_args[1],
10301045
)
10311046
self.assertIn(
1032-
"- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)",
1047+
"- [Github Release 2.0.0-rc1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc1)",
1048+
comment_call_args[1],
1049+
)
1050+
self.assertIn(
1051+
"- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)",
1052+
comment_call_args[1],
1053+
)
1054+
self.assertIn(
1055+
"- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)",
1056+
comment_call_args[1],
1057+
)
1058+
self.assertIn(
1059+
"- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)",
10331060
comment_call_args[1],
10341061
)
10351062
self.assertNotIn("🚀", comment_call_args[1])
10361063

1064+
def test_create_rc_gating_on_backports(self):
1065+
# Arrange
1066+
args = MagicMock(issue=123, remote="my-remote")
1067+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1068+
self.mock_gh.get_issue_body.return_value = """
1069+
## Checklist
1070+
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
1071+
- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12
1072+
- [ ] Tag RC0 | status=pending
1073+
1074+
## Backports
1075+
- [ ] #124 | status=pending
1076+
"""
1077+
# Act
1078+
result = releaser.cmd_create_rc(args)
1079+
1080+
# Assert
1081+
self.assertEqual(result, 1)
1082+
self.mock_git.tag.assert_not_called()
1083+
self.mock_git.push.assert_not_called()
1084+
1085+
def test_create_rc_with_finished_backports(self):
1086+
# Arrange
1087+
args = MagicMock(issue=123, remote="my-remote")
1088+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1089+
self.mock_gh.get_issue_body.return_value = """
1090+
## Checklist
1091+
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
1092+
- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12
1093+
- [ ] Tag RC0 | status=pending
1094+
1095+
## Backports
1096+
- [x] #124 | status=done rc=rc0 commit=abcdef12
1097+
"""
1098+
self.mock_git.get_remote_tags.return_value = []
1099+
self.mock_git.get_commit_sha.return_value = "1234567890"
1100+
1101+
# Act
1102+
result = releaser.cmd_create_rc(args)
1103+
1104+
# Assert
1105+
self.assertEqual(result, 0)
1106+
self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0")
1107+
self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0")
1108+
10371109

10381110
class CmdPromoteRcTest(unittest.TestCase):
10391111
def setUp(self):
@@ -1384,5 +1456,265 @@ def test_create_release_branch_already_exists_non_ff(self):
13841456
self.mock_gh.update_issue_body.assert_not_called()
13851457

13861458

1459+
class CmdProcessBackportsTest(unittest.TestCase):
1460+
def setUp(self):
1461+
_mock_git_and_gh(self)
1462+
self.mock_changelog_news = patch(
1463+
"tools.private.release.process_backports.changelog_news"
1464+
).start()
1465+
self.addCleanup(patch.stopall)
1466+
1467+
def test_process_backports_no_pending(self):
1468+
args = MagicMock(issue=123, remote="origin", dry_run=False)
1469+
self.mock_gh.get_issue_body.return_value = "No backports here"
1470+
1471+
result = releaser.cmd_process_backports(args)
1472+
1473+
self.assertEqual(result, 0)
1474+
self.mock_gh.get_issue_body.assert_called_once_with(123)
1475+
self.mock_git.fetch.assert_not_called()
1476+
1477+
@patch("tools.private.release.process_backports.datetime")
1478+
def test_process_backports_success(self, mock_datetime):
1479+
mock_datetime.date.today.return_value = datetime.date(2026, 7, 1)
1480+
args = MagicMock(issue=123, remote="origin", dry_run=False)
1481+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1482+
self.mock_gh.get_issue_body.return_value = """
1483+
## Checklist
1484+
- [ ] Prepare Release
1485+
- [ ] Create Release branch
1486+
1487+
## Backports
1488+
- [ ] #124 | status=pending
1489+
"""
1490+
self.mock_git.get_remote_tags.return_value = []
1491+
1492+
def mock_resolve(items):
1493+
for item in items:
1494+
if item.pr_ref == "#124":
1495+
item.commit = "abcdef12"
1496+
item.status = "done"
1497+
return items
1498+
1499+
self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve
1500+
1501+
self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"]
1502+
self.mock_git.get_commit_sha.return_value = "12345678"
1503+
self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"'
1504+
1505+
result = releaser.cmd_process_backports(args)
1506+
1507+
self.assertEqual(result, 0)
1508+
self.mock_git.fetch.assert_has_calls(
1509+
[call("origin", tags=True, force=True), call("origin")]
1510+
)
1511+
self.mock_git.checkout.assert_called_once_with(
1512+
"release/2.0", track_remote="origin"
1513+
)
1514+
self.mock_git.cherry_pick.assert_called_once_with("abcdef12")
1515+
self.mock_changelog_news.update_changelog.assert_called_once_with(
1516+
"2.0.0", "2026-07-01"
1517+
)
1518+
self.mock_git.add.assert_called_once_with("CHANGELOG.md", "news/")
1519+
self.mock_git.commit.assert_called_once_with(
1520+
'Cherry-pick "fix bug"\n\nWork towards #123', amend=True
1521+
)
1522+
self.mock_git.push.assert_called_once_with("origin", "release/2.0")
1523+
1524+
self.mock_gh.update_issue_body.assert_called_once()
1525+
call_args = self.mock_gh.update_issue_body.call_args[0]
1526+
self.assertEqual(call_args[0], 123)
1527+
self.assertIn("- [x] #124 | status=done rc=rc0 commit=12345678", call_args[1])
1528+
1529+
@patch("tools.private.release.process_backports.datetime")
1530+
def test_process_backports_dry_run(self, mock_datetime):
1531+
mock_datetime.date.today.return_value = datetime.date(2026, 7, 1)
1532+
args = MagicMock(issue=123, remote="origin", dry_run=True)
1533+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1534+
self.mock_gh.get_issue_body.return_value = """
1535+
## Checklist
1536+
- [ ] Prepare Release
1537+
- [ ] Create Release branch
1538+
1539+
## Backports
1540+
- [ ] #124 | status=pending
1541+
"""
1542+
self.mock_git.get_remote_tags.return_value = []
1543+
1544+
def mock_resolve(items):
1545+
for item in items:
1546+
if item.pr_ref == "#124":
1547+
item.commit = "abcdef12"
1548+
item.status = "done"
1549+
return items
1550+
1551+
self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve
1552+
1553+
self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"]
1554+
self.mock_git.get_commit_sha.return_value = "12345678"
1555+
self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"'
1556+
1557+
result = releaser.cmd_process_backports(args)
1558+
1559+
self.assertEqual(result, 0)
1560+
self.mock_git.fetch.assert_has_calls(
1561+
[call("origin", tags=True, force=True), call("origin")]
1562+
)
1563+
self.mock_git.checkout.assert_called_once_with(
1564+
"release/2.0", track_remote="origin"
1565+
)
1566+
self.mock_git.cherry_pick.assert_called_once_with("abcdef12")
1567+
self.mock_changelog_news.update_changelog.assert_called_once_with(
1568+
"2.0.0", "2026-07-01"
1569+
)
1570+
self.mock_git.commit.assert_called_once_with(
1571+
'Cherry-pick "fix bug"\n\nWork towards #123', amend=True
1572+
)
1573+
self.mock_git.reset_hard.assert_called_once_with("12345678")
1574+
self.mock_git.push.assert_not_called()
1575+
self.mock_gh.update_issue_body.assert_not_called()
1576+
1577+
def test_process_backports_ignored_and_failed_states(self):
1578+
args = MagicMock(issue=123, remote="origin", dry_run=False)
1579+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1580+
self.mock_gh.get_issue_body.return_value = """
1581+
## Checklist
1582+
- [ ] Prepare Release
1583+
- [ ] Create Release branch
1584+
1585+
## Backports
1586+
- [ ] #124 | status=pending
1587+
- [ ] #125 | status=pending
1588+
- [ ] #126 | status=pending
1589+
"""
1590+
self.mock_git.get_remote_tags.return_value = []
1591+
1592+
def mock_resolve(items):
1593+
for item in items:
1594+
if item.pr_ref == "#124":
1595+
item.status = "open-pr"
1596+
elif item.pr_ref == "#125":
1597+
item.status = "draft-pr"
1598+
elif item.pr_ref == "#126":
1599+
item.status = "error-closed-pr"
1600+
return items
1601+
1602+
self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve
1603+
1604+
result = releaser.cmd_process_backports(args)
1605+
1606+
self.assertEqual(result, 1)
1607+
self.mock_gh.update_issue_body.assert_called_once()
1608+
call_args = self.mock_gh.update_issue_body.call_args[0]
1609+
self.assertEqual(call_args[0], 123)
1610+
self.assertIn("- [ ] #126 | status=error-closed-pr", call_args[1])
1611+
self.assertNotIn("status=open-pr", call_args[1])
1612+
self.assertNotIn("status=draft-pr", call_args[1])
1613+
self.mock_git.checkout.assert_not_called()
1614+
self.mock_git.cherry_pick.assert_not_called()
1615+
1616+
def test_process_backports_ignored_error_status(self):
1617+
args = MagicMock(issue=123, remote="origin", dry_run=False)
1618+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1619+
self.mock_gh.get_issue_body.return_value = """
1620+
## Checklist
1621+
- [ ] Prepare Release
1622+
- [ ] Create Release branch
1623+
1624+
## Backports
1625+
- [ ] #124 | status=error-merge-conflict
1626+
- [ ] #125 | status=error-some-other-error
1627+
"""
1628+
self.mock_git.get_remote_tags.return_value = []
1629+
self.mock_gh.get_merge_commits_for_prs.return_value = []
1630+
1631+
result = releaser.cmd_process_backports(args)
1632+
1633+
self.assertEqual(result, 0)
1634+
self.mock_gh.get_merge_commits_for_prs.assert_not_called()
1635+
self.mock_git.checkout.assert_not_called()
1636+
1637+
@patch("tools.private.release.process_backports.datetime")
1638+
def test_process_backports_cherry_pick_failed(self, mock_datetime):
1639+
mock_datetime.date.today.return_value = datetime.date(2026, 7, 1)
1640+
args = MagicMock(issue=123, remote="origin", dry_run=False)
1641+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1642+
self.mock_gh.get_issue_body.return_value = """
1643+
## Checklist
1644+
- [ ] Prepare Release
1645+
- [ ] Create Release branch
1646+
1647+
## Backports
1648+
- [ ] #124 | status=pending
1649+
"""
1650+
self.mock_git.get_remote_tags.return_value = []
1651+
1652+
def mock_resolve(items):
1653+
for item in items:
1654+
if item.pr_ref == "#124":
1655+
item.commit = "abcdef12"
1656+
item.status = "done"
1657+
return items
1658+
1659+
self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve
1660+
1661+
self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"]
1662+
self.mock_git.cherry_pick.side_effect = Exception("Cherry-pick conflict")
1663+
1664+
result = releaser.cmd_process_backports(args)
1665+
1666+
self.assertEqual(result, 1)
1667+
self.mock_git.checkout.assert_called_once_with(
1668+
"release/2.0", track_remote="origin"
1669+
)
1670+
self.mock_git.cherry_pick.assert_called_once_with("abcdef12")
1671+
self.mock_git.cherry_pick_abort.assert_called_once()
1672+
1673+
self.mock_gh.update_issue_body.assert_called_once()
1674+
call_args = self.mock_gh.update_issue_body.call_args[0]
1675+
self.assertEqual(call_args[0], 123)
1676+
self.assertIn("- [ ] #124 | status=error-merge-conflict", call_args[1])
1677+
1678+
self.mock_git.commit.assert_not_called()
1679+
self.mock_git.push.assert_not_called()
1680+
1681+
1682+
class GitCheckoutTest(unittest.TestCase):
1683+
@patch("tools.private.release.git.run_cmd")
1684+
def test_checkout_simple(self, mock_run_cmd):
1685+
git.checkout("my-branch")
1686+
mock_run_cmd.assert_called_once_with(
1687+
"git", "checkout", "my-branch", capture_output=False
1688+
)
1689+
1690+
@patch("tools.private.release.git.branch_exists")
1691+
@patch("tools.private.release.git.run_cmd")
1692+
def test_checkout_track_remote_new_branch(self, mock_run_cmd, mock_branch_exists):
1693+
mock_branch_exists.return_value = False
1694+
1695+
git.checkout("my-branch", track_remote="origin")
1696+
1697+
mock_branch_exists.assert_called_once_with("my-branch")
1698+
mock_run_cmd.assert_called_once_with(
1699+
"git", "checkout", "--track", "origin/my-branch", capture_output=False
1700+
)
1701+
1702+
@patch("tools.private.release.git.reset_hard")
1703+
@patch("tools.private.release.git.branch_exists")
1704+
@patch("tools.private.release.git.run_cmd")
1705+
def test_checkout_track_remote_existing_branch(
1706+
self, mock_run_cmd, mock_branch_exists, mock_reset_hard
1707+
):
1708+
mock_branch_exists.return_value = True
1709+
1710+
git.checkout("my-branch", track_remote="origin")
1711+
1712+
mock_branch_exists.assert_called_once_with("my-branch")
1713+
mock_run_cmd.assert_called_once_with(
1714+
"git", "checkout", "my-branch", capture_output=False
1715+
)
1716+
mock_reset_hard.assert_called_once_with("origin/my-branch")
1717+
1718+
13871719
if __name__ == "__main__":
13881720
unittest.main()

tools/private/release/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ py_binary(
1515
"gh.py",
1616
"git.py",
1717
"prepare.py",
18+
"process_backports.py",
1819
"release.py",
1920
"release_issue.py",
2021
"shell.py",

0 commit comments

Comments
 (0)