How to start using GitHub Actions for CI

Edit: In August 2019, GitHub introduced GitHub Actions v2, with a ton of features focus on CI. The below post is outdated, but still interesting so I'm keeping it around! If you want to learn more about Actions v2, check out New features of GitHub Actions v2.


GitHub Actions can do a whole lot of things - delete branches, compose tweets via pull requests; it's kind of a general "thing do-er." Still, the first reaction people have is that it replaces CI. Actions are a great platform to have CI built into your workflow with minimal setup but a lot of control, so its a totally reasonable idea, but I get to use everyone's favorite answer: it depends!

In this post, I'll share a workflow that I've been using for my Node.js projects, as well as some extensions to it for additional functionality. We'll also take a look at features of popular CI tools and see how they map to GitHub Actions.

Some pre-reading

I'll be delving into the nitty-gritty of writing a workflow file, including some lesser-known functionality. I'd encourage you to read my previous blog post on GitHub Actions workflows, where I talk a bit about workflows as a whole.

You may also want to familiarize yourself with the actions/bin repo, a collection of actions that are highly scoped and useful for composing a workflow without writing any custom code (especially actions/bin/filter).

But I love {{ ci_provider }} - why should I care?

I'm not saying that existing projects should migrate their CI to Actions. Rather, new projects benefit from the minimal setup of a CI workflow if you know what you're doing. Are GitHub Actions the best CI platform? I'd say it really depends on your needs; if you're just running tests and viewing the logs/status, you can get a lot of functionality with very little effort (I know, the dream 😍).

A typical Node.js workflow

This is a main.workflow file that I've been using for a couple of projects and it's been a great experience. With most CI providers, I need to follow these steps:

  1. Push a configuration file to my repo
  2. Go to their site and find my repo
  3. Hit a checkbox/toggle to enable that CI provider to build my repo
  4. Push again to trigger CI

Nowadays, I create my .github/main.workflow file and this is my whole process:

  1. Push a configuration file to my repo

75% efficiency improvement! Because most of my projects follow the exact same CI patterns and have the exact same requirements, it really is repetitive. So, here's the main.workflow file in its entirety:

workflow "Test my code" {
on = "push"
resolves = ["npm test"]
}

action "npm ci" {
uses = "docker://node:alpine"
runs = "npm"
args = "ci"
}

action "npm test" {
needs = "npm ci"
uses = "docker://node:alpine"
runs = "npm"
args = "test"
}

Let's break this down. There are some decisions I've made here that may not be perfect for every project, but that's okay - it's just a starting point.

The first thing that may look new to you is this docker:// line:

action "npm ci" {
uses = "docker://node:alpine"
runs = "npm"
args = "ci"
}

Instead of pointing to an action that has a Dockerfile, you can tell it to use a particular Docker image. It's like declaring FROM node:alpine, but without needing a Dockerfile 🎉. Because most test frameworks (like Jest, which I typically use) exit with a status code of 1 or greater if at least one test fails, Actions will consider it a failure and report back to your commit or pull request accordingly.

Another question you may be asking:

📣 Jason, why didn't you just use the actions/npm action?

Great question! Let's step back for a second and remember that GitHub Actions build and run Docker images. The smaller the image, the faster your action will run - less download time, less build time, means less overall running time.

actions/npm uses the node Docker base image, which isn't quite as small as node:alpine. You can read up on the differences to see what's right for your project. So far, the biggest practical difference that I've found is that node:alpine doesn't ship with Git, so if your project uses dependencies installed from a Git repository you'll need to use node.

We then define a runs property to use the npm CLI that ships with Node.js. This one file gets us two individual Docker containers that run npm ci to install our app's dependencies and npm test to run our automated tests. Actions in the same workflow can access a shared filesystem, despite each action running in a separate container.

Code coverage

In a subset of my projects, I use Codecov to track how much of my code is covered by my tests. It's a great tool and has a CLI - if it has a CLI, we can use it in Actions 😎. So here's an addition we can make:

# An action that uses `npx` and the `codecov` CLI
action "codecov" {
needs = "npm test"
uses = "docker://node"
runs = "npx"
args = "codecov"
secrets = ["CODECOV_TOKEN"]
}

We also need to update the resolves property of our workflow to ensure that our actions' ordering is correct:

 workflow "Test my code" {
on = "push"
- resolves = ["npm test"]
+ resolves = ["codecov"]
}

And after adding the CODECOV_TOKEN secret in the GitHub UI, we're done! Using npx we download and run the codecov library, and so long as our test script is setup to output coverage data (like with jest --coverage) the CLI does the rest ✨

Testing multiple version of Node.js

This is a really interesting task that we can tackle without too much complication. We basically want to define two (or multiple) trees; one for each version. Let's have our workflow run our tests in the newest version of Node.js and version 10.x.x (which at the time of writing is LTS).

First, let's prepare our resolves property to wait for two distinct actions before marking the workflow as completed:

 workflow "Test my code" {
on = "push"
- resolves = ["npm test"]
+ resolves = ["npm test (10)", "npm test (latest)"]
}

Next, we'll duplicate our actions but specify a version in our uses:

# node@10
action "npm ci (10)" {
uses = "docker://node:10-alpine"
runs = "npm"
args = "ci"
}

action "npm test (10)" {
needs = ["npm ci (10)"]
uses = "docker://node:10-alpine"
runs = "npm"
args = "test"
}

# node@latest
action "npm ci (latest)" {
uses = "docker://node:alpine"
runs = "npm"
args = "ci"
}

action "npm test (latest)" {
needs = ["npm ci (latest)"]
uses = "docker://node:alpine"
runs = "npm"
args = "test"
}

That's pretty much all there is to it. We've got two trees of actions (based on their needs properties) that will run in separate containers on separate versions of Node.js. Here's how GitHub renders the workflow in the visual editor:

Rendering of the dual-version workflow

You could also choose to do two separate workflows, to have two distinct statuses:

workflow "Test my code in node@10" {
on = "push"
resolves = ["npm test (10)"]
}
workflow "Test my code in node@latest" {
on = "push"
resolves = ["npm test (latest)"]
}

Converting a CI provider's config file to a workflow

We're going to convert the .travis.yml file in the facebook/jest repository (one of my favorite libraries) over to a main.workflow file. At the time of writing, here's what it looks like:

# https://github.com/facebook/jest/blob/HEAD/.travis.yml
language: node_js
node_js:
- '10'
before_install:
- curl -o- -L https://yarnpkg.com/install.sh | bash
- export PATH="$HOME/.yarn/bin:$PATH"
install: yarn --frozen-lockfile
cache:
yarn: true
directories:
- '.eslintcache'
- 'node_modules'
script:
- yarn run test-ci-partial

Some parts of this don't map perfectly to Actions. The cache property doesn't have an equivalent - instead, GitHub caches Docker images. There's lots still to do in this space to make Actions run faster, so let's skip it for now.

That leaves us with the following information: we're using node@10, yarn, and running the test-ci-partial script after installing our dependencies. Here's what that might look like:

workflow "Test my code" {
on = "push"
resolves = ["test-ci-partial"]
}

action "Install dependencies" {
uses = "docker://node:10"
runs = "yarn"
args = "--frozen-lockfile"
}

action "test-ci-partial" {
needs = "Install dependencies"
uses = "docker://node:10"
runs = "yarn"
args = "run test-ci-partial"
}

That should do it! By using the docker:// protocol on the uses property, we can keep our workflow nice and clean 💅. One thing to note is that I've used the full docker://node:10 image - for this repository, it's needed to support some dependencies. You might want to put some effort into a speedier run by using a smaller base image - on my fork of the repo, this took over 15 minutes to run 😱

A non-hypothetical example

Here's a similar exercise with proven results in JasonEtco/create-an-issue of replacing a basic .travis.yml file with a workflow. Performance is important for CI, we want our tests to run quickly - so let's look at the difference in execution time:

Provider Execution time
Travis CI 36 seconds
GitHub Actions 34 seconds

They're basically the same! The functionality stays exactly the same; no additional features of either platform are in use, it just runs our tests. We haven't taken into account any of the speed improvements available to us in TravisCI, this is just the default behavior. Comparatively, I think that execution speed will only improve with GitHub Actions, and I'm really curious to see what kind of improvements users will get without any additional effort.

README Badges

A beloved feature of most CI providers is their ability to show a badge on a repository's README, depicting the status of the build (whether its passing, failing, etc). I'm so used to the badges that if they aren't present my eyes get confused. Unfortunately, there's no first-class badge support with Actions. I built JasonEtco/action-badges for this purpose; it works by querying for the repository's Check Suites and deriving a status by looking for the GitHub Action app's activity.

![Build Status](https://action-badges.now.sh/JasonEtco/example)

Still, I'd love to see first-class support in the future :fingers_crossed:

Running CI on pull requests from forks

GitHub Actions and forked repositories are currently in a weird state. I expect this to improve quickly, but right now when a pull request is opened from a fork to the upstream, there are a few oddities. The first is that it will only trigger the pull_request event - that makes sense because the associated push isn't happening on the upstream repo. However, this leads to the actual issues: the tests are run against the default branch, and the status isn't reflected back to the pull request.

@gr2m created git-checkout-pull-request-action to checkout the fork's branch before running tests - it'll intercept the workflow and, if necessary, make sure the tests are running against the appropriate code. This solves the first of those two problems, but not the second - the PR isn't updated with the status of the checks 😞. The only way to check that status is to open the Actions tab and try to find the correct run.

I fully expect that behavior to change for the better before GitHub Actions leaves beta status (the Actions team is full of really smart people), so I hope to update this post when it does!

Where Actions isn't perfect

This post isn't intended to somehow prove that independent CI tools are made redundant by Actions - just that for some use-cases, you now have one more option.

For example, a project I use and love, matchai/spacefish, can't use Actions for CI because Docker doesn't support macOS images. Some projects need to be tested in environments that Docker just doesn't support. And that workflow with multiple versions of Node.js? With more versions/variations it'd become even more verbose.

And that's ok - GitHub Actions is awesome, but it's not a silver bullet. It doesn't need to do everything, perfectly. Dedicated products for CI (or really anything) will always have a leg-up over individual products trying to do it all; that's the platform approach. So go forth and use Actions for CI, or whatever tool you like to use best ✨