Running validation builds for a GitHub Pull Request using Azure Pipelines offline webhooks & GitHub Checks Rest API

Running validation builds for a GitHub Pull Request using Azure Pipelines offline webhooks & GitHub Checks Rest API
Photo by Yancy Min / Unsplash

This blog post is part of a series of posts covering the Challenges adopting the Azure DevOps SaaS offering in highly regulated industries. The first post covered the connectivity challenges experienced by some the enterprises which I have worked with. In subsequent posts we then went on to explore some of the options for addressing these challenges, if you missed them feel free to check them out:

In this post we will leverage offline webhooks to trigger PR validation builds in Azure Pipelines for a Pull Request created in GitHub Enterprise, we will then leverage the GitHub Checks REST API to ensure our Azure Pipeline progress is published to the GitHub PR.

This solution is especially useful in regulated industries where GitHub Enterprise may be deployed to an isolated environment where no inbound communication is allowed from public network address space.  If you are not working with an isolated environment then this scenario is already available to you today leveraging the GitHub App.

If we look at the diagram below to refresh our memories, we are looking to resolve connectivity challenges when adopting the Azure DevOps SaaS offering in highly regulated industries:

The challenge is typically related to connectivity required for integration between the Azure DevOps Service and GitHub Enterprise Server, you will also run into issues if you are looking to leverage Microsoft Hosted Agents and wish to connect to your local GitHub Enterprise Server to clone a repository for example.

Overview

Ok, so with the scenario described above in mind I hacked together a very quick Proof of Concept to prove out feasibility for this scenario, follow the steps below to configure it in your environment.

Requirements:

  • Azure DevOps
  • GitHub Enterprise Server

Steps

  1. I followed the steps in my previous post Triggering Azure Pipelines with an offline source repository and setup an Azure Pipeline which would be used to run my validation builds triggered by an offline webhook.
trigger: none

pool: HighlyRegulated

resources:
  webhooks:
    - webhook: highlyregulatedprtrigger
      connection: highlyregulated-pr-trigger
      filters:
        - path: repository.full_name
          value: gareth/HighlyRegulatedLab1


steps:
- task: DownloadArtifactsGitHubEnterprise@1
  displayName: 'Download Artifacts - GitHub Enterprise'
  inputs:
    connection: 'githubentvm-pub-instance'
    Repository: ${{ parameters.highlyregulatedprtrigger.repository.full_name}}
    Branch: ${{ parameters.highlyregulatedprtrigger.pull_request.head.ref}}
    CommitId: ${{ parameters.highlyregulatedprtrigger.pull_request.head.sha}}

2. Next we must Configure a Web Hook on your GitHub repository, it will be triggered for Pull Requests and execute your Azure Pipeline.

3. We can test the configuration by creating a new Pull Request in GitHub, you should see a new Azure Pipeline run execute.

4. Now our GitHub Pull Request is in review, but a reviewer would have no idea that a validation build was executed nor what the result was.

5. Let's leverage GitHub Checks to ensure the status of the Pipeline run is published back to the original Pull Request, to do this we need to register a new GitHub App.

6. We can provide the bare minimum for the GitHub App registration.

7. Ensure you configure the following Repository Permissions so that we are able to manipulate Check Runs.

8. As we will not be using them we can also disable the Apps Web Hook option.

9. Click Create App and take note of the details of this newly created GitHub Application.

10. To allow our application to authenticate against our GitHub instance, we need to Generate & Download it's private key.

11. Finally we need to Install the GitHub App into our account.

12. Select to install the App for all Repos.

13. I then very quickly threw the following code together which allows GitHub Check Runs to be created and updated using the GitHub Apps credentials above from our Azure Pipelines.

using Microsoft.IdentityModel.Tokens;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Security.Claims;
using System.Net.Http.Json;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using System.Text;

namespace GitHubChecks
{
   class Program
   {
       // Dont store key here do it elsewhere securely
       const string privateKey = "[GITHUB_APP_KEY]";
       const string appId = "[GITHUB_APP_ID]";
       const string appInstallationId = "[GITHUB_APP_INSTALL_ID]";
       const string githubBaseUri = "https://[GITHUB_BASE_URI]/api/v3/";
       async static Task Main(string[] args)
       {
           // Get our arguments
           var name = args[0];
           var action = args[1];
           var repo = args[2];
           var head_sha = args[3];
           var details_url = "";
           var run_id = -1;

           if (action != "StartRun")
               run_id = Convert.ToInt32(args[4]);
           else
               details_url = args[4];

           // Generate and Sign our GitHub Apps JWT Token - https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app
           var rsa = System.Security.Cryptography.RSA.Create();
           var bytes = 0;
           rsa.ImportRSAPrivateKey(Convert.FromBase64String(privateKey), out bytes);

           var signingCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256)
           {
               CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = false }
           };

           var now = DateTime.Now;
           var unixTimeSeconds = new DateTimeOffset(now).ToUnixTimeSeconds();

           var jwt = new JwtSecurityToken(
               issuer: appId,
               claims: new Claim[] {
                   new Claim(JwtRegisteredClaimNames.Iat, unixTimeSeconds.ToString(), ClaimValueTypes.Integer64),
               },
               expires: now.AddMinutes(10),
               signingCredentials: signingCredentials
           );

           string token = new JwtSecurityTokenHandler().WriteToken(jwt);

           using (var httpClientHandler = new HttpClientHandler())
           {
               // Allow self-signed certificate
               httpClientHandler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => { return true; };
               using (var client = new HttpClient(httpClientHandler))
               {
                   client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.v3+json"));
                   client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
                   client.BaseAddress = new Uri(githubBaseUri);

                   // Convert our GitHub App JWT token into an Installation Token which we can use to access GitHub REST API's - https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-an-installation
                   var response = await client.PostAsJsonAsync($"app/installations/{appInstallationId}/access_tokens", new {});

                   response.EnsureSuccessStatusCode();

                   var auth = JObject.Parse(await response.Content.ReadAsStringAsync());

                   using (var client2 = new HttpClient(httpClientHandler))
                   {
                       client2.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.v3+json"));
                       client2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth["token"]?.Value<string>());
                       client2.BaseAddress = new Uri(githubBaseUri);

                       switch (action)
                       {
                           case "StartRun":
                               // Create a new GitHub check run - https://docs.github.com/en/rest/reference/checks#create-a-check-run
                               response = await client2.PostAsJsonAsync($"repos/{repo}/check-runs", new { name = name, head_sha = head_sha, status = "in_progress", details_url = details_url });
                               break;
                           case "EndRun":
                               if (run_id > 0)
                               {
                                   // Update existing GitHub check run - https://docs.github.com/en/rest/reference/checks#update-a-check-run
                                   response = await client2.PatchAsync($"repos/{repo}/check-runs/{run_id}", new StringContent(JsonConvert.SerializeObject(new { name = name, head_sha = head_sha, status = "completed", conclusion = "success" }), Encoding.UTF8, "application/json-patch+json"));
                               }
                               break;
                           case "FailedRun":
                               if (run_id > 0)
                               {
                                   // Update existing GitHub check run - https://docs.github.com/en/rest/reference/checks#update-a-check-run
                                   response = await client2.PatchAsync($"repos/{repo}/check-runs/{run_id}", new StringContent(JsonConvert.SerializeObject(new { name = name, head_sha = head_sha, status = "completed", conclusion = "failure" }), Encoding.UTF8, "application/json-patch+json"));
                               }
                               break;
                       }

                       // Did we succeeed?
                       response.EnsureSuccessStatusCode();

                       var run = JObject.Parse(await response.Content.ReadAsStringAsync());

                       Console.WriteLine(run["id"]?.Value<int>());
                   }
               }
           }
       }
   }
}


14. We need to update our Azure Pipeline YAML to ensure we run the steps to publish progress to the GitHub PR. We could also leverage Azure Pipeline Templates to enforce these steps to be executed for all validation builds.

trigger: none

pool: HighlyRegulated

resources:
  webhooks:
    - webhook: highlyregulatedprtrigger
      connection: highlyregulated-pr-trigger
      filters:
        - path: repository.full_name
          value: gareth/HighlyRegulatedLab1


steps:
- script: |
    for /f %%i in ('C:\GitHubChecks\GitHubChecks.exe azure-pipelines-validation StartRun ${{ parameters.highlyregulatedprtrigger.repository.full_name}} ${{ parameters.highlyregulatedprtrigger.pull_request.head.sha}} "$(System.TeamFoundationCollectionUri)/$(System.TeamProject)/_build/results?buildId=$(Build.BuildId)&view=results"') do set CHECKRUN_ID=%%i
    echo "##vso[task.setvariable variable=githubcheckrun_id;]%CHECKRUN_ID%"
- task: DownloadArtifactsGitHubEnterprise@1
  displayName: 'Download Artifacts - GitHub Enterprise'
  inputs:
    connection: 'githubentvm-pub-instance'
    Repository: ${{ parameters.highlyregulatedprtrigger.repository.full_name}}
    Branch: ${{ parameters.highlyregulatedprtrigger.pull_request.head.ref}}
    CommitId: ${{ parameters.highlyregulatedprtrigger.pull_request.head.sha}}
- script: |
    C:\GitHubChecks\GitHubChecks.exe azure-pipelines-validation EndRun ${{ parameters.highlyregulatedprtrigger.repository.full_name}} ${{ parameters.highlyregulatedprtrigger.pull_request.head.sha}} $(githubcheckrun_id)

15. If we create a new Pull Request for our repository, once the validation build is triggered we should see our GitHub Checks appear in the PR.

16. Once the validation build completes the GitHub Check Run will be updated with the result and the Pull Request can be rejected or accepted accordingly.

I hope you have enjoyed this post this is just one of the ineteresting ways which we can leverage to combine Azure DevOps and GitHub Enterprise for Highly regulated customers.