Dependency bound bumping using Renovate

I’ve added Cabal file parsing to Renovate. Renovate is a program that scans your build configuration and finds outdated dependencies. To learn more about Renovate, here is the product page, here is the Github page.

I’d like to invite users of haskell-actions to try this out on projects with simple bounds like >=X.Y && <V.W. Setup instructions: haskell-actions/setup/docs.examples.md#Dependency updates.

For maximum convenience, Cabal constraints should get automatically picked up from Renovate commits, without any additional configuration. Without any way to pick up the new version (be it through git trailers or some other mechanism), the Cabal solver (running in the CI of the Renovate PR) might not actually pick the newly released version of the dependency, which means the PR must be tested manually, which is a bummer.

Using git trailers for messaging between Renovate and the CI suite is one protocol that enables this functionality. It would be nice to agree on the protocol across projects. That way, arbitrary projects could swap either Renovate or haskell-actions out for other solutions.

In the future, I think I’d be nice to add support for this protocol to e.g. Haskell-CI.

Of course, it would also be possible to have Renovate add these trailers by default, eventually.

Example scenario

Let’s say you have the dependency bound lens >=5.2 && <5.3. Now, imagine that lens-5.3 gets released. (you can see on Hackage that this did in fact happen in May 2024)

  1. The Renovate Github app scans your repo periodically
  2. It makes a commit with the trailer New-Versions: lens==5.3.3, and a PR with that commit
  3. Your CI suite, when it runs on the Renovate PR, configured using the instructions linked above, converts the git trailer to the line constraints: lens==5.3.3 in cabal.project.
  4. The CI suite either passes or fails.
    • If it fails (as it very well might, since this is a major release of lens!), human intervention is necessary. Well, at least you know.
    • If it passes, you could merge the PR. Any subsequent Hackage revision/release could also be automated, but that’s a different topic.

Related projects

  • Joachim Breitner authored haskell-bounds-bump-action. One limitation is that it only works for Github. In contrast to Renovate, it is coded in Haskell, which means it can leverage the Cabal libraries.

  • Freckle authored stack-lint-extra-deps. It is an independent executable, so you can run it anywhere, like Renovate. And it is coded in Haskell. But it is obviously just for stack.yaml files.

23 Likes

Can you be more specific here, what exactly is in the commit? There’s no New-Versions property in .cabal file, did you mean build-depends?

You can see an example commit in the pureMD5 repo. New-Versions is a git trailer, they are documented in the official git documentation or at alchemists.io. Git trailers are part of the commit message. Github will show you the commit message in monospace type.

The point of this trailer is to communicate a temporary constraint to Cabal running in the CI system. It’s related to build-depends, since build-depends entries also typically have constraints on them. But New-Versions is used here for pinning to an exact version, which is the version that Renovate discovered. We want the CI suite to run with that exact version, because we want to find out whether the new release is compatible or not. If we didn’t have this additional constraint, the Cabal solver might not actually pick the new release. The modified build-depends entry as generated by Renovate will allow the newly released version, but it is wider than that, and in fact allows the whole major series that the new version is part of.

Does that clarify anything? I am still wondering how I can make this more appealing to e.g. new users reading the haskell-actions documentation. So if this message clarifies things for you, I’d love to hear if you have any suggestions on how I can make this as appealing as possible to readers of the haskell-actions documentation.

2 Likes

While Renovate doesn’t offer native stack.yaml support yet, it is possible to use JSONata to extract dependencies from arbitrary YAML files, using user-written configuration.

Here is an example for how: Add Renovate config by ysangkok · Pull Request #256 · jaspervdj/websockets · GitHub

Note how this snippet doesn’t replace the SHA256. I am mainly posting this as a proof of concept, we’re in the ‘Show and Tell’ category after all. I’d love to discuss if anyone has any interest in developing this further.

1 Like

Many thanks @janus for adding support for Cabal files. Having Renovate support makes Haskell one more important bit commercially appealing.

Since the topic of lock files and the lack thereof in Cabal came up in Request for comment: `cabal freeze` doesn't produce a lock file - #58 by malteneuss. Was this the reason to go for “git trailers”? It feels like a hack, but may have be the only reliable option in the current state.

topic of lock files and the lack thereof in Cabal […] Was this the reason to go for “git trailers”? It feels like a hack, but may have be the only reliable option in the current state.

Do you even want a solver? Do you want full determinism?

In the thread you link, multiple people have conflicting desires. I suspect that a lot of them just want to turn off the solver and pin every version, even transitively. That kind of workflow could hypothetically be supported in Renovate, but it’s not clear how, since Renovate doesn’t know about the dependency graph, so it doesn’t know what isn’t pinned in cabal.project (which is where I imagine you’d want it to enforce pins on transitive dependencies).

Stack/Stackage offers a full solution for people that don’t want a solver. For example application authors that don’t have to be compatible with multiple versions of anything.

Pinning with Renovate

Renovate supports rangeStrategy=pin. But my suspicion is, that it makes more sense when each dependency also specifies the exact version of its own dependencies. This is easier in languages where you can have multiple versions of the same library used at the same time.

Since build-depends doesn’t pin transitive dependencies, this rangeStrategy=pin might not actually achieve what people really want, which is, as previously mentioned, probably just full determinism. I’d love to hear about a potential use case for rangeStrategy=pin, but I haven’t seen it so far. It seems to me that it often makes more sense to just use Stack/Stackage.

Widespread pinning in Hackage gets annoying

When many libraries pin, but they don’t bump at the same time, you get unsolvable build plans when packages with conflicting build-depends get used together. So trustees would need to relax constraints for these packages. I don’t want to induce even more work for trustees. So that’s an argument against supporting rangeStrategy=pin.

Indeterminism in Hackage culture as it exists

Since I am aiming to help existing Hackage libraries keep current, I have developed support for the rangeStrategy=widen, which is used with ranges.

This fits the existing Hackage culture, and work with the maximum amount of library authors. widen means that the build-depends are kept in a format that admits a range of major versions (see earlier posts in this thread).

Admitting a version range in build-depends is a deliberate omission of details. Similarily, if you don’t commit the index-state into your repo, the solver has, depending on when it was invoked, a different set of information that it uses to choose an exact version. In other words, the choice is underspecified, indeterministic. But it’s deliberately the way that Hackage works.

Automated bumping makes it easier to have more determinism

With the advent of CI systems and automated bots, you might argue that we should always pin index-state, because it adds more determinism. I think that might be a good idea. Why don’t people do it? Probably the indeterminism just isn’t enough of a problem. The ranges they use work well enough. So it’s not a bad idea to pin index-state, it’s just that nobody invested the time in developing automatic index-state bumping. Also, as previously mentioned, many application developers don’t even want a solver.

Why temporary constraints?

Previously in this thread, I have tried to explain why it is useful to pin the version of the dependency getting bumped. For example, in my initial message, I covered it in the paragraph starting with For maximum convenience. In my reply to blamario, I used the phrasing temporary constraints.

As previously explained, this constraint is necessary because the Cabal solver is otherwise not necessarily choosing the “new” version.

But just because the temporary constraint is needed because of the Cabal solver, doesn’t mean that this is a lock file in spirit. It covers only a limited amount of direct dependencies. And it’s not a file. It’s not committed into the repo either.

If you didn’t use the Cabal solver, of course the temporary constraint wouldn’t be necessary: Let’s say you used Stackage, but you have an extra-dep. Now, Stack doesn’t have a solver. So if you update the version of the extra-dep, and it is in build-depends, you know that Stack will pick that version. It is committed into stack.yaml, which is a file in your repo. When Renovate updates stack.yaml, the version stays pinned, but now to the new version. No need for any git trailer to tell the CI system about any temporary constraint. All constraints are committed into the repo. More determinism, more specification, more bumping.

So I don’t think there is a bug here, since the solver is an integral part of working with Cabal. Cabal gives you the option of using freeze files, index-state, --prefer-oldest and so on.

So, since there is no bug, workaround is not the right word.

Addendum: Snoyman’s philosophy as I understand it

In the above example, where you have a library that you’re testing with everything pinned in a stack.yaml, you might just want to remove all constraints. After all, you have already pinned the version in stack.yaml, so you could argue that you don’t want to be making any promises about compatibility with other versions, at all. Even minor.

As far as I understand, this would be Snoyman’s philosophy on PVP version bounds:

Curation is a better solution for the majority of use cases

Curation is what Stackage does. So in other words, instead of having every project run the CI suite with every imaginary configuration, let’s just choose one specific version of every library, that all other libraries must then be compatible with. The Cabal solver is replaced by the curator and it is run once for every user instead of once for every build. It’s an actual program that the Stackage Curators run.

I’m loving Renovate support in my repository and I think it’s extremely cool what’s being done! I was just wondering, is there a possibility of supporting disjoint dependency ranges?

I received a PR for:
Aeson >=1.5 && <1.6 || >=2.0 && <2.3>=1.5 && <2.3
which I don’t think is correct.

Glad to hear you’re using this. I am not sure whether it is possible to support disjoint dependency ranges. The implementation has methods like isLessThanRange which doesn’t seem to fit with the concept of disjunct ranges, since it’s unclear to me whether e.g. a hypothetical aeson-1.8 is “less than” the range you mentioned, or not. One would probably have to examine the use sites to infer the desired behaviour of the function.

Renovate doesn’t seem to have internal interfaces that enforce algebraic laws of predicates run on ranges and versions. That’s an aside though, and it might very well be possible to have good algebraic properties enforced within just the PVP code.

In Renovate, the question of how a range is handled is called the rangeStrategy. The only rangeStrategy currently supported is widen. My opinion is, that instead of trying to add additional heuristics to the widen strategy, it might be better to add support for more rangeStrategies like e.g. bump, which would do something different, but reliably. For example, it might just use ^>= to just include the new dependency version, nothing else, and it would discard the old range entirely.

One could also imagine a rangeStrategy that would always emit the replacement oldRange || ^>= newVersion. This doesn’t seem to be captured by any of the existing rangeStrategies though, and I don’t know the criteria for adding a new strategy. The advantage of this hypothetical rangeStrategy, is, that it would work for libraries that leave gaps in the major ranges, like aeson (which you mentioned) or text, another example. The disadvantage is that you’d end up with long chains of ||, which I think are hard to read.

I’d personally question whether it even makes sense to claim compatibility with very many major versions at all… Of course, my opinion shouldn’t, and doesn’t, prevent someone else from contributing support to Renovate that improves support for these patterns.

To support this well, one might need a constraint interpreter that works for any constraint expression. I am not sure how hard this would be, but some parts like e.g. sublibraries seem almost infeasible to add support for. You’d have to reimplement too much of Cabal.

1 Like

Thank you for the reply! That makes sense, I understand the pain. It’s really only for text and aeson, so I’ll manually update those in the future and close the relevant Renovate PRs.

I’d personally question whether it even makes sense to claim compatibility with very many major versions at all…

Yeah, I’m not sure myself, but ideally I want my library to be supporting GHC 8.8 all the way to 9.12 while getting all the bug fixes and new features “backported” for any users stuck in the past due to maybe unchangeable external factors.

On a slight tangent, and this is nothing against you, but more a slight personal rant: my “support many GHC versions” philosophy above was a hinderance when configuring the Renovate CI using git trailers. It was because I wanted:

  • Forcing and testing the bumped version on the most recent GHC CI
  • But for older GHC CIs keep using the last supported dependency version, not using the forced constraint

It’s specifically a problem with the wuss dependency since with every release, it removes support for anything older than the last 3 GHC releases. So on GHC 8.10 CI for example, I need it to use an older wuss, while on GHC 9.12, I need it to test the latest wuss that Renovate found. For now I’ve given up on the Renovate CI because I couldn’t find how to do this.

I can think of two ways that constraints can be conditional on the GHC version:

  • One option is to use the Github Actions YAML-based DSL to conditionally write the constraint. This would mean adding an if: attribute to the step from haskell-actions/setup/examples.md.
  • Another option is to put the conditional inside the cabal.project file, using the impl(compiler) construct (documentation for Cabal v3.14). You need to replace compiler with a predicate like ghc < 9

I’d be interested in hearing if any of these approaches would work for you.

1 Like