Multi-platform CI and Build Matrix
This guide will show you how to build your project in multiple configurations.
Prerequisites:
-
You have set up an agent for the account that owns the repository.
-
You have added a repository to your Hercules CI installation.
-
If you want to build natively on multiple
system
types, you have deployed agents on machines of those types.
Hercules CI dispatches builds to the appropriate agent, depending on its system
value in the derivations, so to create a multi-platform CI job, you typically invoke a function for multiple system
arguments and put the results in their own attributes.
Here’s a simple ci.nix
that will build on two types of system
:
{
hello-macos =
( import (import ./nixpkgs.nix) { system = "x86_64-darwin"; }
).hello;
hello-linux =
( import (import ./nixpkgs.nix) { system = "x86_64-linux"; }
).hello;
}
The evaluator traverses the expression and Hercules CI will dispatch the hello
derivations to the appropriate hercules-ci-agent
processes.
This example is perhaps a bit simplistic, so let’s switch examples and adapt it to various requirements.
A starting point
To see how we can scale this to a non-trivial project, let’s say you’ve packaged a project with multiple components. It may look like this:
# default.nix
let nixpkgs = import ./nixpkgs.nix; # file path
pkgs = import nixpkgs {};
in
rec {
backend = pkgs.callPackage ./backend.nix {};
frontend = pkgs.callPackage ./frontend.nix {};
}
This lets you build the backend with nix-build -A backend
.
After committing this file, Hercules CI will build it for x86_64-linux
, which is the default.
This happens because without a system
argument, the Nixpkgs function will produce derivations for the value of builtins.currentSystem
.
Pass system
to Nixpkgs
Now let’s make our default.nix
more useful, by allowing system
to be passed to it.
# default.nix
{ system ? builtins.currentSystem }: # This line turns the whole file into a function
let nixpkgs = import ./nixpkgs.nix;
pkgs = import nixpkgs { inherit system; }; # and here we pass system along
in
rec {
backend = pkgs.callPackage ./backend.nix {};
frontend = pkgs.callPackage ./frontend.nix {};
}
This also has the benefit that macOS developers can build for Linux with
nix-build default.nix --argstr system x86_64-linux
lib.recurseIntoAttrs
Now we’re ready to create multi-platform CI jobs.
# ci.nix (without recurseIntoAttrs)
{
linux = import ./default.nix { system = "x86_64-linux"; };
macos = import ./default.nix { system = "x86_64-darwin"; };
}
However, this will not build anything. nix-build ci.nix
will return immediately!
nix-build
and Hercules CI will ignore attribute sets, unless it’s the root or
unless has an attribute recurseForDerivations = true
, which can be set with
lib.recurseIntoAttrs
.
Let’s add the latter.
# default.nix
{ system ? builtins.currentSystem }:
let nixpkgs = import ./nixpkgs.nix;
pkgs = import nixpkgs { inherit system; };
in
# recurseIntoAttrs makes nix-build and CI use the nested attributes
pkgs.lib.recurseIntoAttrs rec {
backend = pkgs.callPackage ./backend.nix {};
frontend = pkgs.callPackage ./frontend.nix {};
}
Before Nixpkgs 20.09 you’d have to use pkgs.recurseIntoAttrs .
|
A build matrix
We can make use of Nix as a programming language to create a multitude of configurations to build and test.
Particularly useful is the functionality of mapAttrs
, although it’s easier to read with the parameters flipped.
Let’s refactor ci.nix
to use this and take care of recurseForDerivations
while we’re at it.
# ci.nix
let
dimension = _ignoredName: attrs: f:
builtins.mapAttrs f attrs // {
recurseForDerivations = true;
};
in
dimension "system" {
"x86_64-linux" = {};
"x86_64-darwin" = {};
} (system: _attrs:
import ./default.nix { inherit system; }
)
Perhaps we want our app to be compatible with two versions of ElasticSearch.
First, we add a parameter getElasticSearch
and use it in default.nix
.
# default.nix
{ system ? builtins.currentSystem,
# How to get the right version of elasticsearch out of Nixpkgs
getElasticSearch ? p: p.elasticsearch
}:
let nixpkgs = import ./nixpkgs.nix;
pkgs = import nixpkgs { inherit system; };
in
pkgs.lib.recurseIntoAttrs rec {
backend = pkgs.callPackage ./backend.nix { elasticsearch = getElasticSearch pkgs; };
frontend = pkgs.callPackage ./frontend.nix {};
}
Now we can define a new "dimension" in the build matrix.
# ci.nix
let
dimension = _ignoredName: attrs: f:
{
recurseForDerivations = true;
} // builtins.mapAttrs f attrs;
in
dimension "system" {
x86_64-linux = {};
x86_64-darwin = {};
} (system: _attrs:
dimension "elasticsearch" {
elasticsearch-6 = { getElasticSearch = p: p.elasticsearch6; };
elasticsearch-7 = { getElasticSearch = p: p.elasticsearch7; };
} (_name: { getElasticSearch }:
# In the functional argument of the deepest dimension call,
# all build matrix parameters are in scope.
import ./default.nix { inherit system getElasticSearch; }
)
)
This will generate the eight derivation attributes for all four combinations.
-
x86_64-darwin.elasticsearch-6.backend
-
x86_64-darwin.elasticsearch-6.frontend
-
x86_64-darwin.elasticsearch-7.backend
-
x86_64-darwin.elasticsearch-7.frontend
-
x86_64-linux.elasticsearch-6.backend
-
x86_64-linux.elasticsearch-6.frontend
-
x86_64-linux.elasticsearch-7.backend
-
x86_64-linux.elasticsearch-7.frontend
You can build any part of the tree locally. For example nix-build ci.nix -A x86_64-linux
.
The Nix language and Hercules CI allow arbitrary strings for attribute names, but nix-build may reject some names.
|
You can nest more dimension
calls to multiply the number of combinations.
To remove impossible or uninteresting combinations, you can add a conditional to omit some attributes or set recurseForDerivations
to a boolean expression.
If your Nixpkgs import looks like import nixpkgs {}
, you can import lib
without specifying system
using import (nixpkgs + "/lib")
. lib.optionalAttrs
may come in handy. For example:
let lib = import (nixpkgs + "/lib"); in
#...
dimension "system" {
x86_64-linux = { };
x86_64-darwin = { supportES6 = false; };
} (system: { supportES6 ? true }:
dimension "elasticsearch" ({
elasticsearch-7 = { getElasticSearch = p: p.elasticsearch7; };
} // lib.optionalAttrs supportES6 {
elasticsearch-6 = { getElasticSearch = p: p.elasticsearch6; };
}) (_name: { getElasticSearch }:
#...