Sometimes it can be useful to request a manual approval before a deploy is unleashed on production. GitHub supports manual approval when you use environments, but only on public repositories or private repositories for GitHub Enterprise.

In this post, I look at how GitHub Actions and Microsoft Teams can be used to create a manual approval process.

Let us assume a basic deployment process such that when code is committed to main, tests are run, and the code is deployed automatically to staging. After which a manual approval is needed to deploy the changes to production:

A basic deployment requiring manual approval for a release to go to production

How is this going to work?

First, we need to establish how this process will work at a high level. Let's quickly recap the capabilities of GitHub Actions and Microsoft Teams that will likely support what we're trying to achieve.

If you've spent any time playing with GitHub Action, you'll likely be familiar with the most popular ways of triggering a workflow to run:

  • when a branch is pushed
  • a pull request created
  • when a release is created

In addition to this, it is also possible to trigger a workflow manually using a workflow_dispatch event. Workflow Dispatch events support parameter inputs which can be used to capture additional information – this will be important later.

Most messaging apps, including Microsoft Teams, support the idea of adaptive or interactive messaging cards. Here is an example that you might recognise from the Teams documentation:

With an adaptive card the actions available support being able to trigger a POST request when a button is pressed.

Let's revisit our diagram from earlier that explains the process we're wanting to implement:

A basic deployment requiring manual approval for a release to go to production

The left half of this diagram (everything up until Manual Approval) can be easily implemented using GitHub actions, when code is pushed we can perform a series of steps – nothing revolutionary. In fact this is how that could look:

name: CI
on:
  push:
    branches:
    - main

jobs:
  ci:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3.2.0
        with:
          node-version: 14.x
      - run: npm install
      - run: npm test
      - uses: pulumi/actions@v3
        with:
          command: up
          stack-name: staging
.github/workflows/ci.yml

The real challenge is dealing with Manual Approval. With the manual approval, we'll need to wait for a decision. Because this requires human input, this could happen quickly, or it could take a while depending on how busy the person is or what needs to be completed before manual approval can be given (e.g. waiting for QA to complete exploratory testing).

A job in GitHub Actions can run for up to 6 hours, but having the job run and wait for input feels very crude. It also assumes that you'll have a decision within 6 hours, but you'll also incur billing for 6 hours' worth of GitHub Actions compute time.

What if we could make the process of waiting for approval entirely asynchronous?

We might be able to do this if we take the approval decision-making outside of a GitHub workflow. What if we can ask the user to approve a deployment to production by sending them an adaptive card in Microsoft Teams that asks them to press a "Deploy" button if they're happy with the build?

So, with this hypothesis, we need to split our original process into two GitHub Workflows:

  • One workflow will deploy to staging and request approval of a specific commit via a Teams Card
  • Somehow have Microsoft Teams trigger the GitHub workflow for deployment when the "Deploy" button on the card is pressed
  • The other workflow (deployment workflow) will deploy to production once we have received an approval

As you'd probably expect, given the nature of this post, Microsoft Teams doesn't provide an out-of-the-box approach for triggering a workflow from a Teams card. Therefore, we'll need to figure out how we use teams to glue together these two workflows.

After some research, it turns out that the HttpPOST action supported by Microsoft Teams will allow for a POST request to be triggered to a given URL. However it won't provide any post-body data to help identify which card this request came from. Therefore the card we send to teams will have to have a deploy POST URL that is specific to that card and given approval request.

Therefore, one way to tackle this is to generate a unique URL for deploying a given workflow at the point where approval is needed. Then, send this unique URL in the Microsoft Teams adaptive card that requests the deployment and use this unique URL to kick off the GitHub Workflow that is responsible for deploying to production. Our "glue" to bridge the gap between GitHub Actions and Microsoft Teams will be a couple of HTTP endpoints hosted by an Azure function and a small amount of Table Storage.

Drawn as a series of processes, this would look as follows. Click here for a full screen view:

Diagram showing a manual approval process using GitHub Actions, Microsoft Teams and Azure Functions.
View diagram in full screen.

With an approach established, we can follow the following steps to get a prototype working. I'll start with the GitHub Workflows, as these are more straightforward to set up and provide context to what will need to happen in Azure with Azure Functions and Table Storage.

Step 1: Creating the CI Workflow

I have an azure-static-website repository that I am using as an example codebase that I want to deploy using a manual approval process. Any change to main is automatically deployed to staging, but to deploy to production requires manual approval.

I have a CI workflow which is responsible for the following steps:

  1. Compile/Build
  2. Run tests
  3. Deploy to Staging
  4. Request approval via Microsoft Teams for deployment to production
name: CI
on:
  push:
    branches:
    - main

jobs:
  ci:
    name: Build
    runs-on: ubuntu-latest
    steps:
      # Checkout source code
      - uses: actions/checkout@v3
      # Setup node
      - uses: actions/setup-node@v3.2.0
        with:
          node-version: 14.x
      # Install dependencies
      - run: npm install
      # Run tests
      - run: npm test
      # Deploy to staging
      - uses: pulumi/actions@v3
        with:
          command: up
          stack-name: staging
        env:
          ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}
          ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
          ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
      # Trigger an approval request via Microsoft Teams
      - uses: jamesridgway/github-actions-approval-request@main
        with:
         trigger-workflow: 27506981
         notification-webhook-url: ${{ secrets.WEBHOOK_URL }}
         github-actions-approval-api-url: ${{secrets.ACTIONS_APPROVAL_API_URL}} 
         github-actions-approval-api-key: ${{secrets.ACTIONS_APPROVAL_API_KEY}} 
.github/workflows/ci.yml

This is a basic static website that deploys to Azure using Pulumi to manage the infrastructure. The final step in this process triggers the approval request, which is done via a GitHub Action called github-actions-approval-request.

This is a very basic and generic action which takes the following inputs:

  • trigger-workflow
    ID of Workflow to trigger for the repository
  • notification-webhook-url
    Microsoft Teams Webhook URL to notify
  • github-actions-approval-api-url
    Base URL for github-actions-approval-api – this is the API that we will build in Step 3.
  • github-actions-approval-api-key
    API Key for GitHub Actions Apporval API

The notification-webhook-url is the URL provided by the Incoming Webhook connector:

Accessible from the Connectors menu on your given team.

The action can also automatically determine the repository's name and the Commit ID from the context of the GitHub Action.

These details are then sent in a single POST requst to an API endpoint that we'll build in Step 3:

const webhookPayload = {
    repositoryFullName,  // Repository name
    commitHash,          // Commit ID
    workflowIdToTrigger, // Production workflow to trigger
    webhookUrl           // Webhook URL
}
axios.post(`${gitHubActionsApprovalApiUrl}/api/approval`, webhookPayload)
    .then(response => console.log(`Webhook responded with a ${response.status} response`))
    .catch(error => console.error('There was an error ', error));
Extract from index.js

The API that we build will have two main responsibilities:

  1. Receiving the request for approval and issuing a Microsoft Teams adaptive card
  2. Receiving the deploy request from the adaptive card and triggering the given workflow associated with that card.

Step 2: Creating the Deploy Workflow

Our repository will also need a workflow for deploying to production:

name: Deploy Production
on:
  workflow_dispatch:
    inputs:
      commit:
        # Require the Commit ID as an input so that we deploy a specific commit
        type: string
        description: 'Commit ID to deploy'
        required: true
jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    steps:
      # Checkout source code at a specific commit
      - uses: actions/checkout@v3
        with:
          ref: ${{ github.event.inputs.commit }}
      # Setup node
      - uses: actions/setup-node@v3.2.0
        with:
          node-version: 14.x
      # Install dependencies
      - run: npm install
      # Deploy to production
      - uses: pulumi/actions@v3
        with:
          command: up
          stack-name: production
        env:
          ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}
          ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
          ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
.github/workflows/deploy.yml

In my specific example, I'm deploying to production using Pulumi. As you would expect, this step would change depending on your project and tech stack. The two key things to this workflow are:

  1. commit is a required input that the workflow needs to run
  2. The commit input is used to checkout a specific commit so that we ensure the code that was approved is the code that gets deployed.

Before we progress to Step 3, we can use this GitHub action at this point to deploy our code to production. However, we'll have to supply the required Commit ID before we can kick off the workflow:

GitHub Actions requiring a Commit ID as an input to deploy a specific version of the codebase

In Step 3, we'll build the API responsible for triggering the Microsoft Teams card and handling the "Deploy" button press from the Teams card.

Step 3: Building the API using Azure Functions and Table Storage

Finally, we need to build an API capable of issuing an adaptive card for Microsoft Teams and dealing with the response from the card actions.

I wanted the solution for this to be as lightweight as possible. I therefore opted to build a small REST API using Azure Function with Table Storage for persistence.

The complete code for this is available in github-actions-approval-api, however let me take you through the basic principles behind the codebase. If we ignore any boilerplate code, then the main logic of this API is less than 150 lines of code – nice and simple. In addition, Pulumi infrastructure-as-code for deploying this API to your Azure account is included in the repository.

POST /api/approval

This endpoint receives the following details in the request body:

  • Repository Name
  • Commit ID
  • Workflow ID to Trigger
  • Microsoft Teams Webhook URL to use

All of these details are then stored in Table Storage. The repository name is used to derive a partition key and a generated UUID which we will call the "Approval ID". The Approval ID is used for the row key.

A URL is then generated that can receive a POST request without a body (because Microsoft Teams cannot send a post body that is useful for our purposes):

POST /api/approval/${partitionKey}/${approvalId}

This URL is the URL that is injected into the MessageCard payload that is send to teams requesting the deployment to be approved.

So in summary, this endpoint is responsible for:

  1. Generating an Approval ID
  2. Storing all POST body details against that Approval ID for later retreival in Table Storage
  3. Triggering an adaptive message card that includes a deploy URL based on the Approval ID

This endpoint will trigger a card in Teams that looks as follows:

Microsoft Teams adaptive Message Card requesting approval to deploy

The "Deploy" button will invoke our endpoint described below.

POST /api/approval/:partition_key/:approval_id

This endpoint will lookup the details for this deployment from Table Storage, using the repository name (as Partition Key) and Approval ID (as row key).

This endpoint will then invoke the GitHub Actions API to trigger the workflow dispatch, with the required Commit ID included as an input in the event paylaod:

const triggerWorkflowUrl = `https://api.github.com/repos/${approval.repositoryFullName}/actions/workflows/${approval.workflowIdToTrigger}/dispatches`;
const workflowPayload = {
    ref: 'main',
    inputs: {
    commit: approval.commitHash
    }
};

const githubUsername = (await secretClient.getSecret('github-username')).value;
const githubToken = (await secretClient.getSecret('github-token')).value;
const response = await axios.post(triggerWorkflowUrl, workflowPayload, {
    auth: {
        username: githubUsername,
        password: githubToken
    }
});
Extract from app/api/app.js

In summary, this endpoint:

  1. Looks up the contextual information from Table Storage
  2. Uses the contextual information to kick off the correct dispatch workflow for the given approval

Conclusion

It works! Clicking "Deploy" will successfully trigger the deploy workflow in GitHub Actions.

Interfacing with Microsoft Teams and providing the integration between Teams and GitHub required a bit of creativity and is the most complicated part of the process. But if we consider the fact that the API behind this is a single Azure Function and a bit of Table Storage, I think this ticks the box of being a lightweight and cost-effective solution.

What I've outlined here is a proof of concept. It could definitely be refined and polished with further work, but the core elements are there and they work reliably.

In conclusion, it is possible to put in place a manual approval process that uses Microsoft Teams and GitHub Actions. Both the API (github-actions-approval-api) and GitHub Action (github-actions-approval-request) are generic and can be used across any number of repositories. The only thing that you'll need to do if you want to use this approval mechanism is to deploy your own version of the API using the Pulumi code provided in the API repository.

Happy deploying!