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 nice 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:
This is short 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 light-weight 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 this approach 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:
- 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. - 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 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:
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:
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):
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:
To use that script in CI:
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 short 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:
- A Rust formatting check
- A check to make sure all the code in the repo conforms to the EditorConfig
- An audit of the Rust dependencies using cargo-deny
- A general spell check using codespell
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 inflake.nix
wherever possible. To give an example, instead of using a third-party Action likecargo-deny
, this pipeline runsnix develop --command cargo-deny check
.
As I said above, one of the benefits of the Nix-based approach is that it’s dramatically more straightforward 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):
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.
Find out more about how Determinate Systems is transforming the developer experience around Nix
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 likesetup-java
andsetup-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 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.