cabal-plan-bounds: generate cabal bounds from actual build plans
TL;DR: Updates the .cabal
file’s build-depends
and (not yet) tested-with
based on the
build.json
of actual build paths. You never have to edit the build depends
manually again.
The problem
Manually curated dependency version ranges tend to become a lie: They likely
include versions of your dependencies that are neither longer tested by your CI
system, or implied by compatibility with the tested versions (by way of the PVP).
Typically, these are versions near the lower edge of the bounds, but can also
be on the upper end (e.g. when they are packaged with GHC and Cabal prefers
installed versions, or when they are not actually installable yet).
There are ways to mitigate this problem, such as being very careful, and maybe
using Cabal's new --prefer-oldest
flag. But these are not reliable.
The solution
So the conclusion must be to not write build-depends ranges by hand.
Which is an unpleasant chore anyway.
Instead, derive the build-depends from your actual CI builds.
Presumably you test your code in different situations anyways – different
versions of GHC, stackage releases etc. Keep doing that, collect the actual
build plans used in these CI systems. Then pass them to cabal-plan-bounds
which
will update the bounds accordingly.
Simple example
For a simple example, you can just call cabal build
with different compilers:
$ cabal build -w ghc-8.10.7 --builddir dist-8.10.7
$ cabal build -w ghc-9.0.2 --builddir dist-9.0.2
$ cabal build -w ghc-9.2.5 --builddir dist-9.2.5
$ cabal build -w ghc-9.4.4 --builddir dist-9.4.4
and then update the cabal file
$ cabal-plan-bounds dist-{8.10.7,9.0.2,9.2.5,9.4.4}/cache/plan.json -c cabal-plan-bounds.cabal
This will lead to the following diff:
diff --git a/cabal-plan-bounds.cabal b/cabal-plan-bounds.cabal
index 1db21ca..a99e7bc 100644
--- a/cabal-plan-bounds.cabal
+++ b/cabal-plan-bounds.cabal
@@ -20,9 +20,12 @@ executable cabal-plan-bounds
import: warnings
main-is: Main.hs
other-modules: ReplaceDependencies
- build-depends: base, Cabal-syntax, cabal-plan,
- optparse-applicative, containers,
- text
- build-depends: bytestring,
+ build-depends: base ^>=4.14.3 || ^>=4.15.1 || ^>=4.16.4 || ^>=4.17.0,
+ Cabal-syntax ^>=3.8.1,
+ cabal-plan ^>=0.7.2,
+ optparse-applicative ^>=0.17.0,
+ containers ^>=0.6.4,
+ text ^>=1.2.4 || ^>=2.0.1
+ build-depends: bytestring ^>=0.10.12 || ^>=0.11.3
hs-source-dirs: src/
default-language: Haskell2010
More sophisticated setup
For a more sophisticated setup, you can create multiple cabal.project
files,
one for each setting:
$ ls ci-configs/
ghc-8.10.7.config ghc-9.2.5.config stackage-nightly.config
ghc-9.0.2.config ghc-9.4.4.config
$ cat ci-configs/ghc-9.4.4.config
import: cabal.project
active-repositories: hackage.haskell.org:merge
index-state: hackage.haskell.org 2022-12-21T10:40:48Z
with-compiler: ghc-9.4.4
Here we pin the compiler version and the precise view of Hackage repo to get
reproducible results. You can imagine a separate tool that regularly updates
these time stamps.
Similarly, we can pull in stackage configurations, simply by importing the
corresponding cabal.config
, which also pins down the compiler
$ cat ci-configs/stackage-nightly.config
import: cabal.project
import: https://www.stackage.org/nightly-2023-01-03/cabal.config
(Probably these should also pin the index-state
for reproducibility -- up to you.)
Now you can configure your CI system to run one job for each of these configs,
collect the plan.json
files, and finally check that the version bounds in your
.cabal
file match, and if not, complain or auto-update them. See the workflow
file of this repository for an example.
Usage
$ cabal-plan-bounds --help
Derives dependency bounds from build plans
Usage: cabal-plan-bounds [-n|--dry-run] [--extend] [--also ARG] [PLAN]
[-c|--cabal CABALFILE]
Available options:
-h,--help Show this help text
-n,--dry-run do not actually write .cabal files
--extend only extend version ranges
--also ARG additional versions (pkg-1.2.3 or "pkg ==1.2.3")
PLAN plan file to read (.json)
-c,--cabal CABALFILE cabal file to update (.cabal)
Features and limitations
-
You can pass more than one cabal file at the same time.
-
It edits the .cabal
file in place.
-
It leaves the .cabal
file as is: No reformatting, all comments are
preserved.
-
Only the build-depends
fields are touched. They are reformatted (one
dependency per line).
It does not add, remove or reorder the packages mentioned in the
dependencies.
-
It will apply the same bounds to all mentions of a dependency in the
.cabal
file (e.g. in different components, or in conditionals).
It does not support different ranges in different components. (Maybe it could
be smarter here, but due to common
sections and conditionals, it cannot be
complete.)
This means some behaviour cannot be achieved. Maybe this needs to be revised
(especially with regard to conditionals).
-
It strips the patch level from the bound, and will write ^>= 1.2.3
instead
of ^>= 1.2.3.4
, assuming the package follows the PVP.
Future work (contributions welcome!)
- Proper error handling, e.g. while parsing.
- A test suite
- A
--check
mode that does not touch the .cabal
file, but fails if it would
change it (for CI).
- Update the
tested-with
field according to the compiler versions used.