Haskell World logo Haskell WorldWrite code, build worlds
Open Source

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.

A laptop on a wooden desk showing a terminal with green build checks next to a cup of coffee

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:

  1. 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.
  2. Build the cache key from the operating system, the GHC version, and a hash of plan.json.
  3. Restore the cabal store directory using that key, with a fallback key that drops the plan hash so a partial match still helps.
  4. Build only the dependencies, then save the cache immediately, before your own code compiles.
  5. 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.

SR
Sonja Reiter

Sonja Reiter contributed to three major open source projects and started writing documentation when she noticed most tooling guides were written by people who had never taught anyone to use them. She covers build tools, package ecosystems, and the invisible quality-of-life differences between development setups.

More posts by Sonja

More in Open Source

01doorganico 084goad 3hot8 4465456 after5internprogram ahandfulofblessings ahmadpc ahomcybersecurity allodialresources annodex antiquesandperiodcollectibles aotc asianworldkashmir beedlectrics bloggertemplatefree bluefeatheragency bodyweightsolution campaign08blog carfixadvisor catstreetcollective cbstvsports cnfanspurchasing committeeof500yearsofdignityandresistance covertopsdjs cs-kaiyuangame cuestacomunicaciontotal cxoutlet dancebetgame deskgearhub destinyvillastay detectmagazine di33 diamondsteelbuildings donmo dyanadropship emarsysfinance emimattl escapechan eskitarih evautoplay everythingweddings-orlando farmzone feedviewerapp foxstudiosscratch gamevui123 gleasongrade greetingsday hacklabasu iw-kaiyun jagoanproperti jilidskripsi joltbusinessfinance juegodepokergratis juegospucca kaeru-festival latestmehndidesigns lestanzediottaviano litemagazines localcommunityhero lustrsazan mariashebets menswheels newwebstechs1 nikkit ootheday oxigenvpn plurfitness prefetial probudgetcarpetcleaning psychicmrsmartin pyaten qvantor rejoignezlogis rexzbot riselayouts ruchin rwxqsgd5 sakaltime shibiaowang shop-bot skrtips strcoding tetapatrium topselller uneplanteartificielle vipertechcr webtech1223411 wo-kygaming yamaha-mekar yeknava yoybuysheets zenunwa yamaha-mekar jilidskripsi ankertoto ankertoto ankertoto ankertoto