United Kingdom: +44 (0)208 088 8978

Protecting your main branch in GitHub from bad commits

Want to ensure that your main branch in a GitHub repo always builds? Matt shows how!

We're hiring Software Developers

Click here to find out more

Recently, I was looking into how to ensure that commits on a GitHub repository's main branch only ever have code that compiles and passes all tests. Finding an answer proved a little harder than I expected, so I thought that I'd share the results of my investigation here.

Desired behaviour

  • All commits to the main branch come from pull requests, to prevent pushing bad commits.
  • Before merging pull requests, the code that would result from the merge is checked to be good.
  • Until the resulting code is confirmed to be good, prevent merging the pull request.
  • If the main branch is updated, the stale checks must be repeated, in case the code that would now result from the merge is bad.

Relevant GitHub features

There are two main GitHub features that we'll make use of to get the desired behaviour: GitHub Actions, which allow automated workflows to be triggered when certain things happen in your repository and branch protection rules, which allow commits to be made against branches only under certain conditions.

Be aware that, as described in GitHub's overview of branch protection rules, branch protection rules don't apply to repository administrators without additional configuration.

Prevent pushing bad commits

Preventing bad commits from being pushed to the main branch of your GitHub repo can be achieved by protecting it with a branch protection rule that has the "Require a pull request before merging" setting enabled.

The "Require a pull request before merging" checkbox.

Check whether the merged code would be good

Checking that code that would result from a pull request merge is good can be achieved through an appropriately configured GitHub Action. The precise setup depends on your codebase and requirements, but common checks include successful compilation and all automated tests passing. The build.yml file of the repo that I was using while investigating is a basic example.

...

on:

  ...

  pull_request:
    branches: [ main ]

jobs:

  build:

    ...

    steps:

    ...

    - name: Build
      run: dotnet build --configuration Release

The file tells GitHub that pull requests targeting the main branch should trigger a release-configuration code build. GitHub reports the status of the resulting check on the pull request:

GitHub pull request stating "All checks have passed"

It's worth emphasising here that the code that is used is not the code on either the source or target branch, but the code that would result from the merge.

Prevent merging if the merged code would bad

Configuring the above workflow ensures that the checks are triggered, but doesn't use them to prevent pull requests that fail the checks from being merged.

The check shows as failing, but the merge button is enabled.

The "Require status checks to pass before merging" branch protection rule setting can be used to achieve that behaviour. To configure it, use the name of the check that is required to pass ("build" in this case).

GitHub branch protection rules settings, with "Require status checks to pass before merging" checked.

GitHub pull request checks showing as failing and the merge button disabled.

Repeat stale pull request checks

The above configuration gets us a long way. However, the check can go out of date if the branch that the pull request targets is updated. As described in the documentation for "Require status checks to pass before merging", the "Require branches to be up to date before merging" setting ensures that a pull request cannot be merged if that happens. To make things easy, GitHub provides a button on the pull request webpage that lets a developer update the pull request source branch with the latest changes from the target branch.

GitHub pull request showing message about the branch being out of date, with a button to update the branch.

Merge queues

One downside of using the "Require branches to be up to date before merging" setting is that it results in more merges than may strictly be necessary. In particular, you're forced to update the pull request source branch if the target branch is updated, then wait for the checks to run again.

If you want to avoid having to update source branches yourself, GitHub has a merge queues feature that enables you to schedule the pull request branch for merge into the target branch. The docs have more details on how merge queues work, but the basic idea is that GitHub will manage the merging of queued pull requests, running CI checks immediately before merging and ensuring that the code that is tested contains all changes that would end up on the main branch. GitHub automatically dequeues pull requests for which CI checks fail.

Summary

With the repository configured as above, we can ensure that the main branch doesn't end up with bad commits.

In summary, to get the desired behaviour, we:

  1. Set up a CI pipeline that checks the code that will result from a pull request merge.
  2. Created a branch protection rule for the main branch, requiring:
    a. All changes to come from pull requests.
    b. The CI pipeline status to be good for the code that will result from the merge.
    c. The pull request source branch to be up to date.

If you're interested you can check out the repo that I used for experimenting while investigating this.