Reputation: 23567
I'm trying to configure a GitHub project with the following properties:
main
via a pull request, and cannot push directly to main
I'm having trouble satisfying both the first and third requirements at the same time. Specifically, if I enable the "Do not allow bypassing the above settings" setting, then there's no way for admins to bypass pull request approval. However, if I disable it, then admins are able to push directly to main
. Is there any way I can have my cake and eat it too?
Here are my full branch protection settings for the main
branch:
Upvotes: 10
Views: 5231
Reputation: 52132
You could disable "Require approvals", then use a GitHub Actions workflow and the GitHub API to check if one of these two conditions is true:
and make the outcome a required check.
Using the GitHub CLI, you get the permission level of a user $user
with
gh api "repos/{owner}/{repo}/collaborators/$user/permission" --jq '.permission'
Checking PR approvals is a bit more complicated because without required approvals, the reviewDecision
field in the PR object isn't populated any longer. Instead, we have to look at the array of reviews, and determine if at least one reviewer's most recent non-comment review was an approval.
For the review with ID $id
, this would look like this:
gh pr view "$id" --json reviews --jq '
.reviews
| map(select(.state != "COMMENTED"))
| reduce .[] as $item ({}; . + {($item.author.login): $item.state})
| to_entries
| map(select(.value == "APPROVED"))
| length > 0
'
This returns true
if there is a least one approval.
A workflow using these two checks would have to be triggered when a pull request is opened, and when a review is submitted; additionally, synchronizing a PR might dismiss a review, so that should also be a trigger.
Pull request triggers can filter by base branch, but reviews cannot, so we have to add this condition separately.
As a final obstacle, having multiple triggers (pull_request
and pull_request_review
) results in multiple status checks, and we can't make them both required; for a PR created by a non-admin, the pull_request
check still fails when the pull_request_review
check passes:
To this end, the workflow creates a separate third check, which is the one we have to use in the branch protection rule. For a PR branch with the most recent commit hash $sha
and outcome $state
, the GitHub CLI command to set the status looks like
gh api "repos/{owner}/{repo}/statuses/$sha" \
-f "state=$state" -f context='Non-admin PR approval'
For additional information, a URL can be added, as in the workflow below. The required check can be found under "Non-admin PR approval".
The workflow continues even if a condition isn't met, but the step checking PR approvals is skipped if the first step determined that the author is an admin. The overall result is communicated using the STATE
environment variable, which is used in the final step to set the status.
name: Check PR approval for non-admin authors
on:
# PR into main opened, reopened, or synchronized
pull_request:
branches:
- main
# When a review is submitted
pull_request_review:
types:
- submitted
jobs:
checkapproval:
name: Check PR approval
runs-on: ubuntu-20.04
# Event has to be a pull request, or the base branch has to be main
if: >-
github.event_name == 'pull_request'
|| github.event.pull_request.base.ref == 'main'
steps:
- name: Check if author is repo admin
env:
author: ${{ github.event.pull_request.user.login }}
repo: ${{ github.repository }}
GITHUB_TOKEN: ${{ github.token }}
run: |
perm=$(gh api "repos/$repo/collaborators/$author/permission" \
--jq '.permission')
if [[ $perm != 'admin' ]]; then
echo "Author is not admin; approval required" >&2
else
echo "Author is admin; no approval required" >&2
# Set success state in environment
echo "STATE=success" >> "$GITHUB_ENV"
fi
- name: Check for PR approval
# Run only if the previous step failed
if: env.STATE != 'success'
env:
prid: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ github.token }}
run: |
approved=$(gh pr view "$prid" --repo "$GITHUB_REPOSITORY" \
--json reviews --jq '
.reviews
| map(select(.state != "COMMENTED"))
| reduce .[] as $item (
{}; . + {($item.author.login): $item.state}
)
| to_entries
| map(select(.value == "APPROVED"))
| length > 0
')
if [[ $approved != 'true' ]]; then
echo "No PR approval found" >&2
# Set failure state in environment
echo "STATE=failure" >> "$GITHUB_ENV"
exit 0
fi
echo "PR approval found" >&2
# Set success state in environment
echo "STATE=success" >> "$GITHUB_ENV"
- name: Set result in separate status
env:
GITHUB_TOKEN: ${{ github.token }}
sha: ${{ github.event.pull_request.head.sha }}
repo: ${{ github.repository }}
id: ${{ github.run_id }}
run: |
gh api "repos/$repo/statuses/$sha" \
--raw-field state="$STATE" \
--raw-field context='Non-admin PR approval' \
--raw-field target_url="https://github.com/$repo/actions/runs/$id"
Upvotes: 4