Image for post nix-github-actions
Oct 31, 2022 by Luc Perkins

Streamline your GitHub Actions dependencies using Nix

Let me say at the outset: I enjoy using GitHub Actions as a continuous integration system. It has a nice UI, I’m especially fond of the matrix variations feature, and it’s easy to get started—after all, my code is already “there” on GitHub. One thing that I do not love about the platform: Actions as third-party dependencies. In this post, I’ll argue that Actions are a problematic and often superfluous abstraction and that you should consider using Nix to make your pipelines dramatically more reproducible and ergonomically sound.

The problem with Actions

While there are important exceptions, Actions are typically little more than wrappers around a single executable and yet require substantial boilerplate to work properly. Take creyD/prettier_action as an example. This Action installs Prettier in your CI environment and enables you to specify the commands you want to run with it. Here’s an example configuration:

1
- name: Prettify repo code
2
uses: creyD/prettier_action@v4.2
3
with:
4
prettier_options: --write **/*.{js,jsx,ts,tsx}
5
only_changed: true

This is simple enough to set up—just a few lines of YAML—but have a look at the repo that defines this action. You’ll see an action.yml file that defines which with options are available and how the action is run. The actual logic of the action is then defined in a shell script. This is pretty substantial song-and-dance for what would be a command invocation if Prettier were already installed in the environment. To be clear, there’s nothing wrong with prettier_action per se and I don’t intend to single it out. I chose it solely because it’s representative.

So why all this boilerplate? It’s basically the cost you pay for easy installation. Need a dependency in your pipeline in just a few lines of code? Boom, you got it. No need to fuss with Homebrew or yum or apt or anything else; the creators of the Action have handled that tangled business for you (hopefully). But easy installation harbors some significant drawbacks:

  • The specific Action you want may not exist, in which case you’ll need to find a way to get the desired tool(s) into your CI environment or create an Action yourself (with all the boilerplate that involves).
  • Anxiety of choice. There may be five different Actions that do more or less the same thing, leaving you with a vetting problem you’d likely prefer to avoid.
  • Platform support. The Action may install the tool you want on Linux but not macOS, for example, so if your runner or matrix strategy includes a macos-* machine, that Action might be full-on broken for some of your jobs.

And worst of all, even if you do find an Action that’s Just Right™️, you have two remaining problems:

  1. The time it took you to ascertain that the Action meets your needs—digging through various docs and files and engaging in git push shenanigans—is a steep price to pay just to play by another platform’s rules.
  2. You can’t really run that Action locally. Tools like act do their best to make this possible, but my experiments with act have shown it to be a rough half-solution. Ideally, you’d be able to run most or all of your CI pipeline locally, but if you use third-party Actions you’re setting yourself up for frustrating discrepancies between your CI environment and what you and your team can run locally.

The Nix alternative

As promised, I’m going to present Nix as a clear alternative to using using third-party Actions in your GitHub CI pipelines. The key Nix feature I want to showcase here is Nix shell environments. In a nutshell, you can use Nix expressions to declare which dependencies you want to make available inside an isolated shell environment for your project. Here’s an example of a shell environment with Go 1.18, Prettier, Cargo, Python 3.8, and OpenSSL installed:

1
{
2
devShells.default = pkgs.mkShell {
3
buildInputs = with pkgs; [
4
go_1_18
5
nodePackages.prettier
6
cargo
7
python38
8
openssl
9
];
10
};
11
}

Nix shell environments have the virtue of being highly replicable across platforms, which means that they’re an ideal solution to the “works on my machine” problem for your CI environment. You may not always be able to easily install every tool on every system—some things may not be available on macOS via Nix, for example—and that’s something to always be on the lookout for.

When you define a shell environment using Nix (with flakes enabled), you can enter the default local environment (as in the example above) by running nix develop or a more specific environment using nix develop <flake>#<env>, for example nix develop .#node-env. In a CI environment, though, it’s usually better to run commands as if the shell environment were applied but without entering the environment (much like running bash -c <command>). You can do that using the --command option. Here’s an example:

Terminal window
nix develop --command npm run build

If this command were run against a Nix shell environment with npm installed, the npm invocation would use the specific Nix-defined version instead of globally installed npm. This is the approach I use in my example project, as you’ll see below.

Using Nix inside your GitHub Actions pipeline

So you’ve specified a Nix shell environment and maybe even used Nix to create some scripts that you intend to run both locally and in CI. Now you want to actually put that Nix logic to work in your pipelines.

First, you need to install Nix. I’ve used two Actions for this, personally, and both work seamlessly:

Here’s an example pipeline that uses cachix/install-nix-action (using the nixbuild variant is effectively the same):

1
steps:
2
- uses: actions/checkout@v3
3
- name: Install Nix
4
uses: cachix/install-nix-action@v17
5
with:
6
# Mostly to avoid GitHub rate limiting
7
extra_nix_config: |
8
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
9
# Note: this would only work if Cargo is included in the Nix shell
10
- name: Build release
11
run: nix develop --command cargo build --release

For a more specific example, let’s replace creyD/prettier_action with Nix logic. Let’s say that we want to run prettier --write **/*.{js,ts} in our pipeline (to prettify all the JavaScript and TypeScript files in our repo). There are several ways to do this in a CI step. Let’s start by using the --command flag:

This is probably the fastest way forward. You know prettier is available in your environment so you run a command “sealed” inside your Nix shell environment. But there may be cases where you want to run scripts rather than raw commands. Here’s an example of a script created with Nix using the writeScriptBin function:

1
- name: Prettify JS and TS files
2
run: nix develop --command prettier --write **/*.{js,ts}

To use that script in CI:

1
{
2
prettify = pkgs.writeScriptBin "ci-prettify" ''
3
prettier --write **/*.{js,ts}
4
'';
5
}

Whether to use the --command flag or write Nix-based scripts is up to you. In my example project (discussed in the next section) I’ll use raw commands instead of scripts for the sake of clarity.

Example project

To provide a more complete picture of the Nix-based approach I’m pushing for, I’ve created a project called nix-github-actions. It’s a comically simple Rust “TODOs” web service and I kept it super basic because the song-and-dance around the service is our focus here. Accompanying the code are several checks:

If those checks pass, two things happen:

  • The Rust code is tested
  • The Rust code is built with the --release flag applied

This is pretty standard fare for a Rust project on GitHub. But what makes this repo different is that there two identical GitHub Actions pipelines:

  • no-nix.yml does things the way most repos do nowadays, using third-party Actions exclusively. To
  • audit the Rust dependencies, for example, this pipeline uses EmbarkStudios/cargo-deny-action.
  • nix.yml uses the Nix shell defined in flake.nix wherever possible. To give an example, instead of using a third-party Action like cargo-deny, this pipeline runs nix develop --command cargo-deny check.

As I said above, one of the benefits of the Nix-based approach is that it’s dramatically easier to sync local dev environments with the CI environment because they use the same environment. If you’d like to run the CI checks from the repo on your machine (if you have Nix installed and flakes enabled):

1
- name: Prettify JS and TS files
2
run: nix develop --command ci-prettify

This ci-local script runs the entire CI suite, with the exception of installing Nix and setting up a Rust cache, and prints the result. With this Nix-built script available, you can avoid the vicious cycle you often confront with third-party Actions where you need to git push with empty or meaningless commit messages just to ensure that CI is working the way you expect. You can even set up scripts like this as Git hooks if you like, although I haven’t done that here.

Some things to note from these contrasting pipelines:

  • Excluding the ubiquitous checkout Action, the “no Nix” pipeline uses five different third-party Actions while the Nix pipeline only uses two (one to set up Nix and one to set up caching for Rust).
  • The Nix-based pipeline runs faster in this repo. I won’t make any bold generalizations about this, but when using Nix you do pay an up-front cost for building the shell environment, but that environment is then cached under /nix/store on the runner and running executables in that environment is quite “cheap” after the first run. By contrast, third-party Actions are indeed cached but they’re cached as containers, which always brings overhead with it.

Actions you should and shouldn’t replace

Before you go replacing all of your Actions with Nix logic, I’d like to set forth some considerations. These types of Actions are good candidates for replacement:

  • setup-* actions like setup-java and setup-node. Nix shell environments make these totally superfluous because you can add whichever executables you want to your shell environment, including ones that aren’t in Nixpkgs (if you create package definitions yourself).
  • Checkers, linters, and formatters. These pretty much always fall firmly in the category of “wrapper around a single executable” and are thus trivially replaceable with Nix.

Conversely, you’re usually better off not replacing any Actions that rely on lower-level APIs like the Actions toolkit. Commonly used examples include cache, upload-artifact, and download-artifact. So if you need to interact with the Actions platform itself in a granular way, Nix-based CI logic is unlikely to be an improvement over the relevant third-party Actions.

Implications

I’ve focused on GitHub Actions in this post because it’s very widely used and because I don’t always love its dependency system, but you can employ a similar Nix-based approach in just about any CI system. I strongly encourage you to at least explore the Nix approach for all of your CI use cases. After all, continuous integration is meant to automate your worries and troubles away. Every moment you spend fighting with CI is a moment that goes against the core purpose of the paradigm. With Nix you can reap all the benefits of CI while being unburdened of many common drawbacks.


Share
Avatar for Luc Perkins
Written by Luc Perkins

Luc is a technical writer, software engineer, and Nix advocate who's always on the lookout for qualitatively better ways of building software. He originally hails from the Pacific Northwest but has recently taken to living abroad.