workflows: grant pull-requests write to release_process_backports (#4081)

Calling the reusable workflow 'release_sync_changelog.yaml' requires
'pull-requests: write' permissions. Because
'release_process_backports.yaml' lacked this permission, GitHub
Actions rejected workflow runs when attempting to invoke the changelog
sync workflow.

Grant 'pull-requests: write' permissions to
'release_process_backports.yaml' and add regression tests that
statically verify caller workflows define sufficient permissions for
any reusable workflows they invoke.
diff --git a/.github/workflows/release_process_backports.yaml b/.github/workflows/release_process_backports.yaml
index 8e221d6..df23739 100644
--- a/.github/workflows/release_process_backports.yaml
+++ b/.github/workflows/release_process_backports.yaml
@@ -33,6 +33,7 @@
 permissions:
   contents: write
   issues: write
+  pull-requests: write
 
 jobs:
   process_backports:
diff --git a/tests/workflows/BUILD.bazel b/tests/workflows/BUILD.bazel
index 2e3d686..7f8a3bf 100644
--- a/tests/workflows/BUILD.bazel
+++ b/tests/workflows/BUILD.bazel
@@ -45,5 +45,6 @@
     target_compatible_with = NOT_WINDOWS,
     deps = [
         "//python/runfiles",
+        "@pypi//pyyaml",
     ],
 )
diff --git a/tests/workflows/workflow_permissions_test.py b/tests/workflows/workflow_permissions_test.py
index 8c98c80..db81924 100644
--- a/tests/workflows/workflow_permissions_test.py
+++ b/tests/workflows/workflow_permissions_test.py
@@ -1,6 +1,11 @@
-"""Tests that workflow scripts with shebangs have executable permissions."""
+"""Tests for workflow script permissions and reusable workflow hierarchies."""
+
+from __future__ import annotations
 
 import stat
+from pathlib import Path
+
+import yaml
 
 from python.runfiles import runfiles
 
@@ -13,9 +18,8 @@
     assert len(py_files) > 0, f"No Python files found in {workflows_dir}"
 
     for py_file in py_files:
-        first_line = py_file.read_text(encoding="utf-8", errors="ignore").splitlines()[
-            0
-        ]
+        lines = py_file.read_text(encoding="utf-8", errors="ignore").splitlines()
+        first_line = lines[0] if lines else ""
         if first_line.startswith("#!"):
             mode = py_file.stat().st_mode
             is_executable = bool(mode & stat.S_IXUSR)
@@ -23,3 +27,106 @@
                 f"{py_file.name} has a shebang ('{first_line}') but does not "
                 f"have executable (+x) permissions (mode: {oct(mode)})"
             )
+
+
+def _parse_permissions(perms: object) -> dict[str, str] | None:
+    if perms is None:
+        return None
+    if isinstance(perms, str):
+        if perms == "read-all":
+            return {"_all": "read"}
+        if perms == "write-all":
+            return {"_all": "write"}
+        return {}
+    if isinstance(perms, dict):
+        return {str(k): str(v) for k, v in perms.items()}
+    return {}
+
+
+def test_reusable_workflow_permissions_hierarchy():
+    """Validates that calling workflows grant sufficient permissions."""
+    rf = runfiles.CreateOrRaise()
+    workflows_dir = rf.root() / "rules_python/.github/workflows"
+    workflow_files = list(workflows_dir.glob("*.yaml")) + list(
+        workflows_dir.glob("*.yml")
+    )
+
+    assert len(workflow_files) > 0, f"No workflow files found in {workflows_dir}"
+
+    permission_levels = {
+        "none": 0,
+        "read": 1,
+        "write": 2,
+    }
+
+    workflows_data = {}
+    for wf_file in workflow_files:
+        content = yaml.safe_load(wf_file.read_text(encoding="utf-8"))
+        if isinstance(content, dict):
+            workflows_data[wf_file.name] = content
+
+    for caller_name, caller_content in workflows_data.items():
+        caller_wf_perms = _parse_permissions(caller_content.get("permissions"))
+        jobs = caller_content.get("jobs", {})
+        if not isinstance(jobs, dict):
+            continue
+
+        for job_name, job_config in jobs.items():
+            if not isinstance(job_config, dict):
+                continue
+
+            uses = job_config.get("uses")
+            if not uses or not isinstance(uses, str):
+                continue
+
+            callee_filename = Path(uses).name
+            if callee_filename not in workflows_data:
+                # External workflow or not in local .github/workflows
+                continue
+
+            callee_content = workflows_data[callee_filename]
+            callee_wf_perms = _parse_permissions(callee_content.get("permissions"))
+            if callee_wf_perms is None:
+                # Callee does not define top-level permissions; inherits caller
+                continue
+
+            job_perms = _parse_permissions(job_config.get("permissions"))
+            effective_caller_perms = (
+                job_perms if job_perms is not None else caller_wf_perms
+            )
+
+            if effective_caller_perms is None:
+                # Caller does not define permissions; uses repo defaults
+                continue
+
+            caller_all = effective_caller_perms.get("_all")
+
+            if "_all" in callee_wf_perms:
+                required_level = callee_wf_perms["_all"]
+                available_level = caller_all or "none"
+                req_val = permission_levels.get(required_level, 0)
+                avail_val = permission_levels.get(available_level, 0)
+                assert avail_val >= req_val, (
+                    f"In {caller_name} job '{job_name}': calling "
+                    f"'{callee_filename}' requires '{required_level}-all', "
+                    f"but caller only allows '{available_level}'."
+                )
+
+            for scope, required_level in callee_wf_perms.items():
+                if scope == "_all":
+                    continue
+
+                if caller_all:
+                    available_level = caller_all
+                else:
+                    available_level = effective_caller_perms.get(scope, "none")
+
+                req_val = permission_levels.get(required_level, 0)
+                avail_val = permission_levels.get(available_level, 0)
+
+                assert avail_val >= req_val, (
+                    f"In {caller_name} job '{job_name}': calling "
+                    f"'{callee_filename}' requires "
+                    f"'{scope}: {required_level}', but caller only allows "
+                    f"'{scope}: {available_level}'."
+                )