[RFC] Switch from `stack` to `cabal-install` for building Haskell code
See original GitHub issueWhat
I propose we switch away from using stack
as our Haskell build tool to cabal-install
(aka the cabal
command-line executable).
Why
Why bother switching build tools? Generally, the reasons fall into two categories: reasons stack
is insufficient and reasons cabal-install
has gotten better.
Problems with stack
-
First and foremost, the number one reason to switch away from
stack
is the way it handles profiling. I do not know how to properly profile Haskell code usingstack
, as it forcibly compiles all dependencies with-fprof-auto
(commercialhaskell/stack#2853), which is simply untenable. To me, this is a dealbreaker: when I have needed to profilegraphql-engine
, I have needed to rebuild it withcabal-install
anyway. -
My experience with
stack
2.x has been poor. It uses a new caching system calledpantry
, which is similar in spirit tocabal-install
’s Nix-style build caching. However,pantry
’s caching model is worse—it does not properly account for different sets of build flags in dependencies (which is related to the above point on why-fprof-auto
cannot be disabled)—and it relies on a global SQLite database that provides zero options from recovering from an invalid caching state. -
Also related to the above, there is no way to build library dependencies with different optimization levels, so it is not possible to reliably link against libraries compiled with
-O2
. -
Getting access to new versions of GHC and libraries with
stack
is slow. The use of Stackage LTSes means reliable builds, but it also means you’re locked into those packages unless you want to do a lot of constraint solving yourself.stack
has also recently removed thestack solver
escape hatch, leaving it with no constraint solver capabilities whatsoever. -
Various other minor infelicities, some of which I wrote about here.
Improvements in cabal-install
cabal-install
’s support for Nix-style local builds (aka cabal new-build
) combined with support for per-project configuration (in the form of cabal.project
files) is a total game-changer. cabal-install
now supports essentially all the flexibility provided by stack.yaml
files and much more:
-
cabal.project
files support multi-project builds, as well as using packages directly from git repositories, just likestack.yaml
files do. -
cabal.project
files allow fine-grained control over the build options for every dependency in your project, and it actually caches them properly. If you modify the optimization or profiling settings for a dependency,cabal new-build
will rebuild the minimal set of things necessary, caching the build results globally. -
cabal new-freeze
allows pinning the results of build plan construction, just like package lockfiles in other ecosystems likenpm
, ensuring reliable builds without needing to rely on a Stackage snapshot. -
Improvements in infrastructure like Stackage, Hackage CI, and HEAD.hackage have made
cabal-install
build plans more consistent than ever. Error reporting for plan construction failure is still not ideal, but it is readable, and unlikestack
,cabal-install
allows manually weakening individual constraints of individual dependencies to allow fine-grained control over the constraint solver, if it really comes to that.
It is hard to overstate how pleasant I have found using cabal new-build
to build Haskell projects recently compared to stack
. One of the coolest features of cabal new-build
is that it’s entirely configuration file driven, so you can create a cabal.project.local
file that is .gitignore
d, and you can use that to control what cabal new-build
will do on your local machine: you can change optimization levels, modify profiling options, tweak settings for building documentation, and more, all without needing to muck with any command-line flags. Furthermore, the available options are fairly well-documented, and I found getting started with cabal new-build
to be pretty easy.
How
Even if you’re sold by my sales pitch for cabal-install
, perhaps you’re worried that switching away from stack
will require a ton of work and will screw up your workflow. Everyone’s workflow is a little different, so I can’t say for certain what your experience will be, but mine has been complete ease. I have already been regularly building graphql-engine
with cabal-install
on my local machine, and it’s so easy to do that it really hasn’t even involved any significant overhead despite the fact that everything in the project is set up for stack
.
Here’s what changes:
-
We swap out the
stack.yaml
file for acabal.project
file. This is thecabal.project
file I’ve been using:packages: . package * optimization: 2 package graphql-engine ghc-options: -j source-repository-package type: git location: https://github.com/hasura/pg-client-hs.git tag: de5c023ed7d2f75a77972ff52b6e5ed19d010ca2 source-repository-package type: git location: https://github.com/hasura/graphql-parser-hs.git tag: f3d9b645efd9adb143e2ad4c6b73bded1578a4e9 source-repository-package type: git location: https://github.com/hasura/ci-info-hs.git tag: ad6df731584dc89b72a6e131687d37ef01714fe8
This basically just works.
-
Instead of running
stack build
to buildgraphql-engine
, you runcabal new-build
. Instead of passing command-line options tocabal new-build
, create acabal.project.local
file that specifies any changes to how the project should be built on your machine. For example, mycabal.project.local
file usually looks like this:package * documentation: true package graphql-engine optimization: 0 documentation: false
As the options imply, this enables building docs for all my dependencies and building
graphql-engine
with-O0
. When I want to build with profiling enabled, I change mycabal.project.local
file to this:profiling: true package * documentation: true profiling-detail: none package graphql-engine profiling-detail: toplevel-functions
This automatically rebuilds everything with profiling, and
profiling-detail: none
means GHC will not automatically insert any cost centers in my dependencies, whileprofiling-detail: toplevel-functions
means it will add cost centers for all top-level functions ingraphql-engine
. It’s easy to adjust these settings per-package as necessary while doing performance debugging. -
The one major downside to using
cabal-install
is that it does not manage GHC versions automatically, so you have to install GHC yourself. Fortunately, there are two tools to do this easily and reliably:ghcup
andstack
.Yes, that’s right: I have been using
stack
to manage my installed versions of GHC even without using it to actually build anything. I just runstack --resolver=ghc-<some_ghc_version> exec -- bash
, and I get dropped into a shell withghc
in my path. If you want a lighter-weight solution,ghcup
is also available, but I’ve found this to work totally fine.
Everything else should basically still work the same way it currently does. CI scripts and contributing guides will have to change, but the changes are very small.
When
I do not want to force a different workflow on anyone, so if nobody has any drastic objections to this plan of action, I will prepare a branch with the necessary changes to swap out stack
for cabal-install
. Anyone who wants to verify their workflow is not impacted by the change should take that time to try building everything on the branch, and after some period of time, I’ll merge it in.
If anyone does have any major objections or concerns, please voice them! There are probably ways we can make things work.
Issue Analytics
- State:
- Created 4 years ago
- Reactions:3
- Comments:12 (8 by maintainers)
I’ll open an issue with cabal-install folks to see if they are interested in this. I have nothing more to add, but I think folks who are using
intero
might be affected. I’ll let them pitch in. cc @rakeshkky @hgiasac @ecthiender @nizar-m.I’ve been meaning to open a PR like this for a while, but I haven’t quite gotten around to it. Personally, I’d like to make the switch all in one go—I’d rather not have some things (e.g. CI,
CONTRIBUTING.md
,dev.sh
) usingstack
and others usingcabal-install
.Yes, my intent was to always build using the frozen dependencies.
I don’t think there needs to be too much ceremony around this if someone wants to bump a particular dependency simply because they want a newer feature or something like that. As for bumping them because we want to pull in bugfixes and things like that, I’m not sure, but to be honest I’m not super worried about that, either—the ecosystem is usually pretty stable. We haven’t been bumping our LTS very often and that seems to have been fine.
I think we should probably all build with the same GHC version, and we should probably pin
base
to a particular version to enforce that.