makeci
Like, "I make-a the CI" (not really)
I mentioned in the second koine devlog post the other day that I had:
journcy.net: "koine devlog part 2"an idea ... for a generic Nix-based task runner that I can combine with specially-crafted Makefiles to have a CI environment that runs identically (...or close to it...) in the local development environment and on the CI host.
I then went on to describe how I couldn't make it yet because there were all these prerequisites and I'd probably have to write it not as a shell script and blah blah blah you see where this is going
journcy: "makeci"Why have I done this
As *also* mentioned in the second koine devlog, I have a special POSIX shell script-type not-invented-here syndrome. I self-host much of the software I use every day, and my Git repositories are no exception, but a year ago I started to finally long for some kind of push-based automation. I could've upgraded to GitLab, or finally started hosting my own Tangled knot and spindle like I've been thinking of doing, but I prefer things I don't have to maintain so much to things I do, and I dislike running a giant piece of software to use five percent of it.
(Tangled wouldn't really work for me anyway, because most of my repositories are random personal stuff I wouldn't want public. And with all love to the Tangled team, I fear I don't have the energy to keep up with alpha software.)
So, having had the idea to tie Git's post-receive hook into ntfy to create an event system, at some point I realized I could just make it run make in a container and call it a day.
What have I done
The koine builder tool for my website was most of the way there, it just needed some extra thought and a lot of spit and polish. Fortunately I ended up with the time to put into it over the weekend, so I did the spitting and the polishing. The major hurdles were:
- Establishing SSH access to my server from inside a container without using host networking
- Running Nix inside the container without being boring and writing a Dockerfile FROM nixos/nix
- Designing the final API for the tooling
I had concluded I needed basically a Docker image that would do some iptables magic and forward all the traffic coming into it up to the host, letting me centralize connectivity in a dedicated container rather than doing it from the makeci container. Then I came across a project that does that exact thing, yay:
qoomon: "docker-host"Next I went looking at ways to set up Nix on Alpine Linux, because I'm a big Alpine fan and image sizes over 100 MB scare me.
...I didn't find any that seemed especially attractive, and the more I thought about how that would need to work the more I felt like I should give up and go write a Dockerfile based on nixos/nix. I got as far as considering the best way to install additional packages during the image build before stumbling across the instructions for building the nixos/nix image yourself from the Nix derivation.
Nix Reference Manual: "Using Nix within Docker"It took a bit longer for me to figure out that the casually referenced "docker.nix" file could be found at the very top level of the NixOS/nix GitHub repository, and what I could do with it: add a non-root user, customize packages, customize the Nix config...
NixOS: "docker.nix"wamserma: A flake for building the Nix container imageAfter some experimentation, I had a working image build using a nicely packaged flake. But I needed a script to run in it!
Upgrading the koine builder
The bones of the builder script were basically correct, I just needed to clean up some of the my website-specific logic. This meant deciding how makeci should work in general, though, and while I had the basic idea there were some specifics I hadn't nailed down. The biggest change I made was how to handle push filtering: when you push a ref, how should the system decide whether or not to run CI?
Modern CI tooling generally provides all kinds of filtering/triggering (file path, branch path, schedule/tag-based, etc.) options for CI, which I didn't feel the need to replicate, but I did want to be able to change on a per-repo basis what branches would trigger the CI. (Mostly because I know that I will eventually want to trigger a build from somewhere other than the main branch, but also if I made it every branch I would inevitably publish something stupid to my website.) I really wanted to keep all the configuration centralized in the repository's Makefile, though, no extra files or '.makeci' folder (I really dislike these forge-specific folders for some reason).
Simultaneously, I had realized that my architecture didn't support monorepos very well: a push to any subproject could trigger builds on every project, since there was no good way to distinguish what had changed. (So much for not building every CI filtering option.) This was slightly embarrassing because I'm a big monorepo fan (even if my personal repositories are usually project-specific), and I'm always rolling my eyes at tools that ignore the possibility of having multiple projects per repository.
With a bit of thinking, I realized I could solve both problems pretty straightforwardly by introducing a second, optional Makefile target ('ref', in addition to the obvious titular 'ci' target) and providing it with the metadata about the push event. The target's exit code would then determine if the triggering ref change should have CI run on it. This also naturally introduces a nice CI security model: since we run the ref test using the default branch's Makefile, changes to the ref filtering logic have to make it onto the default branch to come into effect. (Not that this matters, since this will almost definitely never be used for a multiplayer project and there are much bigger security concerns elsewhere, but.)
Git post-receive hooks are provided with the old object ID, the new object ID, and the ref that was changed; this is what gets passed into ntfy, and it's what makeci expects to receive. Now I pass those on to the Makefile being run as well, as environment variables. The file's rules thereby have all the information they need to decide what should run and when, including by pulling down additional refs to calculate diffs if necessary.
How does it work
Okay, so I can run make in my Nix-providing Docker container, but how is this useful? Well, for writing normal Makefiles, of course!
t-ravis: "nix make"journcy: "makeci" example MakefileAll I really wanted was to not have to worry about what dependencies the runner container had installed, Nix was just the easiest way to accomplish that. You could write (and I'll likely include one in the contrib folder eventually) a Dockerfile for a makeci image that was pre-provisioned with all the dependencies you expect to use and forget about it, no Nix required.
Using Nix to install dependencies did mean having to quickly package koine into a flake, so I could 'nix shell' it somehow; fortunately someone has already done the work to make packaging Gleam programs in Nix very easy.
journcy: "koine/flake.nix"Here is the current Makefile for journcy.net:
Pretty slick, if you ask me! I still need to completely overhaul the way the website is served, so I can stop copying files into a cross-container volume mount, but I'm very pleased with how well this came together once the initial prototype was complete.
The kanji of the day is 血
- Its Kodansha Kanji Learner's Course keyword is BLOOD.
- Its readings are ち and ケツ.
This is another very simple but very evocative kanji. 皿 is PLATE, so the mnemonic for this kanji is "drop of blood on a plate," which is such a vivid mental image I'm not sure I've ever missed the flashcard for this kanji. I also just love the 皿 radical and think it's really neat, so it's hard to go wrong with this one.
Discuss this post on Bluesky