Search code examples
githubconfigurationbranchpull-request

On GitHub, allow approving your own PR without allowing pushing to main branch


I'm trying to configure a GitHub project with the following properties:

  1. All users - including admins - are required to submit code to main via a pull request, and cannot push directly to main
  2. All users - including admins - must wait for all CI tests to pass before merging a pull request
  3. All users must have their pull requests approved but admins may bypass this requirement and merge their own pull requests

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:

enter image description here enter image description here enter image description here


Solution

  • 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:

    • the PR author is a repo admin
    • there is a PR approval

    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:

    Multiple checks

    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".

    enter image description here

    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"