Using Azure Functions to configure GitHub branch protection
So I absolutely love GitHub but when you're working with a bigger team in the same organization you want to have some standards in place. New repo's are constantly being created and everyone has a lot of freedom to configure things to their liking. However, there are some basic things you want in place by default when it comes to production environments, branch protection being one of them.
There is currently no easy way to configure branch protection for a specific branch (main for example) by default. I want branch protection to be configured for the main branch upon creation of the repository.
Luckily, GitHub has an amazing set of APIs (api.github.com), good documentation and you can configure webhooks to call an external function!
The Challenge
During the configuration of this I ran into some things:
- People can create an empty repository, this results in a repo without a branch
- You cannot create a branch and thus branch protection through the API
- Alternatively you could create a trigger based on a commit (and thus knowing there is a branch) but this would result in a lot of traffic.
The Solution
To get this done I decided to use a PowerShell Azure Function with an HTTP Trigger. When an action on a repository is performed (for example created or deleted), the GitHub webhook will trigger the function. The function itself will then call the GitHub APIs and do the magic.
For this we need the following:
- A PowerShell Azure Function
- A GitHub Account
- A GitHub Personal Access Token with control of repositories
OAuth will work as well but for demo purposes a PAT should do.
GitHub Configuration
The GitHub Configuration is pretty straight forward. Once you have your Azure Function provisioned, we need URL to the HTTP trigger and the Function Key so we can configure the webhook.
In GitHub, navigate to your account settings and click on "Webhooks". We have three fields we can configure here. Wat we need is the "Payload URL" and the "Content Type".
The "Payload URL" will be the URL of your Azure Function, function key included
The "Content Type" will be set to application/json so we have some proper format to work with.
The next thing we need is information about a repository. This can be done by selecting "Let me select individual events" and then tick the box for "Repositories".
And we're set! If you want to use this for something other than repositories you might end up with different Payloads sent to your function. After configuring the Webhook you can actually go to "recent deliveries" and see all the payloads sent and the response. I definitely recommend inspecting that as it will help you finding the right properties.
The Azure Function
Let's start with the Azure Function. Once you have the PowerShell Azure Function set up we can write the code to configure the branch protection. Let's take a look at the important parts here.
Full code for the function can be found here: https://github.com/whaakman/gh-repo-config-azfunction
First we need a Personal Access Token to call the GitHub APIs. You can create one by going to your account settings -> Developer Settings -> Personal access tokens.
Make sure to create a PATH that has access to the repository. You probably don't need all (why I selected repo:invite, I have no idea, we don't need that). But just make sure you don't tick all the boxes for all the scopes, that would render your PAT a huge security risk :)
Let's look at some code. First we need to configure the header for our API calls. You can write them for scratch, or export them from Postman. I preferred exporting them from Postman as I had already tested the calls in there.
# Header for GitHub API
$ghToken = $env:ghToken
$headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
$headers.Add("Accept", "application/vnd.github.luke-cage-preview+json")
$headers.Add("Authorization", "Basic $ghToken")
$headers.Add("Content-Type", "application/json")
$ghRepoName = $Request.repository.name
Please note that for $ghToken we grabbing the token from the App Settings here. Note that the value of the App Setting "ghToken" is a combination of your GitHub username, the access token separated by a ":" and encoded in Base64. Example:
For a GitHub username "user" with a personal access token "LKdf023f325432tmds1" this would look like "user:LKdf023f325432tmds1" and encoded in Base64 this would be "dXNlcjpMS2RmMDIzZjMyNTQzMnRtZHMx". Our app setting would have the key "ghToken" and value dXNlcjpMS2RmMDIzZjMyNTQzMnRtZHMx".
Last but not least, we're grabbing the repository name from the payload we received through the webhook.
Next I wrote some functions because we need some retry logic and it's easier to call the functions than have redundant code. I ended up with two functions.
ConfigureBranchProtection
To configure the branch protection we need to perform a PUT request to api.githb.com and specifically to the newly created repository. Once again, instead of typing the body, exporting from Postman is your friend here!
function ConfigureBranchProtection {
$bodyConfigureProtection = "{
`n `"required_status_checks`": null,
`n `"enforce_admins`": true,
`n `"required_pull_request_reviews`": {
`n `"dismissal_restrictions`": {},
`n `"dismiss_stale_reviews`": false,
`n `"require_code_owner_reviews`": false,
`n `"required_approving_review_count`": 1
`n },
`n `"restrictions`": null
`n}"
$response = Invoke-RestMethod "https://api.github.com/repos/InterceptBV/$ghRepoName/branches/main/protection" -Method 'PUT' -Headers $headers -Body $bodyConfigureProtection
$response | ConvertTo-Json
}
As you can see in the code example, we want to configure the required_approving_review_count property and the enforce_admins property.
DummyCommit
As I mentioned earlier, sometimes a branch isn't created yet when someone initializes an empty repository. Instead of just doing a simple call to configure branch protection we need to make sure a branch is in place first. There is no endpoint for creating a branch, therefor we need to create a dummy commit to ensure the branch is created.
function DummyCommit {
$bodyDummyCommit = "{
`n `"branch`": `"main`",
`n `"message`": `"Init file to create the initial branch. Please remove and update with a Readme file`",
`n `"content`": `"SW5pdGZpbGU=`"
`n}"
$response = Invoke-RestMethod "https://api.github.com/repos/InterceptBV/$ghRepoName/contents/initfile" -Method 'PUT' -Headers $headers -Body $bodyDummyCommit
$response | ConvertTo-Json
}
Using the put request we're creating a file called "initfile". Note that the content is once again encoded in Base64.
And finally we can execute these functions. There are two scenarios:
- Repository is created with a Readme or with another file
- Repository is created without a file and thus no branch.
First we'll try to Configure the branch protection. If this fails, the branch doesn't exist yet. We'll catch that and run the DummyCommit function before trying to Configure the Branch Protection again.
if ($action -eq "created")
{
try {
Write-Host Configuring branch protection
ConfigureBranchProtection
}
catch {
Write-Host No branches exist, creating dummy commit to initialize branch.
DummyCommit
ConfigureBranchProtection
}
finally {
Write-Host Branch protection configured
}
}
This could use some work and maybe Try/Catch isn't the best way to go about it but it works pretty solid!
The big test
We're going to create a new repository without any files in it and see what happens.
Once we click "Create repository" we can see the Function is triggered and is doing some magic.
The branch is created, an initial file has been pushed and branch protection is configured:
There are other ways you can achieve this but to me this was a pretty easy and solid solution to enforce branch protection on all your repositories.