workflow: allow specific users to trigger backport comment commands (#4203) Currently, the comment dispatch workflow only permits repository owners, members, and collaborators to run slash commands. Trusted contributors outside those roles cannot request backports on pull requests or release tracking issues via `/backport` comments. Add an explicit user allowlist to the workflow gate and enforce per-command authorization in the comment parser. Allowlisted users can trigger `/backport` commands on pull requests and release issues while remaining blocked from administrative release commands.
diff --git a/.github/workflows/on_comment.py b/.github/workflows/on_comment.py index 502db98..53087f1 100755 --- a/.github/workflows/on_comment.py +++ b/.github/workflows/on_comment.py
@@ -2,6 +2,7 @@ """Parses issue and PR comments to dispatch release and backport workflows.""" import argparse +import enum import json import os import re @@ -101,83 +102,124 @@ _add_comment_reaction(repo=repo, comment_id=comment_id, content="-1") +class _Command(enum.StrEnum): + """Workflow commands dispatched from issue and PR comments.""" + + NONE = "none" + CREATE_RC = "create-rc" + PREPARE_COMPLETE = "prepare-complete" + CREATE_RELEASE_BRANCH = "create-release-branch" + PREPARE = "prepare" + PROCESS_BACKPORTS = "process-backports" + SYNC_CHANGELOG = "sync-changelog" + ADD_BACKPORTS = "add-backports" + PROMOTE = "promote" + BACKPORT_PREPARE = "backport-prepare" + BACKPORT_CREATE_RELEASES = "backport-create-releases" + PR_BACKPORT = "pr-backport" + + +_ALLOWED_AUTHOR_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"}) +_BACKPORT_ALLOWED_USERS: frozenset[str] = frozenset([]) + + +def _is_command_allowed( + command: _Command, + *, + user_login: str, + author_association: str, +) -> bool: + """Returns whether the user is allowed to trigger the given command.""" + if command in (_Command.ADD_BACKPORTS, _Command.PR_BACKPORT) and ( + user_login in _BACKPORT_ALLOWED_USERS + ): + return True + if author_association in _ALLOWED_AUTHOR_ASSOCIATIONS: + return True + return False + + def _process_release_issue_comment( comment_body: str, issue_number: str, repo: str = "", comment_id: str = "", -) -> None: +) -> _Command: """Processes comments on a release tracking issue.""" if _match_command("create-rc", comment_body): - _write_github_output("command", "create-rc") - return + _write_github_output("command", _Command.CREATE_RC) + return _Command.CREATE_RC if m := _match_command("prepare-complete", comment_body): - _write_github_output("command", "prepare-complete") + _write_github_output("command", _Command.PREPARE_COMPLETE) if pr_arg := re.sub(r"[\s#]", "", m.group(1)) if m.group(1) else "": _write_github_output("pr_number", pr_arg) - return + return _Command.PREPARE_COMPLETE if _match_command("create-release-branch", comment_body): - _write_github_output("command", "create-release-branch") - return + _write_github_output("command", _Command.CREATE_RELEASE_BRANCH) + return _Command.CREATE_RELEASE_BRANCH if _match_command("prepare", comment_body): - _write_github_output("command", "prepare") - return + _write_github_output("command", _Command.PREPARE) + return _Command.PREPARE if _match_command("process-backports", comment_body): - _write_github_output("command", "process-backports") - return + _write_github_output("command", _Command.PROCESS_BACKPORTS) + return _Command.PROCESS_BACKPORTS if _match_command("sync-changelog", comment_body): - _write_github_output("command", "sync-changelog") - return + _write_github_output("command", _Command.SYNC_CHANGELOG) + return _Command.SYNC_CHANGELOG if m := _match_command(("backport", "backports"), comment_body): raw_args = m.group(1) if m.group(1) else "" items = [item for item in re.split(r"[\s,]+", raw_args) if item] if csv := ",".join(items): - _write_github_output("command", "add-backports") + _write_github_output("command", _Command.ADD_BACKPORTS) _write_github_output("backports", csv) + return _Command.ADD_BACKPORTS else: - _write_github_output("command", "none") + _write_github_output("command", _Command.NONE) _react_negative(repo=repo, comment_id=comment_id) - return + return _Command.NONE if _match_command("promote", comment_body): - _write_github_output("command", "promote") - return + _write_github_output("command", _Command.PROMOTE) + return _Command.PROMOTE - _write_github_output("command", "none") + _write_github_output("command", _Command.NONE) + return _Command.NONE -def _process_backport_issue_comment(comment_body: str) -> None: +def _process_backport_issue_comment(comment_body: str) -> _Command: """Processes comments on a backport tracking issue.""" if _match_command("prepare", comment_body): - _write_github_output("command", "backport-prepare") - return + _write_github_output("command", _Command.BACKPORT_PREPARE) + return _Command.BACKPORT_PREPARE if _match_command("create-releases", comment_body): - _write_github_output("command", "backport-create-releases") - return + _write_github_output("command", _Command.BACKPORT_CREATE_RELEASES) + return _Command.BACKPORT_CREATE_RELEASES - _write_github_output("command", "none") + _write_github_output("command", _Command.NONE) + return _Command.NONE -def _process_pr_comment(comment_body: str, pr_number: str) -> None: +def _process_pr_comment(comment_body: str, pr_number: str) -> _Command: """Processes comments on a pull request.""" if _match_command(("backport", "backports"), comment_body): - _write_github_output("command", "pr-backport") + _write_github_output("command", _Command.PR_BACKPORT) _write_github_output("pr_number", pr_number) - return + return _Command.PR_BACKPORT if _match_command("prepare-complete", comment_body): - _write_github_output("command", "prepare-complete") + _write_github_output("command", _Command.PREPARE_COMPLETE) _write_github_output("pr_number", pr_number) - return + return _Command.PREPARE_COMPLETE - _write_github_output("command", "none") + _write_github_output("command", _Command.NONE) + return _Command.NONE def _report_failure() -> int: @@ -205,7 +247,7 @@ ) return 1 - if command and command != "none": + if command and command != _Command.NONE: if comment_url: header = ( f"Workflow failed for command `{command}` ([comment]({comment_url}))." @@ -234,6 +276,12 @@ if report_failure: return _report_failure() + event = _load_event_data() + comment_data = event.get("comment") or {} + author_association = str(comment_data.get("author_association") or "") + user_data = comment_data.get("user") or {} + user_login = str(user_data.get("login") or "") + comment_body = os.environ.get("COMMENT_BODY", "") is_pr = _get_bool("IS_PR") event_number = os.environ.get("EVENT_NUMBER", "") @@ -243,29 +291,40 @@ repo = os.environ.get("GITHUB_REPOSITORY", "") if is_pr: - _process_pr_comment( + command = _process_pr_comment( comment_body=comment_body, pr_number=event_number, ) - return 0 - - issue_number = event_number - _write_github_output("issue_number", issue_number) - _write_github_env("issue_number", issue_number) - - if has_release_label: - _process_release_issue_comment( - comment_body=comment_body, - issue_number=issue_number, - repo=repo, - comment_id=comment_id, - ) - elif has_backport_label: - _process_backport_issue_comment( - comment_body=comment_body, - ) else: - _write_github_output("command", "none") + issue_number = event_number + _write_github_output("issue_number", issue_number) + _write_github_env("issue_number", issue_number) + + if has_release_label: + command = _process_release_issue_comment( + comment_body=comment_body, + issue_number=issue_number, + repo=repo, + comment_id=comment_id, + ) + elif has_backport_label: + command = _process_backport_issue_comment( + comment_body=comment_body, + ) + else: + _write_github_output("command", _Command.NONE) + command = _Command.NONE + + if command != _Command.NONE and not _is_command_allowed( + command, + user_login=user_login, + author_association=author_association, + ): + print( + f"::error::User '{user_login}' (association: '{author_association}')" + f" is not allowed to trigger command '{command}'." + ) + return 1 return 0
diff --git a/.github/workflows/on_comment.yaml b/.github/workflows/on_comment.yaml index 4bc5317..293e389 100644 --- a/.github/workflows/on_comment.yaml +++ b/.github/workflows/on_comment.yaml
@@ -19,10 +19,7 @@ parse_comment: runs-on: ubuntu-latest - if: | - github.event.comment.author_association == 'OWNER' || - github.event.comment.author_association == 'MEMBER' || - github.event.comment.author_association == 'COLLABORATOR' + if: contains(github.event.comment.body, '/') outputs: command: ${{ steps.parse.outputs.command }} issue_number: ${{ steps.parse.outputs.issue_number }}
diff --git a/downloader_config.cfg b/downloader_config.cfg index 3fa6264..c673c3b 100644 --- a/downloader_config.cfg +++ b/downloader_config.cfg
@@ -1,4 +1,5 @@ # Try GitHub first (primary) +rewrite ^github\.com/bazel-contrib/bazel-gazelle/(.*) github.com/bazel-contrib/bazel-gazelle/$1 rewrite ^github\.com/bazel-contrib/bazel_features/(.*) github.com/bazel-contrib/bazel_features/$1 rewrite ^github\.com/bazel-contrib/rules_go/(.*) github.com/bazel-contrib/rules_go/$1 rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) github.com/bazelbuild/bazel-skylib/$1 @@ -11,6 +12,7 @@ # Fall back to mirror (secondary) # Tracking upstream BCR mirror addition: https://github.com/bazelbuild/platforms/issues/139 +rewrite ^github\.com/bazel-contrib/bazel-gazelle/(.*) mirror.bazel.build/github.com/bazel-contrib/bazel-gazelle/$1 rewrite ^github\.com/bazel-contrib/bazel_features/(.*) mirror.bazel.build/github.com/bazel-contrib/bazel_features/$1 rewrite ^github\.com/bazel-contrib/rules_go/(.*) mirror.bazel.build/github.com/bazel-contrib/rules_go/$1 rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) mirror.bazel.build/github.com/bazelbuild/bazel-skylib/$1
diff --git a/gazelle/downloader_config.cfg b/gazelle/downloader_config.cfg index 3fa6264..c673c3b 100644 --- a/gazelle/downloader_config.cfg +++ b/gazelle/downloader_config.cfg
@@ -1,4 +1,5 @@ # Try GitHub first (primary) +rewrite ^github\.com/bazel-contrib/bazel-gazelle/(.*) github.com/bazel-contrib/bazel-gazelle/$1 rewrite ^github\.com/bazel-contrib/bazel_features/(.*) github.com/bazel-contrib/bazel_features/$1 rewrite ^github\.com/bazel-contrib/rules_go/(.*) github.com/bazel-contrib/rules_go/$1 rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) github.com/bazelbuild/bazel-skylib/$1 @@ -11,6 +12,7 @@ # Fall back to mirror (secondary) # Tracking upstream BCR mirror addition: https://github.com/bazelbuild/platforms/issues/139 +rewrite ^github\.com/bazel-contrib/bazel-gazelle/(.*) mirror.bazel.build/github.com/bazel-contrib/bazel-gazelle/$1 rewrite ^github\.com/bazel-contrib/bazel_features/(.*) mirror.bazel.build/github.com/bazel-contrib/bazel_features/$1 rewrite ^github\.com/bazel-contrib/rules_go/(.*) mirror.bazel.build/github.com/bazel-contrib/rules_go/$1 rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) mirror.bazel.build/github.com/bazelbuild/bazel-skylib/$1
diff --git a/sphinxdocs/downloader_config.cfg b/sphinxdocs/downloader_config.cfg index 3fa6264..c673c3b 100644 --- a/sphinxdocs/downloader_config.cfg +++ b/sphinxdocs/downloader_config.cfg
@@ -1,4 +1,5 @@ # Try GitHub first (primary) +rewrite ^github\.com/bazel-contrib/bazel-gazelle/(.*) github.com/bazel-contrib/bazel-gazelle/$1 rewrite ^github\.com/bazel-contrib/bazel_features/(.*) github.com/bazel-contrib/bazel_features/$1 rewrite ^github\.com/bazel-contrib/rules_go/(.*) github.com/bazel-contrib/rules_go/$1 rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) github.com/bazelbuild/bazel-skylib/$1 @@ -11,6 +12,7 @@ # Fall back to mirror (secondary) # Tracking upstream BCR mirror addition: https://github.com/bazelbuild/platforms/issues/139 +rewrite ^github\.com/bazel-contrib/bazel-gazelle/(.*) mirror.bazel.build/github.com/bazel-contrib/bazel-gazelle/$1 rewrite ^github\.com/bazel-contrib/bazel_features/(.*) mirror.bazel.build/github.com/bazel-contrib/bazel_features/$1 rewrite ^github\.com/bazel-contrib/rules_go/(.*) mirror.bazel.build/github.com/bazel-contrib/rules_go/$1 rewrite ^github\.com/bazelbuild/bazel-skylib/(.*) mirror.bazel.build/github.com/bazelbuild/bazel-skylib/$1
diff --git a/tests/workflows/on_comment_test.py b/tests/workflows/on_comment_test.py index 03c1f95..09738c3 100644 --- a/tests/workflows/on_comment_test.py +++ b/tests/workflows/on_comment_test.py
@@ -44,12 +44,23 @@ comment_id: int | str = 5772601429, comment_url: str = "", repo: str = "bazel-contrib/rules_python", + author_association: str = "OWNER", + user_login: str = "test-owner", ) -> None: + comment: dict[str, object] = { + k: v + for k, v in [ + ("id", comment_id), + ("html_url", comment_url), + ("author_association", author_association), + ] + if v + } + if user_login: + comment["user"] = {"login": user_login} payload = { "issue": {"number": issue_number} if issue_number else {}, - "comment": { - k: v for k, v in [("id", comment_id), ("html_url", comment_url)] if v - }, + "comment": comment, "repository": {"full_name": repo} if repo else {}, } self.event_file.write_text(json.dumps(payload), encoding="utf-8") @@ -64,11 +75,13 @@ monkeypatch.setenv("GITHUB_OUTPUT", str(out_file)) monkeypatch.setenv("GITHUB_ENV", str(env_file)) monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) - return GitHubActionEnv( + env = GitHubActionEnv( output_file=out_file, env_file=env_file, event_file=event_file, ) + env.set_event() + return env @pytest.fixture(name="mock_add_reaction", autouse=True) @@ -93,7 +106,7 @@ has_backport_label: str = "false", comment_id: str = "999", repo: str = "test/repo", -) -> None: +) -> int: monkeypatch.setenv("COMMENT_BODY", comment_body) monkeypatch.setenv("IS_PR", is_pr) monkeypatch.setenv("EVENT_NUMBER", event_number) @@ -101,7 +114,7 @@ monkeypatch.setenv("HAS_BACKPORT_LABEL", has_backport_label) monkeypatch.setenv("COMMENT_ID", comment_id) monkeypatch.setenv("GITHUB_REPOSITORY", repo) - process_comment() + return process_comment() def test_release_issue_create_rc(monkeypatch, gha_env): @@ -501,3 +514,124 @@ content="-1", ) mock_post_comment.assert_called_once() + + +def test_allowed_user_pr_backport(monkeypatch, gha_env): + monkeypatch.setattr( + "on_comment._BACKPORT_ALLOWED_USERS", frozenset({"allowed-user"}) + ) + gha_env.set_event(author_association="CONTRIBUTOR", user_login="allowed-user") + rc = _run_comment( + monkeypatch, + "/backport", + is_pr="true", + event_number="300", + ) + assert rc == 0 + assert gha_env.read_outputs() == { + "command": "pr-backport", + "pr_number": "300", + } + + +def test_allowed_user_release_issue_backport(monkeypatch, gha_env): + monkeypatch.setattr( + "on_comment._BACKPORT_ALLOWED_USERS", frozenset({"allowed-user"}) + ) + gha_env.set_event(author_association="NONE", user_login="allowed-user") + rc = _run_comment( + monkeypatch, + "/backport #123 #456", + has_release_label="true", + ) + assert rc == 0 + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "add-backports", + "backports": "#123,#456", + } + + +@pytest.mark.parametrize( + "comment_body,is_pr,has_release_label,has_backport_label,expected_cmd", + [ + ("/create-rc", "false", "true", "false", "create-rc"), + ("/prepare", "false", "true", "false", "prepare"), + ("/promote", "false", "true", "false", "promote"), + ("/prepare-complete", "true", "false", "false", "prepare-complete"), + ("/prepare", "false", "false", "true", "backport-prepare"), + ("/create-releases", "false", "false", "true", "backport-create-releases"), + ], +) +def test_allowed_user_disallowed_other_commands( + monkeypatch, + gha_env, + capsys, + comment_body, + is_pr, + has_release_label, + has_backport_label, + expected_cmd, +): + monkeypatch.setattr( + "on_comment._BACKPORT_ALLOWED_USERS", frozenset({"allowed-user"}) + ) + gha_env.set_event(author_association="CONTRIBUTOR", user_login="allowed-user") + rc = _run_comment( + monkeypatch, + comment_body, + is_pr=is_pr, + has_release_label=has_release_label, + has_backport_label=has_backport_label, + ) + assert rc == 1 + captured = capsys.readouterr() + assert ( + f"::error::User 'allowed-user' (association: 'CONTRIBUTOR') is not" + f" allowed to trigger command '{expected_cmd}'." + ) in captured.out + + +def test_unauthorized_user_disallowed_backport(monkeypatch, gha_env, capsys): + gha_env.set_event(author_association="CONTRIBUTOR", user_login="random-user") + rc = _run_comment( + monkeypatch, + "/backport", + is_pr="true", + event_number="300", + ) + assert rc == 1 + captured = capsys.readouterr() + assert ( + "::error::User 'random-user' (association: 'CONTRIBUTOR') is not" + " allowed to trigger command 'pr-backport'." + ) in captured.out + + +@pytest.mark.parametrize("association", ["OWNER", "MEMBER", "COLLABORATOR"]) +def test_maintainer_associations_allowed_arbitrary_commands( + monkeypatch, gha_env, association +): + gha_env.set_event(author_association=association, user_login="maintainer") + rc = _run_comment( + monkeypatch, + "/promote", + has_release_label="true", + ) + assert rc == 0 + assert gha_env.read_outputs() == { + "issue_number": "100", + "command": "promote", + } + + +def test_non_command_comment_by_non_member_succeeds(monkeypatch, gha_env): + gha_env.set_event(author_association="NONE", user_login="some-user") + rc = _run_comment( + monkeypatch, + "Just an ordinary comment", + is_pr="true", + event_number="300", + ) + assert rc == 0 + assert gha_env.read_outputs() == {"command": "none"}