Entropic Thoughts

Flake Checks in Shell

Flake Checks in Shell

flake-checks-in-shell.jpg

tl;dr: To use a shell script as a Nix flake check, turn it into a derivation with runCommand. It must

These three steps are not strictly documented anywhere, but are all needed for a shell script to work as a good flake check.

An English linter

We might work on a project where the build server runs linters, unit tests, etc. through the nix flake check command. Let’s say we want to add a linting step that ensures we have chosen the us English spelling of some common words. On this web site, the build failure would look like:

error: builder for '/nix/store/4k4...mal-reject-uk-spelling.drv' failed with exit code 1;
       last 5 log lines:
       > Found suspected words with UK spelling.
       > the-most-mario-colour-revisited: colour
       > update-on-antarctic-sea-ice: grey
       > using-withptr-from-inline-c-in-haskell: realise
       > war-what-is-it-good-for: centre,neighbour
       > when-is-counter-strike-player-good: colour,favourite
       For full logs, run 'nix log /nix/store/4k4..mal-reject-uk-spelling.drv'.

We have a shell script that performs this linting. We can start out with

words='analyse|apologise|catalogue|centre|cheque|colour|defence|favourite|flavour|grey|honour|licence|mum|neighbour|organise|pyjamas|realise|theatre|travelling|tyre'
grep -roP '(?<=\W)('"$words"')(?=\W)' . \
    | sort \
    | uniq \
    | perl -F: -lanE '
        if ($F[0] ne $fn) {
            say ("$fn: " . join(",", @words)) if defined($fn);
            $fn = $F[0] =~ s/\..*//r;
            @words = ();
        }
        push @words, $F[1];'

and this is what we want to get into the nix flake check.

Converting to a derivation

There are some things we need to think about when we try to jam this into our flake.nix. First off, we need to convert it into a Nix derivation, since flake checks are supposed to be derivations. As a reminder, a derivation is a recipe for building something. We aren’t really interested in building anything, so we are looking to create a derivation that has meaningless output, but whose build process runs the linting script.

We could do it manually with mkDerivation as always, but easier in this case is the runCommand helper function. When given a script, that will evaluate to a derivation that runs the script during the build stage. The runCommand helper also takes a buildInputs argument, which lets us specify the dependencies we need for the script. In our case, that’s just Perl, but it could be anything.

When made into a derivation, the script can no longer rely on the literal relative path . as input to grep, so we’ll interpolate the Nix path for the source tree.

{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
  outputs = { self, nixpkgs }:
    let
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
    in
      {
        checks.x86_64-linux = {
          avoidUkSpellings =
            pkgs.runCommand "avoid-uk-spellings" {
              buildInputs = [ pkgs.perl ];
            } ''
                words='analyse|apologise|catalogue|centre|cheque|colour|defence|favourite|flavour|grey|honour|licence|mum|neighbour|organise|pyjamas|realise|theatre|travelling|tyre'
                grep -roP '(?<=\W)('"$words"')(?=\W)' ${srcPath} \
                | sort \
                | uniq \
                | perl -F: -lanE '
                    if ($F[0] ne $fn) {
                        say ("$fn: " . join(",", @words)) if defined($fn);
                        $fn = $F[0] =~ s/\..*//r;
                        @words = ();
                    }
                    push @words, $F[1];'
            '';
        };
      };
}

With this, however, the check will always fail, saying the “builder failed to produce path for output”.

Producing output from the derivation

If we recall that runCommand is masquerading as a build step, we get a better understanding of the problem. It needs to produce some sort of output. The easiest thing we can do is redirect the output of the pipeline to the Nix-provided variable $out.

pkgs.runCommand "avoid-uk-spellings" {
  buildInputs = [ pkgs.perl ];
} ''
    # --------------->8-----
    | perl -F: -lanE '
        if ($F[0] ne $fn) {
            say ("$fn: " . join(",", @words)) if defined($fn);
            $fn = $F[0] =~ s/\..*//r;
            @words = ();
        }
        push @words, $F[1];' > $out
'';

But now we have a new problem: the check passes even when it should not.

Returning the proper error code

The way build steps signal failure to Nix is by exiting with a non-zero status code. Since the $out file only contains something if there were words with a uk spelling, we can use the state of that file to determine whether to fail the build or not.

To the end of the script, we add a line

[ -z "$(cat $out)" ] || exit 1

which ensures that either the file $out is empty, or we fail the build. And now, finally, this works.

Getting better build errors

However, it could be more helpful. When the build fails, all we get from Nix is

error:
  builder for '/nix/store/dql...k4g-avoid-uk-spellings.drv'
  failed with exit code 1

rather than what we hoped to get: neat suggestions for what we need to fix. This is because now that we redirect the output of the script to $out we are no longer logging it to stdout. We can fix that by replacing the output redirection with a pipe to tee.

pkgs.runCommand "avoid-uk-spellings" {
  buildInputs = [ pkgs.perl ];
} ''
    # --------------->8-----
    | perl -F: -lanE '
        if ($F[0] ne $fn) {
            say ("$fn: " . join(",", @words)) if defined($fn);
            $fn = $F[0] =~ s/\..*//r;
            @words = ();
        }
        push @words, $F[1];' \
    | tee $out
    [ -z "$(cat $out)" ] || exit 1
'';

The tee command takes input and both prints it out and writes it to a file.

After this, the check both works and produces helpful output. We took care of the things that were needed:

  • Turn the script into a derivation that creates an unused output.
  • Exit the script with an error code on linting failure.
  • Make sure to print diagnostic messages to standard out.

We used the runCommand helper because it was convenient, but we could turn literally anything into a check with the low-level mkDerivation.

Bonus: fixing up the grep output

At this point, we might be annoyed by a grep peculiarity: if its given an absolute path, it will print the absolute path for the matches too. Our check errors might look like

error: builder for '/nix/store/4k4...mal-reject-uk-spelling.drv' failed with exit code 1;
       last 5 log lines:
       > Found suspected words with UK spelling.
       > /nix/store/p81biq2dvbmhg3har759y18di9ybgpmj-ivc2w2wwrrrksd69nqccm7fz1b5xv171-source/the-most-mario-colour-revisited: colour
       > /nix/store/p81biq2dvbmhg3har759y18di9ybgpmj-ivc2w2wwrrrksd69nqccm7fz1b5xv171-source/update-on-antarctic-sea-ice: grey
       > /nix/store/p81biq2dvbmhg3har759y18di9ybgpmj-ivc2w2wwrrrksd69nqccm7fz1b5xv171-source/using-withptr-from-inline-c-in-haskell: realise
       > /nix/store/p81biq2dvbmhg3har759y18di9ybgpmj-ivc2w2wwrrrksd69nqccm7fz1b5xv171-source/war-what-is-it-good-for: centre,neighbour
       > /nix/store/p81biq2dvbmhg3har759y18di9ybgpmj-ivc2w2wwrrrksd69nqccm7fz1b5xv171-source/when-is-counter-strike-player-good: colour,favourite
       For full logs, run 'nix log /nix/store/4k4...mal-reject-uk-spelling.drv'.

and that’s rather difficult to get an overview of. This has nothing to do with creating checks for Nix, but it might be fun to know how to deal with it anyway. We’ll use sed to strip out the first bits of the path, but we cannot use the typical / separators to the substitution command, because the path contains those characters. Fortunately, sed lets us use any separator characters, so we’ll pick one we’re fairly sure won’t be in the Nix store path for the source.

pkgs.runCommand "avoid-uk-spellings" {
  buildInputs = [ pkgs.perl ];
} ''
    # --------------->8-----
    | perl -F: -lanE '
        if ($F[0] ne $fn) {
            say ("$fn: " . join(",", @words)) if defined($fn);
            $fn = $F[0] =~ s/\..*//r;
            @words = ();
        }
        push @words, $F[1];' \
    | sed 's!${./.}!!' \
    | tee $out
    [ -z "$(cat $out)" ] || exit 1
  '';