Tierney Cyren

An Unintentionally Comprehensive Introduction to GitHub Actions CI

Originally posted on

We're currently approaching GitHub Actions v2 shipping publicly for everyone to use. I'm personally super excited about this because it means I don't need to configure an external service to run my CI – I can slap in some YAML, and I'm off with a cross-platform (!) CI system with multiple versions of Node.js installed.

For me, that's bliss. No need to go to an external site; everything is very neatly contained. That said, when I've used other CI services in the past (primarily Travis CI and Azure Pipelines) I've generally just copy/pasted someone else's CI configuration from the beginning and then tweaked it with additional context.

This time though, there's minimal prior context. During the beta of Actions v2, GitHub has published a few different CI templates that I could copy/paste certain parts from. However, there are a few standards I hold all of my projects to:

This ends up meaning I have a matrix of anywhere from 9 (3 versions multiplied by three operating systems) to 12 (4 versions multiplied by three operating systems) CI runs on every project at any time. I've found that the implementation of how to achieve this varies greatly depending on the CI system.

Given that there's not going to be a massive amount of prior art on release, I figured I'd begin building out some comprehensive templates so at launch people will have something to easily copy/paste and then tweak to suit their exact needs.

GitHub Actions CI Templates

After working on adding GitHub Actions CI to good-first-issue, I figured I should probably abstract the CI file into a repo, so it's a bit more accessible.

As such, last night, I built out GitHub Actions CI Templates. Initially, I shipped it with a single template that covered my needs around Node.js and npm, but as of about an hour ago I've added two additional templates: Node.js and Yarn, and Node.js and pnpm.

If you'd like to check out the templates, they're all relatively straightforward as far as YAML goes:

Dissecting the GitHub Actions YAML for the Templates

The templates all follow a relatively similar structure. I figured I'd walk you through each line of code of the Node.js Cross-Platform file to help ensure that they're understandable to you. Let's go line by line, with code on the top and the description on the bottom:

name: Node.js Cross-platform CI (using Yarn)

The above line is the title of the entire CI script, as it'll show up in the Actions tab of the GitHub repo.

Relevant docs:

on: [push]

The above line indicates the trigger for a run. For most CI cases, [push] will be ideal since you want it to run every time you push code to the repo or to a PR.

Relevant docs:

jobs:

Workflows are composed of one or more jobs. This line is an indicator that we've got multiple jobs to be run.

Relevant docs:

  build:

This one is the job_id of our specific job. Since we're running a build, I named this build but this specific name has no semantic meaning inside of GitHub Actions CI itself.

Relevant docs:

    runs-on: $

This is a required property, which tells the CI run what kind of machine it should be running on. In our case, we've added some complexity by adding a matrix of operating systems that need to be built against. That said, the context of the matrix gets hoisted, and we can use that context here.

One key thing to note from the docs:

Each job runs with a fresh instance of the virtual environment specified in by runs-on.

Meaning, every job is running a clean instance of whatever OS is selected. This is table stakes for CI, but it's always useful to keep it in mind. ❤️

Relevant docs:

    strategy:

Having a strategy line is the way to begin defining a matrix of environments to run your builds in.

Relevant docs:

      matrix:

The tl;dr of a matrix is that it's the set of all the pieces of context you'll want to run against. The most straightforward matrix is one row – for example, multiple Node.js versions on a single platform.

A simple matrix:

ubuntu-latest
Node.js 8
Node.js 10
Node.js 12

That said, JavaScript and Node.js applications are effectively run on all three of the major operating systems in the world as a part of developer workflows. Often, we'll want to run on the three major operating systems to ensure that there are no unexpected platform-specific bugs that are going to occur – especially in open source when there are very few direct paths to end-users. Luckily, a matrix makes this relatively straightforward.

By adding in multiple operating systems, our matrix gets more complex:

ubuntu-latest macos-latest windows-latest
Node.js 8 Node.js 8 Node.js 8
Node.js 10 Node.js 10 Node.js 10
Node.js 12 Node.js 12 Node.js 12

But... that's only the latest versions of each platform. What about older versions that we may often need to support? Well, it turns out that we can also use older versions of each platform in GitHub Actions CI, which could even further complicate the matrix:

ubuntu-latest ubuntu-16.04 macos-latest macOS-10.14 windows-latest windows-2016
Node.js 8 Node.js 8 Node.js 8 Node.js 8 Node.js 8 Node.js 8
Node.js 10 Node.js 10 Node.js 10 Node.js 10 Node.js 10 Node.js 10
Node.js 12 Node.js 12 Node.js 12 Node.js 12 Node.js 12 Node.js 12

And this is currenlty a downtime for Node.js builds. Half of the year (every year) there are 4 supported release lines, which would look more like this:

ubuntu-latest ubuntu-16.04 macos-latest macOS-10.14 windows-latest windows-2016
Node.js 8 Node.js 8 Node.js 8 Node.js 8 Node.js 8 Node.js 8
Node.js 10 Node.js 10 Node.js 10 Node.js 10 Node.js 10 Node.js 10
Node.js 12 Node.js 12 Node.js 12 Node.js 12 Node.js 12 Node.js 12
Node.js 13 Node.js 13 Node.js 13 Node.js 13 Node.js 13 Node.js 13

A matrix is super useful in helping us programmatically define such a list without actually having to define each of these contexts individually. This utility mostly comes when you start adding more platforms and versions, but thankfully the overhead of doing that is incredibly low from the configuration side of things (see the following sections for more context)

Relevant docs:

        os: [ubuntu-latest, windows-latest, macOS-latest]

The above is effectively a variable that we're assigning to the matrix, which can be dynamically called. In our case, we're just saying that the os variable on matrix (so matrix.os) is going to be each of these. The how is still a bit magic to me, but... it works, seemingly by iterating over each of them when they're called. When used in conjunction with another variable (like node-version), they're iterated over to create something like the tables above effectively.

Relevant docs:

        node-version: [8.x, 10.x, 12.x]

Another variable where we're going to define the Node.js versions we'd want to be running.

Relevant docs:

    steps:

Each job contains a set of steps. This specific line is where we indicate that we're going to begin defining the steps.

Relevant docs:

    - uses: actions/checkout@v1

Tells our workflow that we're going to be using the GitHub Action that can be found at actions/checkout which maps to the GitHub org/repo at [gihub.com/actions/checkout]. It's also worth noting that @v1 which is a tagged and released version that can be found in the GitHub Releases for the repo.

Relevant docs:

    - name: Use Node.js $NaN on $

The name to display for the job in the UIs that it's rendered within, given the various variables that we've inserted using matrix.

Note: There seems to be a bug where this does not render properly – instead of rendering as a tagged template literal, the Actions UI renders as a string.

Relevant docs:

      uses: actions/setup-node@v1

Defines an external action – in this case, the [github.com/actions/setup-node] action at version 1.x.x (as released via the GitHub repo). In our case, this is an action that provides a super handy interface to install arbitrary versions of Node.js other than the version that comes baked into the VMs that are provided. My guess is that this will be a default action for anyone who is running JavaScript or Node.js builds simply because it handles so much for you by default.

It's worth noting that actions consumed with uses: can be sourced from within the same repository, from a public repository, and from a Docker image published to Docker Hub.

Relevant docs:

      with:

This is a map (my assumption is that this is a map in the sense of YAML's definition of a map) of the parameters defined in the action. In our case, actions/setup-node needs a version to run with.

Relevant docs:

        node-version: $NaN

The actions/setup-node action needs a version of Node.js to run, via the node-version: property. Since we named the variable for Node.js versions in our Matrix node-versions, we're able to pass matrix.node-version to the node-version: property.

Relevant docs:

    - name: npm install and test

We're again defining the name of a job. In this case, there's no dynamic information since the commands we're going to be running are pretty static.

I use npm install and npm test, but your applications may vary in install/build/test/ci commands – my recommendation for this is to tweak both the title and the actual commands, so it's extremely clear what's being run.

Relevant docs:

      run: |
npm install
npm test

This is an interesting set of lines for those unfamiliar with YAML. We start with using a run property for the job, which allows us to run any command on the system. In our case, we're going to use this to run npm install and npm test... but those are two different commands, that need to be run separately. The pipe (|) is a tool defined in the YAML spec as Literal Style. In our case, it allows us to write multiple lines that execute independently without having to use multiple run: commands or multiple jobs. Basically, it's shorthand that enables use to be looser in how we're able to build out our file.

Note: It may be worth using npm ci rather than npm install to install dependencies, given that npm ci was tailor-made for CI environments. You can find more details on npm ci in the official documentation.

Relevant docs:

      env:

Allows us to set up environment variables in our virtual environments with relative ease.

Relevant docs:

        CI: true

This one is a personal preference, and also happens to be the default for the simplest Node.js workflow suggested by GitHub. Simply sets an environment variable that can be easily picked up on by various tools. GitHub

Relevant docs:

What's next?

Currently, GitHub Actions CI is in a semi-public beta as a part of GitHub Actions v2 – they've invited a bunch of folks who applied to use it. That said, if you feel like this is a repeat of what happened when GitHub Actions initially shipped last year, you'll be happy to know that in the GitHub Special Event in which GitHub Actions CI and GitHub Actions v2 were shared, Nat Friedman said that GitHub Actions CI and GitHub Actions v2, along with GitHub Package Registry, is shipping to everyone on November 13th – the first day of GitHub Universe.

So, in just over a month from the date of publishing this article, you'll be able to start using GitHub Actions CI on any and every public project for free. 🎉

If you've got any questions or comments about what I've talked about in this post, or if there's more you'd like to learn about GitHub Actions CI or GitHub Actions v2, I'd be more than happy to see if I can either answer your questions in the comments directly, make good free and public repos that can help give you answers, or write more posts on the subject if you'd find that helpful!