From 1ac742b9272f17c44e1c64b464644adf899a10d9 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 5 Oct 2026 20:47:33 -0700 Subject: [PATCH] build(release): ignore completed release issues when adding backports When someone comments `/backport` on a PR, `add-backports` auto-discovers the active release tracking issue by looking for open issues with the release label. It only checked that an issue was open, not whether the release it tracks had already shipped. A release issue whose "Tag Final" task was done but that nobody closed therefore kept receiving new backports. Auto-discovery now ignores open issues whose "Tag Final" task is done and emits a workflow warning suggesting the issue be closed. If that leaves no open issue, the tool creates a new patch release tracking issue, as it does when none is open. A complete issue sitting next to an active one no longer triggers the "multiple open issues" error. Explicitly passing `--issue` is unchanged. `sync-changelog` and `create-release-issue` still treat any open issue as active; the new `is_release_complete()` helper can be reused there in a follow-up. --- dev/release/add_backports.py | 24 +++++- dev/release/release_issue.py | 19 +++++ .../private/release/add_backports_test.py | 78 +++++++++++++++++++ .../private/release/release_issue_test.py | 17 ++++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/dev/release/add_backports.py b/dev/release/add_backports.py index 78bcf7879b..d36086b95c 100644 --- a/dev/release/add_backports.py +++ b/dev/release/add_backports.py @@ -7,6 +7,7 @@ add_backports_to_body, add_rc_task_to_body, add_sync_changelog_task_to_body, + is_release_complete, load_release_tracking_template, parse_checklist_state, ) @@ -33,16 +34,31 @@ def run(self) -> int: ) try: open_issues = self.gh.get_open_tracking_issues() - if len(open_issues) > 1: + # An open issue whose release has already been tagged is + # finished; it just hasn't been closed. Don't add work to it. + active_issues = [] + for issue in open_issues: + body = self.gh.get_issue_body(issue["number"]) + if is_release_complete(body): + print( + "::warning::Ignoring open release tracking issue" + f" #{issue['number']} ({issue['title']}): its" + " 'Tag Final' task is done, so the release is" + " complete. Consider closing it." + ) + continue + active_issues.append(issue) + + if len(active_issues) > 1: print( "::error::Multiple open release tracking issues found." " Cannot determine active one:" ) - for issue in open_issues: + for issue in active_issues: print(f"- #{issue['number']}: {issue['title']}") return 1 - elif len(open_issues) == 1: - issue_num = open_issues[0]["number"] + elif len(active_issues) == 1: + issue_num = active_issues[0]["number"] print( f"Auto-discovered active release tracking issue: #{issue_num}" ) diff --git a/dev/release/release_issue.py b/dev/release/release_issue.py index 6bfa0f58e0..1b71e4a082 100644 --- a/dev/release/release_issue.py +++ b/dev/release/release_issue.py @@ -280,6 +280,25 @@ def parse_checklist_state(body): return state +def is_release_complete(body: str) -> bool: + """Returns whether the release tracked by an issue body has been completed. + + A release is complete once its "Tag Final" task is done: the checklist + item is checked, or it carries `status=done` metadata (as written by the + promote step). An open tracking issue in this state should not receive + further work, such as new backports. + + Args: + body: The release tracking issue body. + + Returns: + True if the "Tag Final" task is done, False otherwise (including when + the task is absent). + """ + tag_final = parse_checklist_state(body)["tag_final"] + return tag_final.checked or tag_final.status == "done" + + def parse_backports(body): """Parses the ## Backports checklist section.""" body = body.replace("\r\n", "\n") diff --git a/tests/tools/private/release/add_backports_test.py b/tests/tools/private/release/add_backports_test.py index 08fd9a888f..24636c6e3c 100644 --- a/tests/tools/private/release/add_backports_test.py +++ b/tests/tools/private/release/add_backports_test.py @@ -77,6 +77,84 @@ def test_add_backports_auto_discover_no_issues_creates_patch_release( assert "- [ ] Tag Final" in body +def test_add_backports_auto_discover_skips_complete_release_creates_patch_release( + mock_gh, mock_git, release_tool_env +): + # 1.2.1 has already been tagged (Tag Final done) but the issue was never + # closed. It must be left alone and a new 1.2.2 issue created instead. + mock_git.get_tags.return_value = ["1.2.0", "1.2.1"] + mock_git.get_current_branch.return_value = "main" + complete_body = """ +## Checklist +- [x] Sync Changelog #100 | status=done +- [x] Tag Final | status=done tag=1.2.1 commit= abcdef12 + +## Backports +- [x] #100 | status=done +""" + complete_issue_num = mock_gh.create_issue( + title="Release 1.2.1", + body=complete_body, + labels=["type: release"], + ) + + args = argparse.Namespace(issue=None, prs=["124"]) + + result = AddBackports(args, mock_gh, mock_git).run() + + assert result == 0 + # The completed issue is untouched. + assert mock_gh.get_issue_body(complete_issue_num) == complete_body + + open_issues = mock_gh.get_open_tracking_issues() + assert len(open_issues) == 2 + new_issue = next(i for i in open_issues if i["number"] != complete_issue_num) + assert new_issue["title"] == "Release 1.2.2" + body = new_issue["body"] + assert "- [ ] #124" in body + assert "- [ ] Sync Changelog #124" in body + assert "- [ ] Tag Final" in body + + +def test_add_backports_auto_discover_ignores_complete_release_among_multiple( + mock_gh, +): + # A stale-but-complete issue alongside an active one should not cause the + # "multiple open issues" error; the active one is used. + complete_issue_num = mock_gh.create_issue( + title="Release 1.2.1", + body=""" +## Checklist +- [x] Tag Final | status=done tag=1.2.1 commit= abcdef12 + +## Backports +""", + labels=["type: release"], + ) + active_issue_num = mock_gh.create_issue( + title="Release 1.3.0", + body=""" +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final + +## Backports +""", + labels=["type: release"], + ) + + args = argparse.Namespace(issue=None, prs=["124"]) + + result = AddBackports(args, mock_gh).run() + + assert result == 0 + assert "#124" not in mock_gh.get_issue_body(complete_issue_num) + active_body = mock_gh.get_issue_body(active_issue_num) + assert "- [ ] #124" in active_body + assert "- [ ] Sync Changelog #124" in active_body + + def test_add_backports_patch_release_no_rc_added(mock_gh): args = argparse.Namespace(issue=123, prs=["124"]) mock_gh.issues[123] = { diff --git a/tests/tools/private/release/release_issue_test.py b/tests/tools/private/release/release_issue_test.py index 3530dfd38a..be1f2a8c40 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, + is_release_complete, load_release_tracking_template, parse_backports, parse_checklist_state, @@ -194,6 +195,22 @@ def test_parse_checklist_state_with_sync_changelogs(): assert task_126.pr is None +def test_is_release_complete(): + assert not is_release_complete("- [ ] Tag Final") + assert is_release_complete("- [x] Tag Final") + assert is_release_complete( + "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" + ) + # status=done is authoritative even if the box is somehow unchecked. + assert is_release_complete("- [ ] Tag Final | status=done tag=2.0.0") + # A failed promote attempt leaves the release incomplete. + assert not is_release_complete( + "- [ ] Tag Final | status=error-rc-tag-not-branch-head rc=2.0.0rc1" + ) + # No Tag Final task at all (e.g. malformed body) is not complete. + assert not is_release_complete("## Checklist\n- [x] Prepare Release\n") + + def test_load_release_tracking_template(tmp_path): template_file = tmp_path / "template.md" template_file.write_text("""## Checklist