Tested with Nyxt 2 Pre-release 4

Continuous testing and packaging in Common Lisp

Continuous testing and packaging in Common Lisp

By Pierre Neidhardt

In this article we are going to talk continuous testing and packaging in Common Lisp. The goal is to automate:

Since Nyxt is currently hosted on GitHub, we are leveraging GitHub Actions for our continuous integration. Even though we are using GitHub Actions, we've designed our process to be platform independent. As a result, our GitHub-specific code remains small and trivial.

Testing

Some Common Lisp compilers are particularly good at code analysis:

SBCL and CCL are two high-quality compilers that fit the bill. While only SBCL is officially supported to build Nyxt, CCL proves useful in catching some error and warning classes that SBCL misses. In particular, CCL is better at type-checking class slots.

Let's review what we want to automate to increase our quality assurance:

In practice, the last two points suffice since the code checking is done by building the project.

Allow me to emphasize the importance of code checking: since the compilers don't report false positives, this forces us to have warning-free code at all time, which is a big boost for quality assurance!

Automatic build

First we need to set up the GitHub action in this YAML file.

In short:

Automatic testing

Finally, we run the following:

We load the tests which builds Nyxt itself. If the Nyxt build fails, the error is reported as part of the workflow.

We exit with a custom non-zero error code, here 17. This can help identify the cause of the error in case the output gets confusing.

The rest of the test controls happens in the nyxt.asd file. Our main :nyxt system lists all the tests so that call (asdf:test-system :nyxt) effectively runs the whole test suite.

Each individual test system is specified in the following form:

We've rolled out our own helper function nyxt-run-test to factor some recurring code.

We've added some knobs that we can control externally:

Automatic code checking

Finally, our last step in our YAML file is very simple: it loads a file and executes the compilation-conditions function on systems we want to test, here nyxt and the renderers.

Let's look at this file more closely:

Packaging

Since none of us at Atlas are using a Debian-based distribution such as Ubuntu, and since these distributions are among the most popular platforms, it has become increasingly necessary to automate the process of distributing a pre-built .deb package

Automating packaging is no simple task, for this we leverage the linux-packaging Common Lisp library, which itself is based on fpm.

linux-packaging is a real life saver: it abstracts the tasks of packaging for various package managers (as November 2020, .deb, .rpm and pacman's formats are supported) in a consistent Common Lisp interface that sits on top of ASDF.

Beside wrapping fpm, linux-packaging automatically guesses the operating system dependencies for the FFI libraries and statically links the FFI-generated objects into the Lisp image. No more problem distributing Osicat!

As for continuous testing, we have a YAML file which has roughly the same steps except that we don't leverage Roswell here because we are going to build our own SBCL compiler: indeed, linux-packaging requires SBCL to be built with the non-default --with-sb-linkable-runtime option.

Notice the new dependency line:

Ruby is required for fpm and SBCL for… rebuilding SBCL!

Not much here since everything happens in the build-ubuntu-package.sh script:

Finally, the package build happens in the last command:

quickload will drag all linux-packaging and nyxt dependencies. Then we load and make an ASDF system that's dedicated to the creation of the package.

This system is declared in a separate file because otherwise it would make Nyxt depend on linux-packaging, which the end user does not need.

Let's review it:

Here we declare that loading this system will trigger the linux-packaging:build-op operation which will generate a linux-packaging:deb package.

Notice that there is only one knob to control the type of package we want to produce. It's enough to change this value to linux-packaging:rpm to produce an RPM!

Here we list the Common Lisp systems we want to include in our image. Since nyxt/gtk depends on everything else, it's the only system that we need to list.

The :entry-point is the same we use to build Nyxt from the Makefile.

Here we list all the metadata for the .deb. Since we decided to store this system in a separate .asd file, it can't automatically inherit from the metadata of the nyxt system. So we need to use a reader macro to explicitly tell ASDF to look for the metadata of Nyxt.

The attentive reader may have noticed that I said that linux-packaging automatically derived the operating system dependencies for FFI packages. Indeed, we don't need to declare that WebKitGTK is a dependency here for instance.

However, some of our code has optional dependencies. While WebKitGTK would work without glib-networking it would have limited functionality, like no HTTPS support.

Other dependencies include those that are typically used by Common Lisp libraries that depend on executables, like trivial-clipboard which depends on an external clipboard program like xclip.

Finally, we list all the assets to include in the .deb: icons, .desktop file, etc.

The build-pathname field is the name of the produced executable which will be automatically stored to /usr/bin in the package.

Future work

Thanks for reading!

Special thanks

Florian Margaine for his awesome work on linux-packaging.

@phoe for his Common Lisp tips.