7 min read

Implementing ChatOps on GitHub Actions

Justin Yoo

There have been many discussions around "Continuous Delivery" and "Continuous Deployment" while composing DevOps pipelines. A rough comparison between both can be depicted below:

Depending on circumstances at your organisation, either taking "delivery" or "deployment", or combining both approaches could be possible. The significant difference between both is whether there is a manual step-in process included (Continuous Delivery) or not (Continuous Deployment).

Generally speaking, GitHub Actions supports "Continuous Deployment" out-of-the-box. In other words, to achieve "Continuous Delivery", we need a different approach. There have been many popular methodologies introduced. GitOps and ChatOps are the two most popular ones. Throughout this post, I'll show you how to implement ChatOps on GitHub Actions pipelines to become more interactive, using Microsoft Teams.

What Do We Need for ChatOps?

As the name indicates, we need a chatting platform like Slack or Microsoft Teams. I'm going to use Microsoft Teams for ChatOps.

Sending Messages to Microsoft Teams from GitHub Actions

In my previous post, I introduced a custom GitHub Actions and built a Microsoft Teams action. With this action, we can easily send messages to Microsoft Teams. There are many different formats we can send to Microsoft Teams, but those two formats are frequently used for ChatOps.

  1. Open URI: It provides an external link so that members in the Teams channel can click the link.
  2. HTTP POST: It raises a webhook event so that the external system can capture the event message and process it.

Like the first image, the Open URI format simply provides external URLs, which is useful for notifications. On the other hand, the second image represents the HTTP POST format that sends a webhook payload to an event broker or handler. In addition to this, this can be more interactive with the invocation status, which will result in better user experiences.

The Microsoft Teams action can be defined as below:

- name: Send a message to Microsoft Teams
uses: aliencube/microsoft-teams-actions@v0.8.0
with:
webhook-uri: https://outlook.office.com/webhook/<GUID>/IncomingWebhook/<GUID>
title: <Message Title>
summary: <Message Summary>
text: <Message Text>
theme-color: <Message Theme Color>
sections: '[{ "activityTitle": "hello world" }, { ... }]'
actions: '[{ "@type": "OpenUri", "name": "lorem ipsum", "targets": [{ "os": "default", "uri": "https://localhost" }] }, { ... }]'

The actual implementation that I took from another project looks like this. There are many variables expressed with ${{ ... }}. As they are from other actions, we don't need to worry about them for now.

- name: Send a message to Microsoft Teams
uses: aliencube/microsoft-teams-actions@v0.8.0
with:
webhook_uri: ${{ steps.kvsecrets.outputs.TeamsWebhookUri }}
title: ''
summary: 'Artifact for ${{ matrix.targetPlatform }}, version ${{ github.event.client_payload.artifact.version }}, has been distributed to App Center'
text: ''
theme_color: ''
sections: '[{ "activityImage": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png", "activityTitle": "Artifact Distributed to App Center", "activityText": "Artifact for ${{ matrix.targetPlatform }}, version ${{ github.event.client_payload.artifact.version }}, has been distributed to App Center" }]'
actions: '[{ "@type": "OpenUri", "name": "Go to App Center", "targets": [{ "os": "default", "uri": "${{ format(steps.kvsecrets.outputs.AppCenterUri, steps.kvsecrets.outputs.AppName, matrix.targetPlatform) }}" }] }]'

You might feel overwhelmed by sections and actions parameters. This will be resolved sooner rather than later. who knows?

The second option, HTTP POST, would be better for us to use. Let's have a look at the definition below:

- name: Send a message to Microsoft Teams
uses: aliencube/microsoft-teams-actions@v0.8.0
with:
webhook_uri: ${{ steps.kvsecrets.outputs.TeamsWebhookUri }}
title: ''
summary: 'Artifacts version ${{ steps.buildnumber.outputs.build_number }} have been published to Azure Blob Storage'
text: ''
theme_color: ''
sections: '[{ "activityImage": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png", "activityTitle": "Artifacts Published", "activityText": "Artifacts version ${{ steps.buildnumber.outputs.build_number }} have been published to Azure Blob Storage" }]'
actions: '[{ "@type": "HttpPOST", "name": "Distribute to App Center", "target": "${{ steps.kvsecrets.outputs.ApprovalTargetUri }}", "headers": [{ "name": "x-functions-key", "value": "${{ steps.kvsecrets.outputs.ApprovalTargetAuthKey }}" }], "bodyContentType": "application/json", "body": "{ \"event_type\": \"distribute-appcenter\", \"client_payload\": { \"action\": \"distribute\", \"artifact\": { \"version\": \"${{ steps.buildnumber.outputs.build_number }}\", \"name\": \"UnicornDash\" } } }" }]'

Message Analysis

Let's beautify the JSON object at the actions parameter. The JSON schema is defined at the HTTP POST action.

[
{
"@type":"HttpPOST",
"name":"Distribute to App Center",
"target":"${{ steps.kvsecrets.outputs.ApprovalTargetUri }}",
"headers":[
{
"name":"x-functions-key",
"value":"${{ steps.kvsecrets.outputs.ApprovalTargetAuthKey }}"
}
],
"bodyContentType":"application/json",
"body":"{ \"event_type\": \"distribute-appcenter\", \"client_payload\": { \"action\": \"distribute\", \"artifact\": { \"version\": \"${{ steps.buildnumber.outputs.build_number }}\", \"name\": \"UnicornDash\" } } }"
}
]

As you can see, the webhook payload is sent to somewhere by an HTTP POST request. While the webhook event can be directly sent to GitHub, I deliberately include an Azure Functions endpoint in the middle, which I'll discuss later in this post. The webhook payload looks like:

{
"event_type":"distribute-appcenter",
"client_payload":{
"action":"distribute",
"artifact":{
"version":"${{ steps.buildnumber.outputs.build_number }}",
"name":"UnicornDash"
}
}
}

The event payload format follows the definition of Repository Dispatch. It's currently a public preview at the time of writing this post, which may change later at any time.

  • event_type: It's the string type, not the enum type. Therefore, any string can be acceptable. But your organisation better to define these types.
  • client_payload: It's the object type. Therefore any JSON object can come here. Again, your organisation should define the format.

Send messages to Microsoft Teams through GitHub Actions, with this composition. Then clicking the message on the Microsoft Teams channel will raise the webhook event of Repository Dispatch to GitHub via Azure Functions. This event now turns the approval process.

ChatOps on GitHub Actions

Can a GitHub Actions workflow take the event raised from Microsoft Teams? Of course, it can. There are many events that trigger [GitHub Actions] workflows, including push and pull request. There are other different event types, including Repository Dispatch. Therefore, if we build a workflow that takes this event, then it will work! Let's have a look at the workflow below:

name: Distribute Unity Apps to App Center
on: repository_dispatch
jobs:
distribute-to-appcenter:
name: Distribute the ${{ matrix.targetPlatform }} app to App Center
if: github.event.client_payload.action == 'distribute'
...
view raw workflow.yaml hosted with ❤ by GitHub

This workflow only reacts on the event, repository_dispatch. Let's define the release process in this workflow. Then, if we click the "Approve" button from the Microsoft Teams channel, the approval process gets initiated.

NOTE: Please be mindful here. You should use the if condition here to filter out the event, whether the repository_dispatch event is for me or not; otherwise, all payloads coming through the repository_dispatch event will trigger the workflow, which is not desirable.

What Does Azure Functions Do Here?

As we saw, the webhook payload defined in the Microsoft Teams channel sends the event to Azure Functions first. In fact, it's totally OK to send the event directly to GitHub. Why did we implement Azure Functions then? The response code from GitHub is either 204 No Content or 400 Bad Request, without a response body. Events typically are raised and forgot. They don't care who to consume. The event handler should take care of them, not the event source. But in this case, the event handler side, GitHub, only returns the status code. We don't know if the event has been properly consumed or not.

Therefore, we put an event broker to handle this. There are several event brokers on Azure – Event Grid, Logic Apps and Azure Functions. We use the Azure Functions here in this post.

If this event is mission-critical, I'd recommend using Event Grid for DLQ (Dead Letter Queue).

There's another reason using Azure Functions. When we use the HTTP POST on Microsoft Teams, we can enrich the user experience by adding a response header of CARD-ACTION-STATUS. If the response contains this header, Microsoft Teams can show the action invocation status as a reply. If we directly use the GitHub event, we can't make use of the response header. Here's the sample Function code:

public static class ActionInvokeHttpTrigger
{
[FunctionName("ActionInvokeHttpTrigger")]
public static async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequest req,
ILogger log)
{
log.LogInformation("C# HTTP trigger function processed a request.");
string body = await new StreamReader(req.Body).ReadToEndAsync();
using (var client = new HttpClient())
using (var content = new StringContent(body))
{
var authKey = "<AUTH_KEY>";
var requestUri = "https://api.github.com/repos/<OWNER>/<REPOSITORY>/dispatches";
var accept = "application/vnd.github.v3+json";
var userAgent = "<MY_USER_AGENT_NAME>";
client.DefaultRequestHeaders.Add("Authorization", authKey);
client.DefaultRequestHeaders.Add("Accept", accept);
client.DefaultRequestHeaders.Add("User-Agent", userAgent);
using (var response = await client.PostAsync(requestUri, content).ConfigureAwait(false))
{
try
{
response.EnsureSuccessStatusCode();
req.HttpContext.Response.Headers.Add("CARD-ACTION-STATUS", $"Distribution of the app, {payload.client_payload.artifact.name}, to App Center has been invoked.");
}
catch
{
req.HttpContext.Response.Headers.Add("CARD-ACTION-STATUS", "Oops, something goes wrong!");
}
}
}
var result = new OkObjectResult(body);
return result;
}
}
view raw function.cs hosted with ❤ by GitHub

Depending on the HTTP API result from GitHub, we can set up a different CARD-ACTION-STATUS header value in the response, and Microsoft Teams will know the approval process has gone successful or not.


So far, we have walked through implementing ChatOps on GitHub Actions with Microsoft Teams. This is a really simple use case. Your organisation might have more complex scenarios. Instead of Azure Functions, how about using Event Grid or Logic Apps for your use cases? I'll leave that to you.