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.
diff --git a/RELEASING.md b/RELEASING.md
index f16b13e..6749e2f 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -96,8 +96,10 @@
 ### Method A: Comment on the PR
 
 Comment `/backport` on the PR you wish to backport. This will automatically
-add the PR to the active release's backports checklist. Once the PR is merged,
-the backports will be automatically processed.
+add the PR to the active release's backports checklist, or automatically create
+a patch release tracking issue for the next patch version if no release tracking
+issue currently exists. Once the PR is merged, the backports will be
+automatically processed.
 
 > [!NOTE]
 > Commenting `/backport` on an open PR will block further release publishing
diff --git a/tests/tools/private/release/add_backports_test.py b/tests/tools/private/release/add_backports_test.py
index c1c7b5c..b4f2efc 100644
--- a/tests/tools/private/release/add_backports_test.py
+++ b/tests/tools/private/release/add_backports_test.py
@@ -53,12 +53,50 @@
     assert "- [ ] #124" in updated_body
 
 
-def test_add_backports_auto_discover_no_issues(mock_gh):
+def test_add_backports_auto_discover_no_issues_creates_patch_release(
+    mock_gh, mock_git, release_tool_env
+):
+    mock_git.get_tags.return_value = ["1.0.0", "1.2.0"]
+    mock_git.get_current_branch.return_value = "main"
+
     args = argparse.Namespace(issue=None, prs=["124"])
 
+    result = AddBackports(args, mock_gh, mock_git).run()
+
+    assert result == 0
+    open_issues = mock_gh.get_open_tracking_issues()
+    assert len(open_issues) == 1
+    issue = open_issues[0]
+    assert issue["title"] == "Release 1.2.1"
+    body = issue["body"]
+    assert "- [ ] #124" in body
+    assert "- [ ] Sync Changelog #124" in body
+    assert "Tag RC" not in body
+
+
+def test_add_backports_patch_release_no_rc_added(mock_gh):
+    args = argparse.Namespace(issue=123, prs=["124"])
+    mock_gh.issues[123] = {
+        "title": "Release 1.2.1",
+        "body": """
+## Checklist
+- [ ] Prepare Release
+- [ ] Create Release branch
+- [ ] Tag Final
+
+## Backports
+""",
+        "labels": ["type: release"],
+        "number": 123,
+        "url": "https://github.com/bazel-contrib/rules_python/issues/123",
+    }
     result = AddBackports(args, mock_gh).run()
 
-    assert result == 1
+    assert result == 0
+    updated_body = mock_gh.get_issue_body(123)
+    assert "- [ ] #124" in updated_body
+    assert "- [ ] Sync Changelog #124" in updated_body
+    assert "Tag RC" not in updated_body
 
 
 def test_add_backports_auto_discover_multiple_issues(mock_gh):
diff --git a/tests/tools/private/release/release_issue_test.py b/tests/tools/private/release/release_issue_test.py
index 869a303..64fa2b5 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,
+    load_release_tracking_template,
     parse_checklist_state,
     parse_metadata_line,
 )
@@ -155,3 +156,35 @@
     assert not task_126.checked
     assert task_126.status is None
     assert task_126.pr is None
+
+
+def test_load_release_tracking_template(tmp_path):
+    template_file = tmp_path / "template.md"
+    template_file.write_text("""## Checklist
+- [ ] Prepare Release
+- [ ] Create Release branch
+- [ ] Tag RC0
+- [ ] Tag RC1
+- [ ] Tag Final
+
+## Backports
+""")
+
+    # No version specified (defaults to full template)
+    default_template = load_release_tracking_template(template_path=template_file)
+    assert "- [ ] Tag RC0" in default_template
+
+    # Minor release version (keeps RC tasks)
+    full_template = load_release_tracking_template(
+        version="1.2.0", template_path=template_file
+    )
+    assert "- [ ] Tag RC0" in full_template
+    assert "- [ ] Tag RC1" in full_template
+
+    # Patch release version (strips RC tasks)
+    patch_template = load_release_tracking_template(
+        version="1.2.1", template_path=template_file
+    )
+    assert "Tag RC" not in patch_template
+    assert "- [ ] Prepare Release" in patch_template
+    assert "- [ ] Tag Final" in patch_template
diff --git a/tests/tools/private/release/utils_test.py b/tests/tools/private/release/utils_test.py
index d39b5e1..48abbad 100644
--- a/tests/tools/private/release/utils_test.py
+++ b/tests/tools/private/release/utils_test.py
@@ -311,3 +311,18 @@
     next_version = utils.determine_next_version()
 
     assert next_version == "1.2.4"
+
+
+def test_determine_next_version_on_main_with_is_patch(mocker, release_tool_env):
+    mocker.patch(
+        "tools.private.release.git.Git.get_current_branch", return_value="main"
+    )
+    mocker.patch("tools.private.release.utils.get_latest_version", return_value="1.2.3")
+    (release_tool_env.git_root / "mock_file.bzl").write_text(
+        ":::{versionadded} VERSION_NEXT_FEATURE"
+    )
+
+    # Without is_patch, feature marker causes minor bump
+    assert utils.determine_next_version(is_patch=False) == "1.3.0"
+    # With is_patch=True, it produces a patch bump
+    assert utils.determine_next_version(is_patch=True) == "1.2.4"
diff --git a/tools/private/release/add_backports.py b/tools/private/release/add_backports.py
index 63c70af..a24e02d 100644
--- a/tools/private/release/add_backports.py
+++ b/tools/private/release/add_backports.py
@@ -1,20 +1,25 @@
-"""Subcommand to add PRs to the release tracking issue backports checklist."""
+import os
 
 from tools.private.release.gh import GitHub
+from tools.private.release.git import Git
 from tools.private.release.release_issue import (
+    RELEASE_TITLE_RE,
     add_backports_to_body,
     add_rc_task_to_body,
     add_sync_changelog_task_to_body,
+    load_release_tracking_template,
     parse_checklist_state,
 )
+from tools.private.release.utils import determine_next_version
 
 
 class AddBackports:
     """Class to add PRs to the release tracking issue."""
 
-    def __init__(self, args, gh: GitHub):
+    def __init__(self, args, gh: GitHub, git: Git | None = None):
         self.args = args
         self.gh = gh
+        self.git = git or Git(os.getcwd())
 
     def run(self) -> int:
         """Executes the add-backports subcommand."""
@@ -28,21 +33,40 @@
             )
             try:
                 open_issues = self.gh.get_open_tracking_issues()
-                if not open_issues:
-                    print("Error: No open release tracking issues found.")
-                    return 1
                 if len(open_issues) > 1:
                     print(
-                        "Error: Multiple open release tracking issues found."
+                        "::error::Multiple open release tracking issues found."
                         " Cannot determine active one:"
                     )
                     for issue in open_issues:
                         print(f"- #{issue['number']}: {issue['title']}")
                     return 1
-                issue_num = open_issues[0]["number"]
-                print(f"Auto-discovered active release tracking issue: #{issue_num}")
+                elif len(open_issues) == 1:
+                    issue_num = open_issues[0]["number"]
+                    print(
+                        f"Auto-discovered active release tracking issue: #{issue_num}"
+                    )
+                else:
+                    print(
+                        "No open release tracking issue found. Creating a new"
+                        " patch release tracking issue..."
+                    )
+                    patch_version = determine_next_version(git=self.git, is_patch=True)
+                    template_content = load_release_tracking_template(
+                        version=patch_version
+                    )
+
+                    issue_num = self.gh.create_release_tracking_issue(
+                        patch_version, template_content
+                    )
+                    print(
+                        f"::notice::Created patch release tracking issue #{issue_num} for"
+                        f" v{patch_version}"
+                    )
             except Exception as e:
-                print(f"Error auto-discovering tracking issue: {e}")
+                print(
+                    f"::error::Error auto-discovering or creating tracking issue: {e}"
+                )
                 return 1
 
         resolved_prs = []
@@ -51,7 +75,7 @@
                 pr_num = self.gh.resolve_pr_number(pr_ref)
                 resolved_prs.append(pr_num)
             except Exception as e:
-                print(f"Error resolving PR ref '{pr_ref}': {e}")
+                print(f"::error::Error resolving PR ref '{pr_ref}': {e}")
                 return 1
 
         print(
@@ -69,24 +93,34 @@
                 not task.checked and task.status != "done" for task in rc_tags.values()
             )
             next_rc_num = max(rc_tags.keys()) + 1 if rc_tags else 0
-            if not has_pending_rc:
+
+            issue_title = self.gh.get_issue_title(issue_num)
+            version_match = RELEASE_TITLE_RE.search(issue_title)
+            is_patch = False
+            if version_match:
+                version = version_match.group(1)
+                is_patch = not version.endswith(".0")
+
+            if not has_pending_rc and (rc_tags or not is_patch):
                 print(
                     f"No pending RC task found. Adding 'Tag"
                     f" RC{next_rc_num}' to checklist..."
                 )
                 body = add_rc_task_to_body(body, next_rc_num)
         except ValueError as e:
-            print(f"Error: {e}")
+            print(f"::error::{e}")
             return 1
         except Exception as e:
-            print(f"Failed to update tracking issue: {e}")
+            print(f"::error::Failed to update tracking issue: {e}")
             return 1
 
         try:
             self.gh.update_issue_body(issue_num, body)
-            print("Successfully updated tracking issue checklist.")
+            print(
+                f"::notice::Successfully updated tracking issue #{issue_num} checklist."
+            )
         except Exception as e:
-            print(f"Failed to update tracking issue body: {e}")
+            print(f"::error::Failed to update tracking issue body: {e}")
             return 1
 
         return 0
@@ -115,4 +149,5 @@
     def run_from_args(cls, args):
         """Instantiates and runs the command from parsed args."""
         gh = GitHub()
-        return cls(args, gh).run()
+        git = Git(os.getcwd())
+        return cls(args, gh, git).run()
diff --git a/tools/private/release/backport_create_releases.py b/tools/private/release/backport_create_releases.py
index a6d2bea..1a9faaf 100644
--- a/tools/private/release/backport_create_releases.py
+++ b/tools/private/release/backport_create_releases.py
@@ -1,7 +1,6 @@
 """Subcommand to initiate releases for verified backports."""
 
 import argparse
-import pathlib
 import re
 from dataclasses import dataclass
 
@@ -10,6 +9,7 @@
 from tools.private.release.release_issue import (
     add_backports_to_body,
     add_sync_changelog_task_to_body,
+    load_release_tracking_template,
     parse_metadata_line,
     update_task_in_body,
 )
@@ -70,14 +70,6 @@
     return True, "Eligible"
 
 
-def _load_release_template() -> str:
-    """Loads the release tracking issue template."""
-    template_path = pathlib.Path(".github/ISSUE_TEMPLATE/release_tracking_template.md")
-    if not template_path.exists():
-        raise FileNotFoundError(f"Template file not found at {template_path}")
-    return template_path.read_text(encoding="utf-8")
-
-
 class BackportCreateReleases:
     """Class to initiate releases for verified backports."""
 
@@ -112,9 +104,6 @@
             list(verify_statuses.keys()), key=lambda m: [int(x) for x in m.split(".")]
         )
 
-        # We need the templates for release issues
-        template_content = _load_release_template()
-
         updated_body = body
         changes_made = False
 
@@ -144,17 +133,7 @@
                     )
                 else:
                     # Create the issue
-                    is_first_release = version.endswith(".0")
-                    if is_first_release:
-                        issue_template = template_content
-                    else:
-                        lines = template_content.splitlines()
-                        lines = [
-                            line for line in lines if not re.search(r"Tag RC\d+", line)
-                        ]
-                        issue_template = "\n".join(lines)
-                        if template_content.endswith("\n"):
-                            issue_template += "\n"
+                    issue_template = load_release_tracking_template(version=version)
 
                     if args.dry_run:
                         print(
diff --git a/tools/private/release/create_release_issue.py b/tools/private/release/create_release_issue.py
index 72b71aa..0777941 100644
--- a/tools/private/release/create_release_issue.py
+++ b/tools/private/release/create_release_issue.py
@@ -1,9 +1,7 @@
 """Subcommand to create a release tracking issue."""
 
-import pathlib
-import re
-
 from tools.private.release.gh import GitHub
+from tools.private.release.release_issue import load_release_tracking_template
 from tools.private.release.utils import determine_next_version, semver_type
 
 
@@ -28,19 +26,7 @@
                 print(f"- {issue['title']}: {issue['url']}")
             return 1
 
-        template_path = pathlib.Path(
-            ".github/ISSUE_TEMPLATE/release_tracking_template.md"
-        )
-        if not template_path.exists():
-            raise FileNotFoundError(f"Template file not found at {template_path}")
-        template_content = template_path.read_text(encoding="utf-8")
-
-        is_first_release = version.endswith(".0")
-        if not is_first_release:
-            # Patch release: remove RC tasks
-            lines = template_content.splitlines()
-            lines = [line for line in lines if not re.search(r"Tag RC\d+", line)]
-            template_content = "\n".join(lines)
+        template_content = load_release_tracking_template(version=version)
 
         issue_num = self.gh.create_release_tracking_issue(version, template_content)
         print(f"Created tracking issue #{issue_num} for v{version}")
diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py
index 00412a8..28783cb 100644
--- a/tools/private/release/prepare.py
+++ b/tools/private/release/prepare.py
@@ -2,7 +2,6 @@
 
 import argparse
 import datetime
-import pathlib
 
 from tools.private.release import changelog_news
 from tools.private.release.gh import (
@@ -13,6 +12,7 @@
 )
 from tools.private.release.git import Git
 from tools.private.release.release_issue import (
+    load_release_tracking_template,
     parse_checklist_state,
     update_task_in_body,
 )
@@ -68,14 +68,7 @@
                 return 1
             except NoTrackingIssueError:
                 # Not found, we need the template
-                template_path = pathlib.Path(
-                    ".github/ISSUE_TEMPLATE/release_tracking_template.md"
-                )
-                if not template_path.exists():
-                    raise FileNotFoundError(
-                        f"Template file not found at {template_path}"
-                    )
-                template_content = template_path.read_text(encoding="utf-8")
+                template_content = load_release_tracking_template(version=version)
 
                 if args.dry_run:
                     print(
diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py
index 9c73d65..c8f2f9d 100644
--- a/tools/private/release/release_issue.py
+++ b/tools/private/release/release_issue.py
@@ -1,7 +1,42 @@
+import pathlib
 import re
 from typing import Any
 
 
+def load_release_tracking_template(
+    version: str | None = None,
+    template_path: pathlib.Path | None = None,
+) -> str:
+    """Loads the release tracking issue template, stripping RC tasks for patch releases.
+
+    Args:
+        version: Optional version string (e.g. '1.2.1'). If provided and represents a
+            patch release (i.e. does not end in '.0'), strips Tag RC tasks from the template.
+        template_path: Optional path to the template file. Defaults to
+            .github/ISSUE_TEMPLATE/release_tracking_template.md.
+
+    Returns:
+        The template content string.
+    """
+    if template_path is None:
+        template_path = pathlib.Path(
+            ".github/ISSUE_TEMPLATE/release_tracking_template.md"
+        )
+    if not template_path.exists():
+        raise FileNotFoundError(f"Template file not found at {template_path}")
+    template_content = template_path.read_text(encoding="utf-8")
+
+    is_patch = version is not None and not version.endswith(".0")
+    if is_patch:
+        lines = template_content.splitlines()
+        lines = [line for line in lines if not re.search(r"Tag RC\d+", line)]
+        template_content = "\n".join(lines)
+        if not template_content.endswith("\n"):
+            template_content += "\n"
+
+    return template_content
+
+
 class BackportTask:
     """Represents a backport task from the tracking issue checklist."""
 
diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py
index ab1e0ac..2441380 100644
--- a/tools/private/release/utils.py
+++ b/tools/private/release/utils.py
@@ -55,9 +55,10 @@
             yield filepath
 
 
-def get_latest_version():
+def get_latest_version(git=None):
     """Gets the latest version from git tags."""
-    git = Git(".")
+    if git is None:
+        git = Git(os.getcwd())
     tags = git.get_tags()
     versions = [
         (tag, parse_version(tag))
@@ -80,9 +81,10 @@
     return stable_versions[-1]
 
 
-def get_latest_rc_tag(version, remote=None):
+def get_latest_rc_tag(version, remote=None, git=None):
     """Queries git tags and returns the highest RC tag for the version."""
-    git = Git(".")
+    if git is None:
+        git = Git(os.getcwd())
     if remote:
         tags = git.get_remote_tags(remote)
     else:
@@ -109,9 +111,10 @@
     return False
 
 
-def determine_next_version(branch_name=None):
+def determine_next_version(branch_name=None, git=None, is_patch=False):
     """Determines the next version based on git tags and the current branch."""
-    git = Git(".")
+    if git is None:
+        git = Git(os.getcwd())
     if branch_name is None:
         branch_name = git.get_current_branch()
 
@@ -150,10 +153,10 @@
                 )
                 return next_version
 
-    latest_version = get_latest_version()
+    latest_version = get_latest_version(git=git)
     major, minor, patch = [int(n) for n in latest_version.split(".")]
 
-    if should_increment_minor():
+    if not is_patch and should_increment_minor():
         return f"{major}.{minor + 1}.0"
     else:
         return f"{major}.{minor}.{patch + 1}"