RSS

Hakyll Pt. 6 – Pure Builds With Nix

Info

SummaryLearn how to make your hakyll project build pure and how to patch hakyll if you need to.
Shared2020-04-30
Revised2023-01-30 @ 23:00 UTC

Special thanks to Utku Demir for review and constant inspiration.

This is part 6 of a multipart series where we will look at getting a website / blog set up with hakyll and customized a fair bit.

Overview

In this post we’re going to create a new hakyll site from scratch with a caveat: we will do just about everything with nix in order to guarantee reproducibility for anyone (or anything) using our project. There are also two bonuses that we will inherit simply because we are using nix: * we will not need to rely on global package installs (apart from nix, of course) * we will be able to easily patch any package problems; for example, if some of hakyll’s dependencies are not available in nixpkgs, we can patch hakyll to get it to work.

Here is the example repository with what we’re going to make: https://github.com/rpearce/hakyll-nix-example

Note: this post assumes that you have installed nix on your system.

Steps to Build Our Hakyll Project With Nix

Make a new project with release.nix, default.nix, and shell.nix, and get into its pure nix shell environment:

λ mkdir hakyll-nix-example && cd $_
λ echo "{ }: let in { }" > release.nix
λ echo "(import ./release.nix { }).project" > default.nix
λ echo "(import ./release.nix { }).shell" > shell.nix
λ nix-shell --pure -p niv nix cacert

We won’t have to touch default.nix nor shell.nix again, for we are delegating their responsibilities to the release.nix file that we’ll add more to in a moment.

Note: we require nix and cacert when running a pure nix-shell with niv because of an issue (https://github.com/nmattia/niv/issues/222).

Now that we’re in the nix shell, initialize niv and specify your nixpkgs owner, repository, and branch to be whatever you want:

[nix-shell:~/projects/hakyll-nix-example]$ niv init
[nix-shell:~/projects/hakyll-nix-example]$ niv update nixpkgs -o NixOS -r nixpkgs-channels -b nixpkgs-unstable
[nix-shell:~/projects/hakyll-nix-example]$ exit

Update your release.nix file with the following:

let
  sources = import ./nix/sources.nix;
in
{ compiler ? "ghc883"
, pkgs ? import sources.nixpkgs { }
}:

let
  inherit (pkgs.lib.trivial) flip pipe;
  inherit (pkgs.haskell.lib) appendPatch appendConfigureFlags;

  haskellPackages = pkgs.haskell.packages.${compiler}.override {
    overrides = hpNew: hpOld: {
      hakyll =
        pipe
           hpOld.hakyll
           [ (flip appendPatch ./hakyll.patch)
             (flip appendConfigureFlags [ "-f" "watchServer" "-f" "previewServer" ])
           ];

      hakyll-nix-example = hpNew.callCabal2nix "hakyll-nix-example" ./. { };

      niv = import sources.niv { };
    };
  };

  project = haskellPackages.hakyll-nix-example;
in
{
  project = project;

  shell = haskellPackages.shellFor {
    packages = p: with p; [
      project
    ];
    buildInputs = with haskellPackages; [
      ghcid
      hlint       # or ormolu
      niv
      pkgs.cacert # needed for niv
      pkgs.nix    # needed for niv
    ];
    withHoogle = true;
  };
}

Don’t worry, we’ll circle back to what we just did.

Create a hakyll.patch diff file:

λ touch hakyll.patch

Bootstrap the hakyll project (we won’t ever need this again):

λ nix-shell --pure -p haskellPackages.hakyll --run "hakyll-init ."

Build the project and --show-trace just in case something goes wrong:

λ nix-build --show-trace

Run the local dev server:

λ ./result/bin/site watch

Navigate to http://localhost:8000 and see your local dev site up and running!

Understanding the release.nix File

Let’s break down what we copied and pasted into release.nix.

let
  sources = import ./nix/sources.nix;
in
{ compiler ? "ghc883"
, pkgs ? import sources.nixpkgs { }
}:

# ...

The let gives us the space to define an attribute (variable), and it is here that we import our sources.nix file that was generated by niv. The in block defines a function parameter with two attributes, compiler and sources, that each have defaults (when there’s a :, that means what comes next is a function body or another function argument). For the compiler, we will use this version to compile all of the Haskell packages that we interact with. For the pkgs, we default to using our pinned version of nixpkgs, but this is overridable.

# ...

let
  inherit (pkgs.lib.trivial) flip pipe;
  inherit (pkgs.haskell.lib) appendPatch appendConfigureFlags;

  # ...

Our new let falls within the function we created above, and we then state that we would like to inherit some nice functions from pkgs.lib.trivial and pkgs.haskell.lib. The flip and pipe functions are standards in functional programming, but I’ll share a short recap: * flip takes a function a -> b -> c and flips the accepted arguments to act like b -> a -> c. Its definition is flip = f: a: b: f b a; – it takes a function, then a, then b, and then it applies a and b in reversed (flipped) order. * pipe establishes a set of functions that you can apply data to, one after the other. Think of bash pipes: cat blog_post.txt | grep nix.

let
  # ...

  haskellPackages = pkgs.haskell.packages.${compiler}.override {
    overrides = hpNew: hpOld: {
      hakyll =
        pipe
           hpOld.hakyll
           [ (flip appendPatch ./hakyll.patch)
             (flip appendConfigureFlags [ "-f" "watchServer" "-f" "previewServer" ])
           ];

      hakyll-nix-example = hpNew.callCabal2nix "hakyll-nix-example" ./. { };

      niv = import sources.niv { };
    };
  };

  # ...

This uses our pinned (or overridden) nixpkgs to create our own haskellPackages for a specific Haskell compiler version.

For hakyll, we need to make sure it gets compiled with the watchServer and previewServer flags, or we won’t be able to use its local dev server. We also provide an optional patch file (git diff > hakyll.patch file) that we can build hakyll with if there are any changes to the project that we need to make. Patch files can be empty when no patches are required, but if you do need to patch something, here is an example hakyll.patch file:

diff --git a/hakyll.cabal b/hakyll.cabal
index fcded8d..9746f20 100644
--- a/hakyll.cabal
+++ b/hakyll.cabal
@@ -199,7 +199,7 @@ Library
   If flag(previewServer)
     Build-depends:
       wai             >= 3.2   && < 3.3,
-      warp            >= 3.2   && < 3.3,
+      warp,
       wai-app-static  >= 3.1   && < 3.2,
       http-types      >= 0.9   && < 0.13,
       fsnotify        >= 0.2   && < 0.4

The hakyll-nix-example attribute is specifically for our Haskell project in order for us to be sure our project is compiled with our desired compiler version. We leverage the callCabal2nix tool to handle automatically converting our hakyll-nix-example.cabal file into a nix derivation for our build.

Lastly, we ensure that the niv we are using in the nix-shell is our pinned niv that niv itself generated.

let
  # ...

  project = haskellPackages.hakyll-nix-example;
in
{
  project = project;

  # ...

The project attribute is what our default.nix will use when being called with tools like nix-build. All we do is access our hakyll-nix-example attribute from our customized haskellPackages.

let
  # ...
in {
  # ...

  shell = haskellPackages.shellFor {
    packages = p: with p; [
      project
    ];
    buildInputs = with haskellPackages; [
      ghcid
      hlint       # or ormolu
      niv
      pkgs.cacert # needed for niv
      pkgs.nix    # needed for niv
    ];
    withHoogle = true;
  };
}

Exactly like default.nix uses the project attribute, shell.nix is looking for a shell attribute to define everything it needs when running nix-shell --pure. We use shellFor, which comes with the nixpkgs Haskell tools, and we provide it a few attributes: * the packages attribute holds our project package and any other nixpkgs that you would like to have built when entering the shell * the buildInputs attribute holds all the tools that we’ll have available to us while we’re in the shell; for example, you can run ghcid and load your Haskell code to test it out or run hlint to lint your Haskell files * withHoogle gives us the ability to query https://hoogle.haskell.org

Wrapping Up

Using nix to build our project helps make development consistent and predictable; however, learning nix is not necessarily a breeze. The following articles directly contributed to my understanding that led to this post:


Thank you for reading!
Robert