Speeding up CI with Nix
In the article Maintaining libraries with Claude I update the CI pipeline to release the package automatically on Hackage and GitHub when pushing a version tag.
I added a git hook, check-cabal-sync.sh, that checks the version in package.yaml against the
version in evm-opcodes.cabal, as well as the version in the git tag, to ensure I ran hpack before
releasing; otherwise Hackage upload will fail.
Total CI running time: 2m30s before
Of which haskell-actions/setup takes: 2m5s.
After making the extra hpack check: 10m30s. 🤯
The culprit? cabal install hpack installs from source.
- name: Check cabal file is in sync
run: |
cabal install hpack
.github/hooks/check-cabal-sync.sh
Visiting the Hackage index, downloading and compiling the packages are all time-consuming!
I could just install hpack from a binary distribution; maybe haskell-actions/setup has it.
But I also want to sync CI tooling with developer tooling and try to bring that 2m5s down.
Deterministic builds
I have a confession.
For 3 years I have consistently used import <nixpkgs> {} in my projects.
What that means is that when my project tooling activates, it looks for the version of nixpkgs in my NIX_PATH. It’s like the UNIX system PATH, but for Nix expressions that resolve to Nix derivations.
That means my builds are not ultimately deterministic:
I might come back to a project after a year and with a completely new, incompatible nixpkgs.
The reason I might only put a default.nix or a shell.nix in a project is speed: Flakes are a little bit slower, and I really care about latency when entering and leaving my directories.
But facing CI, I don’t assume to have an already installed nixpkgs that I conveniently maintain.
Pinning default.nix
The deterministic alternative is to pin your nixpkgs to a specific version.
A quick selection of options here:
- Hardcode pinning into your .nix files, e.g. via
fetchTarballorfetchFromGitHub - Using a dependency management system like niv or npins
- Using flakes because they come with a flake.lock that pins versions
For dependency management, niv or npins are just as good as flakes.
But I’ll go with flakes here for secondary reasons.
Letting go of pre-commit
It was at this moment I had to let go of pre-commit:
- I am curiously using nixpkgs-25.11 on this nix-darwin installation because that’s how it shipped
- For that reason,
import <nixpkgs> {}in the default.nix for this project used a stable nixpkgs - Stable nixpkgs have their binary cache fully matured
- Switching to flakes made pre-commit recompile from source, dragging the uncompiled .NET runtime and the uncompiled Swift runtime into the build pipeline, and failed. Since this is a dev dependency, it reminded me: Why do I need two full toolchains just to copy a file into .git/hooks?
The key here is: import <nixpkgs> {} is replaced with a lambda: { pkgs, ... }: ...
This makes default.nix go from a Haskell package-producing derivation to a function that takes a
pkgs and produces a Haskell package-producing derivation. It then only works in a context where
that pkgs is specified, which happens to be the flake.
direnv takes care of loading the flake instead.
And the flake.lock (omitted below) contains the pin.
commit 028d6d46819a8c5dafc6f575d5f1c56fceadc0ae
Author: Simon Shine <simon@simonshine.dk>
Date: Mon Jan 26 22:44:36 2026 +0100
fix: Replace implicit NIX_PATH pinning with flake.lock
diff --git a/.envrc b/.envrc
index 1d953f4..3550a30 100644
--- a/.envrc
+++ b/.envrc
@@ -1 +1 @@
-use nix
+use flake
diff --git a/default.nix b/default.nix
index 8bb541e..8e4840f 100644
--- a/default.nix
+++ b/default.nix
@@ -1,6 +1,4 @@
-let
- # Use nixpkgs available on the system
- pkgs = import <nixpkgs> {};
+{ pkgs, ... }: let
haskellDependencies = with pkgs.haskellPackages; [
stack
cabal-install
@@ -16,15 +14,5 @@ let
root = ./.;
modifier = drv: addBuildTools drv haskellDependencies;
};
-
- # Extend the build environment with pre-commit installed and activated
- withPreCommitHook = old: {
- nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.pre-commit ];
- shellHook = (old.shellHook or "") + ''
- if [ -d .git ]; then
- pre-commit install --hook-type pre-push
- fi
- '';
- };
in
- package.overrideAttrs withPreCommitHook
+ package
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..ab5717d
...
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..6d1ab26
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,15 @@
+{
+ description = "Opcode types for Ethereum Virtual Machine (EVM)";
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
+ flake-parts.url = "github:hercules-ci/flake-parts";
+ };
+
+ outputs = inputs:
+ inputs.flake-parts.lib.mkFlake { inherit inputs; } {
+ systems = [ "x86_64-linux" "aarch64-darwin" ];
+ perSystem = { config, self', pkgs, ... }: {
+ packages.default = import ./default.nix { inherit pkgs; };
+ };
+ };
+}
GitHub Actions and Nix CI
Installing Nix and setting up its cache comes in two steps that can be copy-pasted from the examples: nix-community/cache-nix-action and nixbuild/nix-quick-install-action. Flakes are enabled by default, so I don’t need to explicitly add them to the nix.conf.
Besides installing Nix and setting up its cache, everything is almost the same:
I prefix commands that need access to tooling with nix develop -c.
I simplify the jobs/steps to conditional states.
I got rid of the build matrix for simplicity; testing multiple compilers felt wasteful.
name: Haskell CI
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
env:
CONFIG: "--enable-tests --enable-benchmarks"
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1, 2025-12-02
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34, 2025-09-24
with:
nix_conf: |
keep-env-derivations = true
keep-outputs = true
- name: Restore and save Nix store
uses: nix-community/cache-nix-action@106bba72ed8e29c8357661199511ef07790175e9 # v7, 2026-01-08
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
gc-max-store-size-linux: 1G
purge: true
purge-prefixes: nix-${{ runner.os }}-
purge-created: 0
purge-last-accessed: P1DT12H
purge-primary-key: never
- name: Build Nix dev shell
run: nix develop -c true
- name: Check cabal file version is in sync
run: nix develop -c .github/hooks/check-cabal-sync.sh
- name: Build source code
run: nix develop -c cabal build $CONFIG
- name: Test
run: nix develop -c cabal test $CONFIG --test-show-details=always
- name: Run HLint
run: nix develop -c hlint src/
- name: Run benchmarks
run: nix develop -c cabal bench $CONFIG
- name: Generate Haddock
run: nix develop -c cabal haddock $CONFIG
- name: Generate sdist for package release
run: |
rm -rf dist-newstyle/sdist/
nix develop -c cabal sdist
- name: Upload to Hackage
if: startsWith(github.ref, 'refs/tags/v')
run: nix develop -c cabal upload --publish --token "${{ secrets.HACKAGE_TOKEN }}" dist-newstyle/sdist/evm-opcodes-*.tar.gz
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0, 2025-12-01
with:
files: dist-newstyle/sdist/evm-opcodes-*.tar.gz
generate_release_notes: true
How does it perform?
A non-release push: 1m31s
A release push: 1m37s
🥳
I beat haskell-actions/setup and I sync’ed my developer tooling with CI.