Continous Integration with Nix on GitLab

In this post I want to explain how we set up a CI infrastructure with Gitlab for our Nix-based project.

tl;dr: arianvp put up a nice definition of a gitlab-runner to execute nix-based builds efficiently. All credits go to him, I’m only writing this blog post because I haven’t seen it discussed anywhere.

Background

With a small web project (which I will write about sometime, maybe) we wanted to explore Haskell in the browser. Given that bootstrapping GHCJS and managing dependencies can be a sub-optimal experience, we naturally gravitated towards using Nix.

Although the scope of our project (part-time student and me working on it on-and-off) doesn’t really demand CI, having CI is still nice to have. And to be honest, I’m very used to it and working without CI really feels like something is missing.

The default for Nix-based continous integration seems to be Hydra. Reasons why we did not choose Hydra:

  • There seems to be only limited documentation on how to set it up
  • It’s not used outside the Nix community
  • We’re quite happy with GitLab for our other projects

That’s why I explored possibilities on how to use GitLab for our purpose.

A few words about GitLab

GitLab is a web platform to manage your git repositories that tries to integrate all parts of a modern software developement workflow. Similiary to GitHub, for example, it also includes a way to track issues and host static pages. GitLab includes proper CI functionality (unlike GitHub which requires you to use an external service like Circle CI or Travis).

A simple example how a CI pipeline setup with GitLab could look:

stages:
- build

nix-build:
  stage: build 
  image: nixos:20.03
  script: nix-build

Per repository you can create one such pipeline by adding a file named gitlab-ci.yml at the top level. Every pipeline consists of one or more stages (e.g. “build”) and each stage contains one or more jobs (e.g. “nix-build”). The execution of the pipeline is usually triggered by a push to the repository which in turn causes jobs to be added to a queue.

Other computers running instances of gitlab-runner continously poll the queue for jobs and execute those. There are different kinds of runners, the two most relevants are the so called shell runner which executes the script specified in the script: section of the yaml file on the host and the docker runner which executes the script inside a docker container.

The naive solution

Caching is an essential part of every CI pipeline, because it’s important to get feedback soon after you committed your code and ideally, you would like to check every single commit. Here’s an example of a pipeline that builds an Angular web application:

stages:
- build 

angular:
  image: node:10
  script:
  - npm install
  - npm run build
  caches:
  - paths:
    - node_modules
  artifacts:
  - dist

We’re telling GitLab to cache the folder node_modules. This works great for builds like this. For a Maven project you might export a variable like MAVEN_HOME=./maven and tell GitLab to cache the folder maven. Naturaly we came up with the idea to specify a job for nix-build like this:

build:
  image: nixos:20.03
  script: nix-build 
  caches:
  - paths:
    - /nix/store

But there are two reasons why this is a bad idea.

  • Reason 1: nix/store is way too big to be cached between builds. (GitLab runners upload the contents of the cache after every job execution to an S3 store so that it can be downloaded by another runner).
  • Reason 2: Even if that wasn’t a problem, it still doesn’t work because - as it turns out - GitLab only allows to cache files inside the checked-out git repository. Most tools allow you to configure the location for the temporary files (e.g. the node_modules or $MAVEN_HOME folder). Unfortunately, with Nix we do not have that luxury.

The solution

Arian van Putten has put together a nice GitLab runner here.

Other benefits

You can add that build server as binary cache on your own system. That means that whenever you checkout a new commit or a new branch nix-build downloads the artifacts from the CI server instead of building it locally, saving you time (and battery!).