At my previous Haskell role, we had a workflow where our CI/Production builds were using Nix (using cabal2nix), but the issue is that Nix rebuilds things on the package level, so if one file in a package changes it must rebuild the entire package. This is too slow to be practical for building changes during development.
So for development, I had the following setup:
- A
cabal.projectfile with quite strict warning and error options (like-Wall -Werror) - A second
.projectfile, saycabal-dev.project, whichimport:edcabal.project, but with a looser set of warnings/errors, in particular, turning off warnings/errors for stylistic things, like unused imports, and also turned off optimisation (i.e.-O0) to speed up builds. - A third
.projectfile, which was calledcabal-hls.project, which imported fromcabal-dev.project, but had an even looser set of warnings, and did things like turn on-fdefer-typed-holesand even-fdefer-type-errors. I found the issue with the HLS language server with a standard set of build options that minor issues in some modules (like an unused import) would actually break downstream HLS processing on files that imported a file with an issue, so keeping the compile warnings very loose for HLS would allow edited interactivity to continue even if some files had minor compile issues.
So, this set up worked okay, but there’s a few issues:
cabalcaches the last build, but if any of the settings change, that cache is unused and overridden. I actually solved that by setting up scripts to build these various versions ofcabal.projectin different directories, but…- If I change my branch/commit, it would reuse any cached build results for unchanged files, but rebuild anything changed. But then if I change back to the previous branch, it’s build results would then need to be rebuilt even though they were built minutes ago. I could perhaps improve this by caching the build results in different directories based on commit id also, but that would mean whenever I created a new commit I’d need to rebuild everything from scratch which is probably worse.
cabal2nixdoesn’t look atcabal.projectfiles but instead just looks at individual.cabalfiles. So one has to replicate your project based settings in Nix and keep these in sync with thecabal.projectsettings. Also whilst we tried to ensure the same package versions were being used by pinningindex-state:incabal.projectto a similar time/date as ourflake.lock, there could sometimes still be slight mismatches.
I understand haskell.nix fixes problem 3, by building the Nix derivations directly from the cabal build plan, but that still leaves the issues with having reasonably fast builds of small incremental changes.
I quite like how Nix solves issues 1 and 2. There’s no need for scripts to put builds in separate directories/hacks based on git commit id, it just reuses what previous results are reusable, no matter how many times you’ve change build options/git branches. The only problem I see that it does this at the granularity of entire packages, not per file.
It really looks like ghc-nix is the solution I’m looking for. It just solves everything, fast incremental builds, no difference between development and CI build processes etc. But it hasn’t been updated in over three years now, and comes with a big warning “this project is still in very early days, so it’s not too easy to use… yet.”.
The other alternative I’ve found is Snack which seems to be a similar offering, but working as a cabal/stack replacement instead of a GHC wrapper. But Snack has been unmaintained for six years.
I was just wondering if there has been any recent work in this space? I quite like the benefits that Nix gives particularly regarding reproducibility and transparent caching, but it’s just too slow in a development workflow when the smallest build unit is at the package, not module level. Or should I just dive into ghc-nix even though it hasn’t had any updates for a few years? Is anyone using it for current projects on a modern version of GHC?