I first looked at nix back in 2016 when snap forced me to use a macbook. At the time, I was just looking for a package manager, and it was a hard sell, much harder than the linux laptop that followed a few months later. As such, it was five years before I picked it back up, and I regret not giving it the time back then.
I guess this is also a goodbye letter to archlinux, as I have moved my personal systems over to nixos earlier this year, but I mostly wanted to show off how I moved this blog over. Which seems to be the only thing I can remember to actually publish here.
Most of the leg work for nixos was already done, and props to the sourcehut community therein. As well, I will push you to Xe for a wonderful flakes intro, as doing that here is more than out of scope.
My effort was largely about transferring logic currently in .build.yml
into flake.nix
. The first thing of note is that I use flake-utils to flatten all of the entries in the outputs
function’s attrset. This does not change the meaning of anything, just means that if you see devShells.foo
or packages.bar
, know that this maps onto devShells.${system}.foo
or packages.x86_64-linux.bar
.
This seemed like a great time to add linting and formatting, since that is new and easier than build or deploy. Formatting is invoked by the nix fmt
command and one must add something like this to the formatter
entry of the attrset returned by the outputs
function:
Note that
pkgs = nixpkgs.legacyPackages.x86_64-linux;
.
{
formatter = pkgs.alejandra;
}
I generally prefer to use nix fmt
to fixup the whole repo, thus we need a script that invokes several binaries, and pkgs.writeShellApplication
works well for this:
{
formatter = pkgs.writeShellApplication {
name = "fmt";
runtimeInputs = with pkgs; [
alejandra
statix
nodePackages.prettier
];
text = ''
alejandra --quiet .
statix fix
prettier --loglevel error --write .
'';
};
}
You will also note that because we are using runtimeInputs
and can call these tools directly as opposed to needing to inject their absolute paths: ${pkgs.statix}/bin/statix
. While I do find this ergonomic, it is mostly done so that we have a single list of dependencies that we can reuse later.
Linting will then be our first foray into derivations. Since formatting is mutable, it can just be a script, but checks must be derivations because they are building and validating things. Like formatter
they live in the outputs
function under checks
, however as the name implies you can have multiple checks per flake, unlike formatter
for which there is only one.
{
checks.default = pkgs.runCommandLocal "check" {
buildInputs = [pkgs.alejandra];
} ''
alejandra --check ${self} && touch $out
'';
}
Here we are using pkgs.runCommandLocal
to execute a command as part of building a derivation. Now, checks are funny, since they must be derivations, but they also do not really produce a meaningful artefact, just the lack of an error. As such, we run our linter in check mode and if it succeeds we create an empty artefact. This is not terribly ergonomic, especially when we need three linters, so let us first break out our runtimeInputs
from before in our outputs
function:
let
doCheck = name: text: pkgs.runCommandLocal name {
nativeBuildInputs = checkInputs;
} ''
cd ${self}
${name} ${text} .
touch $out
'';
checkInputs = [
alejandra
statix
nodePackages.prettier
];
in {
checks = builtins.mapAttrs doCheck {
alejandra = "--check";
statix = "check";
prettier = "--check";
};
}
With that out of the way, and your interests piqued, let us move onto the main afair: packaging. As the name implies, we will be adding to the packages
entry in our attrset. I opted to keep the nix code as simple as possible, both so that we can reproduce failures without nix, and because doing everything in nix gets messy fast. As such, we have added a Makefile
that contains all
and install
targets.
DEST ?= "site.tar.gz"
all: public
install: public
tar --create --directory public --file $(DEST) --gzip --verbose .
public:
hugo
These replace the build
and package
tasks from .build.yml
, and contain sane defaults that use the current directory as input and output, reusing the same filenames as before. This lead to a simple as derivation:
{
packages.default = pkgs.stdenv.mkDerivation {
name = "site.tar.gz";
src = self;
dontConfigure = true;
nativeBuildInputs = with pkgs; [
gnutar
hugo
];
makeFlags = ["DEST=$(out)"];
};
}
Unfortunately, some of the limitations of flakes started to rear their heads. In our case, we have been using hugo’s enableGitInfo
feature. This gives all pages a cute link to git source. This feature requires we shellout to git for information. In the before times, this was automatic, since .git
just happened to be there, and what self respecting ci tooling does not ship with the git binary accessable?
Nix requires everything be explicit, so this did not “just work”. The easiest option would just be to disable this feature, but I opted to mock git’s responses so as to preserve the link. It is probably quite flaky, but as long as our dependencies are pinned, it is guaranteed not to break.
Finally, we need to deploy out this artefact, .build.yml
’s deploy
task for those following along at home. I opted to add an entry to apps
and trigger it with nix run .#deploy
:
{
apps.deploy = let
name = "deploy";
in {
type = "app";
program = "${pkgs.writeShellApplication {
inherit name runtimeInputs;
text = ''
hut pages publish ${self.packages.${sys}.default} \
--domain www.urandom.co.uk \
--not-found /404.html
'';
}}/bin/${name}";
};
}
These entries are derivations with binaries, similar to formatter
, and because we need to do custom things, I once again lean on pkgs.writeShellApplication
. You will also notice we back reference self.packages
, this is a way to reference another element of the outputs
function’s attrset, and in this case we want packages.default
as defined above. Unfortunately, flake-utils cannot save us here, thus we must use the fully qualified self.packages.${sys}.default
reference, where sys
is being populated by flake-utils.
And with that we have a running website. Unfortunately, developing against flakes like this is a non-starter, unless you always write code without bugs. As such, we have one final step: devShells
. These are the way flakes expose a shell environment setup with all your tools: nix develop
. They are a killer feature of nix, and I would have lead with them, but I wanted you to see all the various places we added tooling so you could now understand why I prefered to share and inject nativeBuildInputs
and runtimeInputs
.
let
checkInputs = with pkgs; [
alejandra
statix
nodePackages.prettier
];
nativeBuildInputs = with pkgs; [
gnutar
hugo
];
runtimeInputs = [pkgs.hut];
in {
devShells.default = pkgs.mkShell {
packages = checkInputs ++ nativeBuildInputs ++ runtimeInputs;
};
};
Yep, it is that easy. I did leave off the other consumers for these three values, but this way if we add a new linter, we can guarantee that if it works in nix flake check
it is also included in nix develop
. Sorry for how long this has been, but flakes can be tricky, and I appreciate you making it to the end. As a reward, here is the full flake, warts and all:
{
description = "personal website";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
flake-utils,
nixpkgs,
self,
}:
flake-utils.lib.eachSystem [flake-utils.lib.system.x86_64-linux] (sys: let
checkInputs = with pkgs; [
alejandra
statix
nodePackages.prettier
];
doCheck = name: text:
pkgs.runCommandLocal name {
nativeBuildInputs = checkInputs;
} "cd ${self} && ${name} ${text} . && touch $out";
git = pkgs.writeShellScriptBin "git" ''
case $1 in
-C)
printf '\n'
;;
-c)
printf '\x1e${self.rev or ".."}'
printf '\x1f${self.shortRev or "dirty"}'
printf '\x1f\x1fColin Arnott'
printf '\x1fcolin@urandom.co.uk'
printf '\x1f1970-01-01 00:00:00 +0000'
printf '\x1f1970-01-01 00:00:00 +0000'
printf '\n'
find ${self} -type f -printf '%P\n'
;;
*)
exit 1
esac
'';
hugo = pkgs.symlinkJoin {
name = with pkgs.hugo; "${pname}-${version}";
paths = [pkgs.hugo];
nativeBuildInputs = [pkgs.makeWrapper];
postBuild = ''
wrapProgram $out/bin/hugo --prefix PATH : ${
pkgs.lib.makeBinPath [git]
}
'';
};
nativeBuildInputs = with pkgs; [
gnutar
hugo
];
pkgs = nixpkgs.legacyPackages.${sys};
runtimeInputs = [pkgs.hut];
in {
apps.deploy = let
name = "deploy";
in {
type = "app";
program = "${pkgs.writeShellApplication {
inherit name runtimeInputs;
text = ''
hut pages publish ${self.packages.${sys}.default} \
--domain www.urandom.co.uk \
--not-found /404.html
'';
}}/bin/${name}";
};
checks = builtins.mapAttrs doCheck {
alejandra = "--check";
statix = "check";
prettier = "--check";
};
devShells.default = pkgs.mkShell {
packages = checkInputs ++ nativeBuildInputs ++ runtimeInputs;
};
formatter = pkgs.writeShellApplication {
name = "fmt";
runtimeInputs = checkInputs;
text = ''
alejandra --quiet .
statix fix
prettier --loglevel error --write .
'';
};
packages.default = pkgs.stdenv.mkDerivation {
name = "site.tar.gz";
src = self;
dontConfigure = true;
inherit nativeBuildInputs;
makeFlags = ["DEST=$(out)"];
};
});
}