How to Set Up Haskell CI for an Open Source Library
Your library builds on your laptop, a stranger opens a pull request, and three days later someone files an issue saying the package no longer compiles on GHC 9.6.

Your library builds on your laptop, a stranger opens a pull request, and three days later someone files an issue saying the package no longer compiles on GHC 9.6. Without Haskell CI that runs on every push, you find out about breakage from your users, which is the slowest and most embarrassing feedback loop an open source maintainer can have.
This guide walks through a Haskell CI setup for a small or medium open source library hosted on GitHub. It covers which compilers to test, how to cache dependencies so runs finish in minutes instead of half an hour, what checks are worth adding, and which popular recommendations you can safely skip. The examples assume a cabal based project, but most of the reasoning applies to Stack users as well.
What Haskell CI should prove for a library
An application only has to build with one compiler and one set of dependencies, the ones you deploy. A library is different. Every downstream user picks their own GHC version and their own dependency versions, and your package has to resolve and compile inside all of those combinations. Good Haskell CI therefore answers four questions on every commit:
- Does the package build and pass its tests on every GHC version listed in the tested-with field of the cabal file?
- Does it still build when the solver picks the oldest versions your lower bounds allow?
- Does the Haddock documentation generate without errors?
- Does cabal check report anything that Hackage would reject on upload?
If your pipeline answers those four, it already catches the large majority of problems that reach issue trackers. Everything else is polish.
Choosing the GHC matrix
The most common mistake is testing either one compiler or every compiler since GHC 8.0. One version hides real breakage, and twelve versions turn each push into a 40 minute wait that contributors learn to ignore.
A practical rule is to support the newest three or four major GHC releases. In late 2026 that usually means something like 9.6, 9.8, 9.10 and 9.12, plus the newest release if your dependencies already support it. Write exactly those versions into tested-with, and generate the CI matrix from that field so the two never drift apart. The haskell-ci tool by Oleg Grenrus does this automatically: it reads tested-with and writes a GitHub Actions workflow with one job per compiler.
Run the matrix on Linux for every version. Add a single macOS job and a single Windows job on the newest compiler only. Platform bugs in pure Haskell libraries are rare, and when they do appear they tend to show up on any compiler version, so one job per extra platform is enough to catch path handling or line ending problems.
Caching that actually saves time
A cold build of a library with a typical dependency tree, say aeson, text, containers and a testing framework such as tasty or hspec, takes 10 to 20 minutes per compiler on a standard GitHub runner. A warm build with the cabal store restored from cache takes 2 to 4 minutes. Across a five job matrix, that difference decides whether people wait for CI or merge without it.
The reliable pattern looks like this:
- Run cabal update, then ask cabal for a dry run build so it writes the build plan to dist-newstyle/cache/plan.json without compiling anything.
- Build the cache key from the operating system, the GHC version, and a hash of plan.json.
- Restore the cabal store directory using that key, with a fallback key that drops the plan hash so a partial match still helps.
- Build only the dependencies, then save the cache immediately, before your own code compiles.
- Build and test your package.
Saving the cache right after dependencies finish matters more than it looks. If your tests fail, a cache saved at the end of the job is often skipped, and the next run starts cold again, which is exactly when you are pushing fixes quickly and need fast feedback.
Do not cache dist-newstyle itself. It holds build products for your own package, it goes stale in subtle ways, and it can make a broken commit look green because an old object file was reused.
The lower bounds job most maintainers skip
Upper bounds get attention because Hackage revisions and the PVP push maintainers to think about them. Lower bounds rot quietly. You write text at 1.2 or newer in the cabal file, start using a function added in 2.0, and nothing complains because your machine and your CI both resolve the newest text.
Add one extra job on a middle compiler that uses the prefer-oldest solver option, available since cabal 3.10. It asks the solver for the oldest versions your bounds allow. If that job fails, either raise the lower bound or stop using the newer function. Either fix is honest, and your users on older Stackage snapshots stop hitting mysterious compile errors. The guide on Haskell PVP versioning for library maintainers explains how to pick the new bound without breaking anyone who relies on it.
Advice worth ignoring
Plenty of CI templates tell you to turn on every GHC warning and fail the build on any of them. For an application with one compiler, that is fine. For a library tested across four compilers, it is a trap. Each GHC release adds new warnings, and a warning that appears only on 9.12 will block an unrelated pull request from a first time contributor who never touched that code.
A better split is to fail on warnings in one job only, on the compiler you develop with, and let the rest of the matrix report warnings without failing. Contributors still see the problems, but a new compiler release does not suddenly turn every open pull request red. The same reasoning applies to formatting. Run the formatter check as its own fast job so a spacing problem shows up as a separate red mark, not as a failed build that looks like a real bug. If your team has not settled on a tool yet, the comparison in choosing a Haskell code formatter for your team covers the trade offs.
Making CI friendly for contributors
CI is part of how a project feels to outsiders. A first pull request that sits for 30 minutes and then fails on a Windows job for reasons nobody explains is a strong signal not to come back. A few habits make a real difference:
- Give each job a readable name such as "GHC 9.10 on Linux" instead of an opaque matrix string.
- Describe the exact local commands that reproduce each job in CONTRIBUTING.md, so a contributor can check before pushing.
- Cancel in progress runs when a new commit lands on the same branch, which saves runner minutes and avoids stale red marks.
- Require only the Linux jobs to pass before merging, and treat macOS and Windows as advisory until they have proven stable for a few months.
Projects that do this well tend to share other habits too. The overview of how the Haskell community builds and maintains its packages shows how CI fits into the wider maintenance routine.
Frequently asked questions
Is haskell-ci better than writing a GitHub Actions workflow by hand?
For libraries with several supported compilers, generated workflows are usually easier to keep correct because the matrix follows tested-with automatically. Hand written workflows are easier to read and customize. Small projects with one or two compilers often prefer the hand written version.
How many GHC versions should an open source Haskell library support?
Three or four recent major versions is a sensible default for most libraries. Supporting older compilers is generous, but every extra version adds CI time and limits which language features you can use. Foundational packages with many dependents sometimes choose to support more.
Should a Haskell library test with Stack as well as cabal?
Only if many of your users build with Stack and you want to confirm the package works on a specific snapshot. A single Stack job on the current LTS snapshot is enough for that. Running the whole matrix twice rarely finds anything the cabal jobs missed.
More in Open Source
Open Source
How to Publish a Haskell Package on Hackage the Right Way
You have a library that works on your machine, a few people have asked for it, and now you want to publish a Haskell package...
Open Source
Haskell PVP Versioning Explained for Library Maintainers
You release version 1.4.2 of your library on a Friday, and by Monday three downstream packages fail to build because you...
Open Source
First Open Source Contribution to a Haskell Project
The first pull request I ever sent to a Haskell library changed eleven characters in a Haddock comment.