joshlf
joshlf

Reputation: 23567

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

Upvotes: 10

Views: 5231

Answers (1)

Benjamin W.
Benjamin W.

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:

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

Upvotes: 4

Related Questions