nomeata / cabal-plan-bounds

Calculate Haskell dependency ranges from multiple build plans
BSD 2-Clause "Simplified" License
33 stars 3 forks source link

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

Future work (contributions welcome!)