Skip to content

Calculate Haskell dependency ranges from multiple build plans

License

Notifications You must be signed in to change notification settings

nomeata/cabal-plan-bounds

Repository files navigation

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.