…at least more often than not, if their goal is to say that “my code works with the versions in that bounds”.
How things can go wrong
-
You start a new project
wombat
, and add all the currently used versions of its dependencies (here:foo-1.2.3
) to thebuild-depends
line of thewombat.cabal
file, maybe like this:build-depends: foo >= 1.2.3 && < 1.3,
You set up a nice CI system, maybe testing various versions of GHC.
All is well.
-
To get extra cookie points you maybe subscribe to a packdeps RSS feed for our package and get notified when
foo-1.3
gets released. So you change the dependency tobuild-depends: foo >= 1.2.3 && < 1.4,
test that it still works, and release a new version of
wombat
.Still all is well.
-
A few weeks later, you add New Shiny Feature to
wombat
. Your tests all pass, CI is happy, and you release a new version.Is still all well? We don’t know!
You package claims to work with
foo-1.2.3
, but if your latest feature happens to be using code only available infoo-1.3
, this is now a lie, and there are no checks in place that check that. On CI,cabal
prefers building with the newest version possible, and always builds withfoo-1.3
.
Can affect upper bounds too!
Ping by packdeps, I just updated the few upper bounds of transformers
to allow 0.6. But transformers
is a boot package (comes with GHC, installed by default), so cabal
keeps using 0.5.6.2.
This led to a incompatiblity between mtl-2.3.1
, transformers-0.6
and GHC 9.0 being released without the CI system complaining.
(For the upper-version-untested-problem there is a maybe a simpler fix possible; in a way what I propose below is a generalization of that.)
The underlying problem
In both cases, the problem is that the package metadata specifies a (possibly) wide ranges of compatible packages, but the CI systems only test a small subset of these versions. As a rule of thumb, if it’s not CI tested, it breaks, so this is a problem.
The goal of version bounds
Stepping back a bit, I wonder: What is the main purpose of the version bounds in the cabal file?
It seems to be to indicate which dependency versions are expected to build, so that downstream users are not bothered with build failures of their dependencies (instead, cabal tries to pick different versions, or reports earlier with the inability to form a build plan).
Cabal’s 3.0 caret-set-syntax of specifying bounds, where the equivalent to the above build-depends would be
build-depends: foo ^>= { 1.2.3, 1.3 }
emphasizes this purpose.
(There is also the use of bounds to indicate semantic incompatibilities.)
Let’s avoid lies!
So it becomes clear: If we want to avoid our cabal files from lying, we need to have the package CI check every version allowed by the version range. Or, a bit more realistic, those versions that (by the PVP) imply compatibility with all versions. In the case of caret-set-depends, it’s simply the versions in the set.
But how?
What’s not clear to me is how to achieve that.
We could generate one Job for each dependency/version pair. Ideally dynamically, because we probably don’t want to edit/generate the .github/workflows
file whenever we edit the .cabal
file.
But some packages come with GHC are not reinstallable (e.g. base
), so when generating the job definition, we’d also need to know which GHC version can be used to test that package.
And this would lead to highly redundant builds once we have more than one dependency, as the jobs we generate for the versions of dependency foo
also build against some version of bar
, so having separate jobs for bar
is wasteful. (Users of nix
with the haskell.nix
infrastructure and a good nix-aware CI system like Hercules CI might get away with it, though).
Many freeze files?
Maybe this scheme would work:
-
Think about why you even want to keep supporting an older version of a dependency. Typical reasons are, I think
- You want to support building with an older version of GHC, and the dependency comes with GHc.
- You want to allow users of certain package sets (stackage, nixpkgs, Debian) to use your library without having to also upgrade these packages.
-
From this, you can derive a policy listing version set targets, e.g.
- latest stackage releases covering the latest 3 GHC versions
- nixpkgs unstable and stable,
- latest versions on hackage with latest released GHC
- GHC HEAD with head.hackage
-
Find a source for a cabal freeze file for each of these target.
For stackage, such files are provided (e.g. https://www.stackage.org/lts-17.13/cabal.config). For the others, let’s assume similar services exist, or tools that generate them on the fly.
-
For each of these freeze files, have a CI that uses it. This guarantees that your code keeps continues to be tested with the older versions of its dependencies.
-
Have a CI job that checks that the bounds in the
.cabal
file are nothing but lines of the formbuild-depends: foo ^>= { 1.2.3, 1.3 }
where the versions are those in the freeze file, and complains if they aren’t or – better – fixes them.
Alternatively, and more elegantly, don’t keep version bounds in the
.cabal
file in.git
and only add them, based on the tested freeze files, upon upload to hackage.
Collateral benefit: More automation
It seems that with this scheme, one does not only avoid lies in the .cabal file, but moreover the tedious maintenance of this data is simplified: One now has to manage these version sets sources as a whole, and the individual entries are derived from that.
I would imagine that the package sets are pinned (either simply vendored to the repo, or referenced in an immutable way) and an automated process regularly updates them as hackage/stackage/nixpkgs progresses.
With that in place, keeping your version bounds up-to-date and fresh may become merely a matter of checking and accepting automated PRs (which could include the relevant changelog entries from the updated dependencies in the PR description!) and making releases (itself automatable).
Does that make sense?
(Yes, there is still the lie that with multiple dependencies, such a .cabal
file will promise that all combinations of dependency versions ought to work, despite this not being tested on CI. But one step at a time.)