From 169bf407ab74bc2f3ed393f3ae8365e6399960a7 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Tue, 18 Aug 2026 21:41:20 -0700 Subject: [PATCH] workflows: grant pull-requests write to release_process_backports Calling the reusable workflow `release_sync_changelog.yaml` requires `pull-requests: write` permissions. Because `release_process_backports.yaml` did not specify this permission, GitHub Actions rejected the workflow run as invalid. Grant `pull-requests: write` permissions to `release_process_backports.yaml` and add an automated regression test in `tests/workflows/` that statically verifies caller workflows grant sufficient permissions to called reusable workflows. --- .../workflows/release_process_backports.yaml | 1 + tests/workflows/BUILD.bazel | 1 + tests/workflows/workflow_permissions_test.py | 115 +++++++++++++++++- 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release_process_backports.yaml b/.github/workflows/release_process_backports.yaml index 8e221d643d..df2373977c 100644 --- a/.github/workflows/release_process_backports.yaml +++ b/.github/workflows/release_process_backports.yaml @@ -33,6 +33,7 @@ on: permissions: contents: write issues: write + pull-requests: write jobs: process_backports: diff --git a/tests/workflows/BUILD.bazel b/tests/workflows/BUILD.bazel index 2e3d686485..7f8a3bf590 100644 --- a/tests/workflows/BUILD.bazel +++ b/tests/workflows/BUILD.bazel @@ -45,5 +45,6 @@ pytest_test( 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 8c98c80e99..db81924e2f 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 @@ def test_py_files_with_shebangs_are_executable(): 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 @@ def test_py_files_with_shebangs_are_executable(): 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}'." + )