diff --git a/.github/workflows/issue-branch-policy.yml b/.github/workflows/issue-branch-policy.yml index 3d7929e..a2b7050 100644 --- a/.github/workflows/issue-branch-policy.yml +++ b/.github/workflows/issue-branch-policy.yml @@ -122,6 +122,8 @@ jobs: local pull_request_number="$1" local expected_head_sha="$2" local pull_request_json branch_name head_sha + local head_repo_full_name head_commit_json + local head_commit_author_login head_commit_verified head_commit_verification_reason local category issue branch_date issue_json category_lines local issue_categories=() local issue_category @@ -152,6 +154,48 @@ jobs: return 2 fi + head_repo_full_name="$(jq -r '.head.repo.full_name // ""' <<< "$pull_request_json")" || { + printf 'Pull request #%s did not provide a readable head repository.\n' \ + "$pull_request_number" >&2 + return 1 + } + + head_commit_json="$(gh api "repos/$GITHUB_REPOSITORY/commits/$head_sha")" || { + printf 'Pull request #%s did not provide a readable head commit.\n' \ + "$pull_request_number" >&2 + return 1 + } + head_commit_author_login="$(jq -r '.author.login // ""' <<< "$head_commit_json")" || { + printf 'Pull request #%s did not provide a readable head commit author.\n' \ + "$pull_request_number" >&2 + return 1 + } + head_commit_verified="$(jq -r '.commit.verification.verified // false' <<< "$head_commit_json")" || { + printf 'Pull request #%s did not provide readable head commit verification state.\n' \ + "$pull_request_number" >&2 + return 1 + } + head_commit_verification_reason="$(jq -r '.commit.verification.reason // ""' <<< "$head_commit_json")" || { + printf 'Pull request #%s did not provide a readable head commit verification reason.\n' \ + "$pull_request_number" >&2 + return 1 + } + + # Dependabot branches are generated by GitHub and cannot use the + # issue-backed branch naming convention. The PR author is not a + # trustworthy identity check: a collaborator can push a new head + # commit to an existing Dependabot branch after it is opened. Use + # the current head commit's verified GitHub identity instead. + if [[ "$head_commit_author_login" == "dependabot[bot]" && + "$head_commit_verified" == true && + "$head_commit_verification_reason" == valid && + "$head_repo_full_name" == "$GITHUB_REPOSITORY" && + "$branch_name" == dependabot/* ]]; then + printf 'Validated Dependabot branch %s for pull request #%s.\n' \ + "$branch_name" "$pull_request_number" + return 0 + fi + if [[ ! "$branch_name" =~ $branch_pattern ]]; then printf 'Pull request #%s: branch name is not canonical.\n' \ "$pull_request_number" >&2 diff --git a/tests/test_issue_branch_policy.py b/tests/test_issue_branch_policy.py new file mode 100644 index 0000000..0f2ab9c --- /dev/null +++ b/tests/test_issue_branch_policy.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +import tempfile +from pathlib import Path + +import pytest +import yaml + + +def test_dependabot_policy_rejects_an_unverified_collaborator_head_commit() -> None: + if os.name == "nt": + pytest.skip("the policy under test is a Bash workflow and is validated on POSIX runners") + if shutil.which("bash") is None: + pytest.skip("bash is required to execute the workflow policy") + if shutil.which("jq") is None: + pytest.skip("jq is required by the workflow policy") + + root = Path(__file__).resolve().parents[1] + workflow = yaml.safe_load((root / ".github/workflows/issue-branch-policy.yml").read_text(encoding="utf-8")) + policy_run = workflow["jobs"]["policy"]["steps"][0]["run"] + head_sha = "a" * 40 + + fake_gh = """#!/bin/sh +set -eu +path="" +for argument in "$@"; do + case "$argument" in + repos/*) path="$argument"; break ;; + esac +done +case "$path" in + repos/basefoundry/base-cli/pulls\\?state=open\\&per_page=100) + printf '474\\t%s\\n' "$TEST_HEAD_SHA" + ;; + repos/basefoundry/base-cli/pulls/474) + printf '%s\\n' '{"number":474,"state":"open","user":{"login":"dependabot[bot]"},"head":{"sha":"'"$TEST_HEAD_SHA"'","ref":"dependabot/github_actions/astral-sh/setup-uv-10.3.1","repo":{"full_name":"basefoundry/base-cli"}}}' + ;; + repos/basefoundry/base-cli/commits/*) + printf '%s\\n' '{"author":{"login":"collaborator"},"commit":{"verification":{"verified":false,"reason":"unsigned"}}}' + ;; + repos/basefoundry/base-cli/statuses/*) + ;; + *) + printf 'unexpected gh api path: %s\\n' "$path" >&2 + exit 2 + ;; +esac +""" + + with tempfile.TemporaryDirectory() as directory: + fake_bin = Path(directory) / "bin" + fake_bin.mkdir() + gh_path = fake_bin / "gh" + gh_path.write_text(fake_gh, encoding="utf-8") + gh_path.chmod(gh_path.stat().st_mode | stat.S_IXUSR) + + environment = os.environ.copy() + environment.update( + { + "PATH": os.pathsep.join((str(fake_bin), environment["PATH"])), + "GITHUB_REPOSITORY": "basefoundry/base-cli", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_RUN_ID": "123", + "GH_TOKEN": "test-token", + "POLICY_CONTEXT": "base/issue-branch-policy", + "PULL_REQUEST_NUMBER": "474", + "ISSUE_EVENT_NUMBER": "", + "ISSUE_EVENT_LABEL": "", + "DEFAULT_BRANCH": "main", + "EVENT_NAME": "pull_request_target", + "EVENT_HEAD_SHA": head_sha, + "DISPATCH_HEAD_SHA": "", + "PREVIOUS_HEAD_SHA": "", + "RUN_SHA": "b" * 40, + "TEST_HEAD_SHA": head_sha, + } + ) + completed = subprocess.run( + ["bash", "-euo", "pipefail", "-c", policy_run], + cwd=root, + env=environment, + capture_output=True, + text=True, + ) + + assert completed.returncode != 0 + assert "One or more PRs sharing this commit violate branch policy" in completed.stderr + assert "Validated Dependabot branch" not in completed.stdout