Fast Haskell Scripts on GitHub Actions
Magix is a neat tool that lets us run Haskell programs as scripts 1 . We put a shebang on top mentioning Magix, list the Haskell packages we need, and just works. This post is about running such a script fast(er) on GitHub Actions . This post was originally published on abhinavsarkar.net . As our example, we’ll take the static site generator (SSG) I wrote some time ago: BlogShake . It is written as a single Haskell file. It uses Shake to build the website, Pandoc to render posts, and Mustache for templates. The script starts with these Magix directives 2 : Running the script is as simple as: Magix compiles the script into an executable and runs it. Nothing else to install, no , or required. Nix and Cabal can also run scripts by providing shebang directives 3 , so why reach for Magix instead? The shebang reinterprets the script with on every run, which is slow. Cabal compiles the script, but it fetches dependencies from Hackage and builds them from source, leading to very slow first build and rebuilds. Magix compiles the script once into a binary executable and caches it for the next runs. It also fetches dependencies from the prebuilt Nix cache. So running via Magix is faster than either case. But when running on GitHub Actions, we have a problem. GitHub Actions gives us a fresh runner for every build, with no Nix store and no Magix cache. So every run, we have to install Nix and Magix, download all the dependencies, and compile the script. In one instance, the build from scratch took 105 seconds, with installation, dependency download, and compilation taking 92 seconds. That is a lot of wasted work because the script and its dependencies change rarely. The actual run itself takes only a few seconds once the executable exists. What if we could persist the compiled executable across runs? Magix’s build is deterministic: the same script and the same nixpkgs revision produces the same executable. If we could stash that executable somewhere durable, a cache hit could skip Nix and Magix installation, as well as the script compilation entirely, and just run the executable. Magix creates a Nix derivation for compiling the Haskell script with GHC and builds it. The resultant executable lives in Magix’s cache, as a symlink with a path like: GHC statically links all the Haskell libraries into the executable. The only dynamic dependencies are a handful of libraries—zlib, libffi, gmp etc. So if we have the executable plus those few libraries as a self-contained unit, we don’t need the hundreds of other packages in the Nix store 4 . I wrote a small script, , with two subcommands: The script works for any Magix script, not just . Let’s go through it. The script takes a command and a script file, derives the bundle name from the script file name, and resolves the directories it needs: is the script file name without its extension, and is a SHA-256 hash of the script contents; together they key the bundle directory. mirrors how Magix itself resolves its cache directory: or , overridable with the environment variable. Bundles live under by default, overridable with the env var. The function locates the script’s build result in Magix’s cache, copies the executable out of the Nix store, and gathers the libraries it links against: Here is what it does: The two flags are the interesting part. The flag makes the dynamic loader find the bundled libraries, so we don’t need or the Nix store at run time. The flag is needed because Nix rewrites each executable’s dynamic loader to point at its own glibc inside the Nix store. Since we don’t bundle glibc, we reset the interpreter to the host’s loader according to the system’s architecture 8 . We deliberately do not bundle glibc. Unlike the other libraries, glibc cannot simply be shipped alongside the executable 9 . The dynamic loader needs to be at an absolute path and needs to be matched with the glibc version. So we simply don’t bundle it and rely on the host’s glibc. One caveat here is that the host’s glibc must be at least as new as the one the executable was built against. That works because glibc is backwards-compatible 10 . The rest of the script is the subcommand and the command dispatch: computes the same SHA-256 hash of the script, checks that the bundle exists, and s the executable, passing the remaining arguments through untouched. The workflow uses Nix, Magix and the bundler script to execute the Haskell script. First, we set the runner image and the nixpkgs branch we track 11 : The next two steps compute the cache key and restore the bundle if we have one: We resolve the nixpkgs branch to its latest commit with at the start of the job, use that commit in the cache key, and pin the whole build to the same commit via , as we see below. The cache key has two parts: the hash of the script and the resolved nixpkgs commit hash. When the branch moves or the script changes, the cache misses because of the key change, and we rebuild against the new commit and/or script. We also restore the previously cached bundle to , if found. The next four steps run only on a cache miss: adds Magix’s binary cache to Nix, so that we get the prebuilt packages for Magix. The step runs with no arguments 12 : This causes Magix to compile the script, but Shake has nothing to build, so it exits immediately. The build is pinned to the same nixpkgs commit that keys the cache, via the env variable used by Magix. Finally, packages the compiled executable by running the bash script we saw earlier. The rest of the workflow runs on every run, cache hit or not: I’ve cut down the rest of the workflow to the only step that is relevant to us: that runs the bundled executable with the script’s arguments. Other steps are specific to BlogShake. All of this work, what does it buy us? Here are two numbers: With the cached bundle, the runner just downloads it and runs the executable, skipping Nix and Magix installation and script compilation altogether. This post showed how to speed up a Haskell script running on GitHub Actions by caching a compact bundle of its compiled executable. The approach works for any Haskell script that runs on Linux: compile once using Magix, bundle the binary with its library dependencies, and let the cache do the rest. One caveat though: the bundler script uses the internal details of Magix, which may break if Magix changes how it works. The full source code: If you have any questions or comments, please leave a comment below. If you liked this post, please share it. Thanks for reading! At the point of writing this post, Magix supported Bash, Haskell, and Python. ↩︎ Because the executable is compiled once and run many times, we build the script with . ↩︎ The shebang-based alternatives look like this: for Nix, and: for Cabal. ↩︎ Why not cache the entire Nix store between runs? Because the size of the full Nix store closure required to build Haskell scripts is usually in GBs. Caching that per run would defeat the purpose of caching by taking way too much time to download the cache. You may also want to reach out for the Nix bundle feature, which produces self-contained compressed executables. But these executables are still too big: 45 MB compressed/178 MB uncompressed for BlogShake. Our approach in this post results in a 9 MB bundle, compressed. Another completely different option is to build a fully statically linked executable, which I wrote about in Nix for Haskell: Static Builds . However, that requires a custom toolchain, running which on GitHub Action is too complex and/or slow. ↩︎ A result symlink can be left dangling if the store path has been garbage-collected. We skip those and pick the newest live one. ↩︎ Magix wraps the built executable with , which renames the real executable to and puts a wrapper script in its place. We copy the real executable. ↩︎ Nix store files are read-only mode, and we are about to modify the file, so we make it writable. ↩︎ We hardcode the loader paths here, but it should work on most mainstream Linux distributions with glibc. ↩︎ Well, it can be actually. That’s what Nix bundle does. It copies the glibc in the Nix store to the bundle, and points the program interpreter at the bundled loader inside a chroot . ↩︎ An executable built against glibc 2.42 runs fine on a host with glibc 2.43, but not the other way around. ↩︎ The glibc constraint dictates the runner image. Here we build against nixpkgs branch , which has glibc 2.42. Ubuntu 26.04, the GitHub runner image we use, ships with glibc 2.43. So they are compatible. ↩︎ That no-argument behavior is Shake-specific: with no actions given, Shake runs nothing, so a bare compiles the script and exits. A general script won’t do that by default. If you adapt this for a non-Shake script, give it a mode that does nothing, say a flag, so running it bare just produces the executable. This step’s only job is to get Magix to build the script, not to run it. ↩︎ Thanks for reading this post via feed. Feeds are great, and you're great for using them. ♥ This post was originally published on abhinavsarkar.net . Read more of my posts and notes . The Problem The GitHub Actions Workflow The Conclusion It picks the latest symlink for the script by modified time, skipping dangling ones 5 . It dereferences the symlink to a Nix store path, copies the compiled executable at from the Nix store path into the bundle 6 , and makes the copy writable. 7 . It copies the dynamic library dependencies of the executable by calling the function . finds the dependencies with , and copies the ones that live in into the bundle’s directory, skipping glibc. Note that it does this recursively, copying the dependencies of dependencies as well. It rewrites the executable with , setting the interpreter and the library search path for it. It also sets the library search path for libraries themselves so that transitive dependencies work as well. Build with no bundle: 1 min 45 sec. Build with cached bundle: 10 sec. BlogShake Haskell script Magix bundle script BlogShake GitHub Actions workflow At the point of writing this post, Magix supported Bash, Haskell, and Python. ↩︎ Because the executable is compiled once and run many times, we build the script with . ↩︎ The shebang-based alternatives look like this: for Nix, and: for Cabal. ↩︎ Why not cache the entire Nix store between runs? Because the size of the full Nix store closure required to build Haskell scripts is usually in GBs. Caching that per run would defeat the purpose of caching by taking way too much time to download the cache. You may also want to reach out for the Nix bundle feature, which produces self-contained compressed executables. But these executables are still too big: 45 MB compressed/178 MB uncompressed for BlogShake. Our approach in this post results in a 9 MB bundle, compressed. Another completely different option is to build a fully statically linked executable, which I wrote about in Nix for Haskell: Static Builds . However, that requires a custom toolchain, running which on GitHub Action is too complex and/or slow. ↩︎ A result symlink can be left dangling if the store path has been garbage-collected. We skip those and pick the newest live one. ↩︎ Magix wraps the built executable with , which renames the real executable to and puts a wrapper script in its place. We copy the real executable. ↩︎ Nix store files are read-only mode, and we are about to modify the file, so we make it writable. ↩︎ We hardcode the loader paths here, but it should work on most mainstream Linux distributions with glibc. ↩︎ Well, it can be actually. That’s what Nix bundle does. It copies the glibc in the Nix store to the bundle, and points the program interpreter at the bundled loader inside a chroot . ↩︎ An executable built against glibc 2.42 runs fine on a host with glibc 2.43, but not the other way around. ↩︎ The glibc constraint dictates the runner image. Here we build against nixpkgs branch , which has glibc 2.42. Ubuntu 26.04, the GitHub runner image we use, ships with glibc 2.43. So they are compatible. ↩︎ That no-argument behavior is Shake-specific: with no actions given, Shake runs nothing, so a bare compiles the script and exits. A general script won’t do that by default. If you adapt this for a non-Shake script, give it a mode that does nothing, say a flag, so running it bare just produces the executable. This step’s only job is to get Magix to build the script, not to run it. ↩︎