build(release): ignore completed release issues when adding backports (#4225)

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.
diff --git a/dev/release/add_backports.py b/dev/release/add_backports.py
index 78bcf78..d36086b 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 @@
             )
             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 6bfa0f5..1b71e4a 100644
--- a/dev/release/release_issue.py
+++ b/dev/release/release_issue.py
@@ -280,6 +280,25 @@
     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 08fd9a8..24636c6 100644
--- a/tests/tools/private/release/add_backports_test.py
+++ b/tests/tools/private/release/add_backports_test.py
@@ -77,6 +77,84 @@
     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 3530dfd..be1f2a8 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 @@
     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