GitHub Actions: A Deep Dive
2020-10-14In my last post, I did a quick overview of GitHub Actions and demonstrated how to use an existing action to deploy a Gatsby site to GitHub Pages. In this post, I'd like to dive deeper into GitHub Actions exploring the underlying motivation behind it, the architecture, and the steps involved in building a custom action.
Before getting into the specifics of GitHub Actions, let's understand their value proposition by delving into GitHub events, and how GitHub has supported the handling of these events.
What Are Events?
Don’t call us, we’ll call you.
An event is a message used by a software component or system to notify a change in its state to another component or system.
For example, within a software system, an Orders component may notify a Notification component about a new order placed so that the notification component can send an email to the customer confirming the order.
Software as a Service (SaaS) products emit events to allow customers of these products to extend core product flows. For example, an e-commerce site integrating with Stripe for payments could use a Stripe event to react to an anomalous payment activity. For example, if a user of the e-commerce site disputes a payment processed on Stripe, the charge.dispute.created
event generated by Stripe could be used to notify the e-commerce site's operations team to look into the matter further.
Events are fundamental to the extensibility of a product. They allow you to extend existing product flows to hook into the tools and processes that you or your organization already use.
GitHub Events
GitHub provides hosting for software projects and version control using git. It has formed the nucleus of the software development lifecycle by supporting collaboration across teams (often distributed), code reviews, and project management.
GitHub events play a big part in helping GitHub do what it does best while also providing extensibility points to integrate with other tools used in the software development lifecycle. You might use GitHub for collaborating on the source code, JIRA for project management, and Confluence for documenting processes. GitHub events help you extend typical activities performed on GitHub to hook into these other tools that you already use.
“Each event corresponds to a certain set of actions that can happen to your organization and/or repository. For example, if you subscribe to the issues event you'll receive detailed payloads every time an issue is opened, closed, labeled, etc.” - The GitHub Events Documentation
For example, when a pull request is merged in GitHub, you might want to update the status of the corresponding story in JIRA.
What GitHub Events Can We React To?
Github provides pretty good event coverage. You can hook into a large number of activities performed on GitHub and extend them according to your team or business' needs.
Reacting to Events — The Before Times
So, how do you listen for and react to GitHub events? Before GitHub Actions, webhooks were the recommended way to consume GitHub Events.
The Event Listener in this case is a web service that exposes an HTTP endpoint. You'd configure your repository with this endpoint. GitHub would POST events to this endpoint whenever events of interest happened in the repository.
Setting up a webhook to respond to an event looks something like this:
- Create a webservice that exposes an HTTP endpoint and contains the logic to handle events of interest
- Configure a webhook in a GitHub repository by providing the HTTP endpoint specified in (1)
- Parse the event payload received from GitHub in the HTTP endpoint and respond appropriately.
The biggest drawback of webhooks is having to stand up an external service. This means writing a new service, deploying it, and maintaining it.
There are entire CI platforms built around GitHub events and webhooks. For example, when you enable a GitHub repository to be built on Travis CI, a webhook is set up that pushes events to an externally hosted Travis CI server (you don’t have to explicitly maintain the server in this case, but it is still an external dependency).
Enter GitHub Actions
GitHub Actions provide a way to react to GitHub events within the GitHub platform instead of having an independently deployed webhook to receive events.
“GitHub Actions is your workflow: built by you, run by us.” — The GitHub Blog
GitHub Actions provide a number of advantages:
- Provides the ability to react to events within GitHub. Avoids introducing a third-party tool and prevents data from flowing through another service.
- Provides a lower pricing plan than a separate service for CI/CD. It is free for public repositories and pay-as-you-go for private repositories.
- Allows the reuse of shared workflows. This point is especially important, as it prevents developers from having to solve the same problems over and over again. Instead, public workflows create an ecosystem of Actions that developers can fork, edit, iterate, and improve upon, much like code.
The Building Blocks — Workflow, Jobs, Steps, Actions, Runners
A GitHub Event triggers a Workflow. A Workflow is run on a Runner.
- A Workflow has one or more jobs (that by default run in parallel)
- A Job has one or more steps that execute on the same runner. Steps in the same job can pass data between them
- A Step (individual task) has 1 action
- An Action is the smallest standalone components combined into steps to create a job
- A Runner is a server that has the GitHub Actions runner application installed which listens for jobs, runs one job at a time, reports progress, logs results
A picture is worth a thousand words:
What Exactly is a GitHub Action?
An action is the smallest standalone component in a workflow. As we saw above, it can be combined into steps to create a job. It runs a bit of code and has the ability to pass information to the next step in a job. There are two ways we can write a GitHub Action:
Javascript Action
Here, you write the code for the action in Javascript. This code is executed using the version of node you specify in the action.yml (more on this later). The version of node supported is mentioned in the runner specification.
There are a few drawbacks to this approach.
- The action is dependent on the version of Node available on the runner. If you use your own runner running node 10, Javascript actions written for node 12 will not work correctly.
- Dependencies need to packaged with the action (either by including the node_modules folder in the action repository or by packaging the node_modules folder into a single file using vercel/ncc)
Docker Container Action
Docker containers bundle the environment and code of a GitHub action together which makes it a more reliable way of packaging an action. The consumer of the action does not need to worry about the tools or dependencies used in the action. For example, if the action specified a node version of 12 but was run on a non standard runner which ran node 10, this would cause issues. Using a Docker container which bundled the correct version of node with the action would avoid such issues.
Creating a Javascript Action
In a previous post, I described how I set up a GitHub webhook to comment on pull requests opened by new contributors on a repository. We're going to recreate this behavior using a GitHub Action.
Initializing The Repository
- Create an empty folder
- Inside the folder:
npm init -y
Describing Our Action
Create an action.yml
file in the same folder. This file contains metadata that describes the intent of this action, its inputs, and its outputs.
name: 'Welcome bot on new pull requests' | |
description: 'Greet new contributors on a repository' | |
inputs: | |
access-token: | |
description: 'A GitHub personal access token used to make comments on your behalf' | |
required: true | |
message: | |
description: 'A personal message to send to a new contributor on your repository' | |
required: true | |
default: 'Welcome, {}! Thank you for your contribution' | |
runs: | |
using: 'node12' | |
main: 'src/index.js' |
This action needs to have an access-token
to be able to comment on a pull request on behalf of a user. The welcome message
to new contributors is also configurable.
We specify that the action runs on node12
and also specify the entry point (where the code for our action is located).
Adding Toolkit Dependencies
GitHub provides an action toolkit with basic utilities that are required by any action. We will use these utilities for reading the event, getting a GitHub API client configured with a token, and other niceties that we don't want to have to end up building ourselves.
npm install @actions/core
npm install @actions/github
Writing The Action Code
const core = require('@actions/core'); | |
const github = require('@actions/github'); | |
async function run() { | |
try { | |
const accessToken = core.getInput('access-token'); | |
const message = core.getInput('message'); | |
const payload = github.context.payload; | |
const githubClient = github.getOctokit(accessToken); | |
core.info("Request received"); | |
if (payload.action === "opened") { | |
core.info("New Pull Request.."); | |
const pullRequest = payload.pull_request; | |
const userName = pullRequest.user.login; | |
const owner = pullRequest.base.repo.owner.login; | |
const repoName = pullRequest.base.repo.name; | |
const issueNumber = pullRequest.number; | |
const comment = message.replace(/{}/g, userName); | |
const shouldComment = await isFirstPull( | |
githubClient, owner, repoName, | |
userName, issueNumber | |
); | |
// Comment on the pull request made by a new contributor | |
if (shouldComment) { | |
core.info("Commenting"); | |
githubClient.issues.createComment({ owner, repo: repoName, issue_number: issueNumber, body: comment }); | |
} | |
} | |
} catch (err) { | |
core.setFailed(err.message); | |
} | |
} | |
run(); |
- We import the
core
andgithub
modules from the GitHub action toolkit - We access the inputs to the action using the
core.getInput
function - We access the event payload using the
github
module:github.context.payload
- We create a GitHub client using the
github
module:github.getOctokit(accessToken)
with the accessToken obtained from the inputs - We inspect the
payload
to see if it is a new pull request (i.e. the status isopened
). We also check to see if this is a pull request made by a new contributor - If it is, we use the
githubClient
we created previously to comment on the pull request:githubClient.issues.createComment
And that's it!
Commiting the Code
- Add the
src
folder,package.json
andpackage-lock.json
files,node_modules
folder - Commit your changes
- Tag your changes
- Push your changes to a Repository on Github
To see the complete code for this GitHub Action, check out my repository on GitHub.
Using Your Action in a Repository Workflow
In the repository where you'd like to use this action and comment on pull requests made by new contributors automatically, add a .github/workflows
folder. Add a .yml
file to this folder to describe this workflow:
name: Comment on pull request | |
on: | |
pull_request: | |
branches: | |
main | |
jobs: | |
comment: | |
runs-on: ubuntu-latest | |
steps: | |
- uses: deborah-digges/new-pull-request-comment-action@v1.9 | |
with: | |
access-token: ${{ secrets.ACCESS_TOKEN }} |
Here is where you specify the Events you want to run the action in response to. In our case, we want it to be on a pull_request
to the main
branch.
The workflow has a single job called comment
which has a single step that consists of the action we created. We reference the action we created using this format: deborah-digges/new-pull-request-comment-action@v1.9
which indicates:
- The GitHub username or organization under which to find the action
- The repository name
- The version of the action (This could either be in the form of a tag or a commit)
Adding An Access Token
We specify the input to our action using ${{ secrets.ACCESS_TOKEN }}
. Add a Personal Access Token to the Secrets
section in your repository.
See the full source code of this repository that makes use of the action here.
Giving The Action a Test Run
Go ahead! Test out your new workflow by making a pull request to your repository! If this is the first pull request you're making to the repository, you should see a welcome message from your friendly GitHub Action!
I hope you enjoyed this deep dive into GitHub Actions. Stay tuned for my next post where I'll go over the details of how to make a Docker GitHub Action. Exciting times ahead!