In recent months I've been building more and more of my packages with Nix. However, most of my personal systems are still running Arch. While NixOS is making headways there and is probably the eventual destination for all my systems, I'm not interested in migrating all my personal infrastructure all at once.

Instead, what I really want to do is having my Nix tooling output Arch packages that I can install on those systems in this transitional period. After some experimentation, I got it working for one of my packages, this blog, and this post details the steps it took.

Building the Blog via Flake

As with all my Nix packages, I'm using Flakes for this. I wrote a guide introducing flakes if you need an introduction. I'd converted this blog over to a flake some time ago, and was deploying by having my flake output a .tar.zst and unpacking it on my remote host. This was a little more manual than I would have liked, but it was an important component that I reused in building the Arch package.

The blog is built using Zola, so the actual process of building the blog amounts to installing Zola, then running zola build. As a Nix build step then, it looks like this:

{
    packages.x86_64.default = let 
        outputHost = "https://tonyfinn.com";
    in pkgs.stdenv.mkDerivation { 
        pname = "tonyfinn.com";
        version = "0.1";
        src = ./.;

        nativeBuildInputs = [ pkgs.zola ];

        buildPhase = ''
        zola build --base-url ${outputHost} --output-dir $out
        '';
        installPhase = "echo Nothing to do";
    };
}

The outputHost is assigned to a variable so I can quickly change it for testing purposes. Here I just have Zola build directly into the output directory. Now I can run nix build and have the HTML of my site published to the result directory on any system with Nix.

This is a good first start, but as far as getting it onto another host, it's handy to have a single file. Also Nix's behaviour of setting timestamps to 1970-01-01 can break the default caching behaviour of the web server I use.

So as a second step, I pack the output into a .tar.zst and set the mtime on the tar to the last commit date of the git repo my site is hosted. Setting the mtime means that the newly installed files will look newer to the previous ones when the webserver is decided if the response matches the users cache or not. I define a second flake output for this:

{
    packages.x86_64-linux.tzst = pkgs.stdenv.mkDerivation {
        pname = "tonyfinn.com-tzstd";
        version = "0.1";
        src = self.packages.x86_64-linux.default;

        nativeBuildInputs = [ pkgs.zstd ];

        buildPhase = ''
            mtime=$(date -d '@${toString self.lastModified}' '+%Y%m%d %H:%M')
            tar -cf tonyfinn.com.tar.zst --zstd "--mtime=$mtime" --mode=a+rX,u+w --dereference -C $src .
        '';

        installPhase = ''
            mkdir -p $out
            cp tonyfinn.com.tar.zst $out/
        '';
    };
}

This uses the date command to convert the self.lastModified property of flakes from a unix time stamp to a format that tar will take in its mtime property. Once the tar file is created, it's populated in the result directory.

Building an Arch Package

Now that I have tarball with the desired contents of my Arch package, it's time to package it up. The arch package format is a little awkward using binary files like .MTREE, so rather than try to synthesize the files or build a custom tool for this, I just install the Arch packaging tools with Nix and have them build a package from the Nix output. This means I need a PKGBUILD:

pkgname=tonyfinn.com
pkgver=1
pkgrel=1
arch=('any')
source=('tonyfinn.com.tar.zst')
md5sums=('SKIP')

package() {
    mkdir -p ${pkgdir}/usr/share/webapps/tonyfinn.com
    tar -xf tonyfinn.com.tar.zst -C ${pkgdir}/usr/share/webapps/tonyfinn.com
}

This is a minimal package build which creates the directory to host the web assets at the Arch recommended location and unpacks the tar into it. Now to use this from Nix, I created a third and final build output in my flake:

{
    packages.x86_64-linux.arch = pkgs.stdenv.mkDerivation {
        pname = "tonyfinn.com-arch";
        version = "0.1";
        src = self.packages.x86_64-linux.tzst;
        dontUnpack = true;

        nativeBuildInputs = [ pkgs.zstd pkgs.pacman pkgs.fakeroot pkgs.libarchive ];

        buildPhase = ''
            cp $src/tonyfinn.com.tar.zst .
            cp ${./PKGBUILD} ./PKGBUILD
            
            PKGEXT=".pkg.tar.zst" makepkg --config ${pkgs.pacman}/etc/makepkg.conf
        '';

        installPhase = ''
            mkdir -p $out
            cp *.pkg.tar.zst $out/
            touch -m -d '@${toString self.lastModified}' $out/*.pkg.tar.zst
        '';
    };
}

So let's decompose this a bit:

src = self.packages.x86_64-linux.tzst;
dontUnpack = true;

Here I tell Nix to use the output of the tzst flake output as the src for this arch flake output. I also tell Nix not to unpack it - I want a single input file to makepkg so I don't need a huge list of input files in the PKGBUILD.

nativeBuildInputs = [ pkgs.zstd pkgs.pacman pkgs.fakeroot pkgs.libarchive ];

This is the list of dependencies that makepkg needs to run in an isolated environment.

{
    buildPhase = ''
        cp $src/tonyfinn.com.tar.zst .
        cp ${./PKGBUILD} ./PKGBUILD
        
        PKGEXT=".pkg.tar.zst" makepkg --config ${pkgs.pacman}/etc/makepkg.conf
    '';
}

This copies the PKGBUILD and tonyfinn.com.tar.zst files into the build directory alongside each other, so that makepkg can find the tonyfinn.com.tar.zst file. Then I run makepkg. The PKGEXT=".pkg.tar.zst" uses zstd compression for smaller packages (the default is gzip), and the --config ${pkgs.pacman}/etc/makepkg.conf argument points makepkg to its configuration file. Without this second file it will look in /etc/makepkg.conf, which would work if you're running an impure build on an Arch host system, but I also want to be able to build this package from my NixOS laptop.

{
    installPhase = ''
        mkdir -p $out
        cp *.pkg.tar.zst $out/
        touch -m -d '@${toString self.lastModified}' $out/*.pkg.tar.zst
    '';
}

Finally I copy the built nix package to my flake output. I also set the mtime to the last modified date rather than the default because it makes my life easier.

Fixing the package version

This is almost complete. The one problem left is that the hardcoded pkgver, which means this will always produce a package with version 1-1 and so pacman will not see it as an update to an already installed package. There's a few ways around this, but the way I decided to tackle this was to use the last modified date as the version number and to pass it into the makepkg build as a source file.

To do this I updated the Nix buildPhase like so:

{
    buildPhase = ''
        pkgver=$(date -d '@${toString self.lastModified}' +%Y%m%d)
        cp $src/tonyfinn.com.tar.zst .
        cp ${./PKGBUILD} ./PKGBUILD
        chmod +w ./PKGBUILD
        echo $pkgver > PKGVER
        
        PKGEXT=".pkg.tar.zst" makepkg --config ${pkgs.pacman}/etc/makepkg.conf
    '';
}

The first line computes the last modified date as a YYYYMMDD string, which is then written out to a file called PKGVER in the build directory. Then inside the PKGBUILD, I add a dynamic pkgver by adding a pkgver function which just prints out the content of that PKGVER file:

pkgver() {
   cat PKGVER 
}

Finally I update the source and md5sums arrays to include this new file in the makepkg input:

source=('tonyfinn.com.tar.zst' 'PKGVER')
md5sums=('SKIP' 'SKIP')

(Note for those unfamiliar with arch packages, this does not replace the hardcoded pkgver, you need both! Also using this dynamic pkgver requires the PKGBUILD to be writable, which is why I did the chmod +x ./PKGBUILD in my buildPhase).

Now it'll create a package file like tonyfinn.com-20230123-1.pkg.tar.zst with version 20230123 which can be installed on an arch system with pacman -U <path to package file> and uninstalled and otherwise integrated.