palantir / gradle-consistent-versions

Compact, constraint-friendly lockfiles for your dependencies
Apache License 2.0
113 stars 13 forks source link

Support for multiple disjoint versions.{props,locks} in a single project #972

Open mglazer opened 2 years ago

mglazer commented 2 years ago

What happened?

We are currently undertaking a large change to concurrently support both the legacy javax JavaEE bindings as well as the newer jakarta JavaEE bindings across our internal services and libraries. To do this, instead of making a hard break release where we suddenly cut over to the newer versions of the libraries (thus breaking any consumers who want a newer version of our library), we have been creating parallel copies of our libraries:

Where library is the javax version of the library which consumers can continue to upgrade to without issues, and library-jakarta is the one which pulls in the jakarta dependencies.

The way we've been working around this with GCV is a combination of two flags:

  1. Disabling the java plugin defaults for the javax version:
versionsLock {
  disableJavaPluginDefaults()
}

And then applying strict version constraints to certain libraries:

2.

implementation 'org.glassfish.jersey.core:jersey-server', {
   version {
     strictly '2.26'
   }
}

This works...mostly. It fails however in a number of circumstances:

  1. We regularly have to disable plugins which do anything novel with the runtime configuration. The revapi conjure plugin is one example, but there are others. I suspect this happens because the GCV unified classpath effectively locks every subproject into the same versions, and each plugin would have to have some way to respect the disableJavaPluginDefaults flag if they were to understand that they shouldn't lock into the root versions.
  2. Some upstream libraries, such as undertow, published a bridge version of their libraries, where for a short period of time you could resolve to undertow-servlet-jakarta or undertow-servlet. Libraries like Jersey never did this (to my knowledge), and thus if you have two dependencies on org.glassfish.jersey.core:jersey-server with different versions, GCV will use Gradle's largest version resolution rule, and always resolve to the 3.x version of Jersey across all projects.

We like using GCV because it allows us to trivially view the actually resolved versions of libraries within a project, so we would like to continue using GCV for its ability to let us easily audit the libraries that are used, but we'd like to propose some changes to GCV to better suit this particular use case (knowing that this use case should only be used under very limited circumstances such as those described above).

What did you want to happen?

I'm going to propose a few changes to GCV to accommodate our needs. First, given an example project:

library/
  build.gradle
library-jakarta/
  build.gradle
versions.props
versions.lock

Where versions.props should always contain the versions you want to move to, specifically:

versions.props:

org.glassfish.jersey.*:* = 3.0.6

jakarta.servlet:jakarta.servlet-api = 5.0.0
jakarta.ws.rs:jakarta.ws.rs-api = 3.1.0
jakarta.annotation:jakarta.annotation-api = 2.0.0
jakarta.validation:jakarta.validation-api = 3.0.2
jakarta.xml.bind:jakarta.xml.bind-api = 4.0.0

"legacy" versions of the libraries can define their own versions.props which override those of the root versions.props:

library/
  build.gradle
  versions.props
  versions.lock
library-jakarta/
  build.gradle
versions.props
versions.lock

Where library/versions.props contains:

org.glassfish.jersey.*:* = 2.36

And versions.locks contains the locks written as if the versions.props all along was:

org.glassfish.jersey.*:* = 2.36

jakarta.servlet:jakarta.servlet-api = 5.0.0
jakarta.ws.rs:jakarta.ws.rs-api = 3.1.0
jakarta.annotation:jakarta.annotation-api = 2.0.0
jakarta.validation:jakarta.validation-api = 3.0.2
jakarta.xml.bind:jakarta.xml.bind-api = 4.0.0

The concrete description of how this might works is as follows:

  1. Before running, GCV looks for any versions.props files located in any subprojects.
  2. If there are any versions.props in any subprojects, GCV begins to split its computation graph. It will take the root project versions.props and overwrite any exact group-artifact dependencies with the version defined in the subproject's `versions.props.
  3. For each subproject, create a separate unified classpath configuration and GCV Locks Configuration which allows for that subproject to independently own its GCV Locks, as if it were a separate root project from the get go.

There are some obvious complications here notably:

  1. If we have a structure like:
library/
  build.gradle
  versions.props
  sub-library/
    build.gradle
    versions.props

Do we allow such a system? I would personally argue that this should be disallowed since it just ends up with a combinatorial mess of configurations and locks. I'd suggest for an initial version we just check if there are sub-sub-versions.props and throw an exception if such is the case.

  1. Intra-project dependencies creating conflicting graphs

Say we have:

library-1/
  build.gradle
  versions.props
library-2
  build.gradle
  versions.props
versions.props

Where library-2 depends on library-1. Which versions.props do we use as the canonical truth? I would again propose that this usage be outlawed since it's not clear if there is a conflict between library-1 and library-2 which version we choose. Instead we should just enforce that there can be no intra-project dependencies between projects if both projects declare a versions.props.

Alternatives Considered

Exclusion List

A "cheap" option here would be to create an exclusion list of dependencies which we know to be in conflict in any given subproject, and simply exclude those from the constraints created in constructConstraintsFromLockFile. This exclusion file could be fined per-subproject or globally.

While this is an option, it seems undesirable because it makes the versions.lock not actually representative of what is being used or published and makes the auditability as well as relative reproducibility of builds more difficult to reason about.

Using Gradle Native Locking

Gradle native locking actually does precisely what we're proposing here:

https://docs.gradle.org/current/userguide/dependency_locking.html

There are reasons discussed (albeit going back to Gradle 4.8) about why GCV was favored over native lock support:

https://github.com/palantir/gradle-consistent-versions#are-these-vanilla-gradle-lockfiles

While it seems somewhat reasonable to try Gradle Native Locking again, it does suffer from a problem whereby we would have to update a large portion of our internal Gradle plugins to no longer assume the presence of GCV (many do rely on com.palantir.consistent-versions plugin being present in order to lock into a certain configuration). While certainly achievable to do this, it is possibly very disruptive to completely switch our dependency locking scheme for _some_projects (but may be easier as the end state is simply to delete these legacy subprojects.

deewar commented 11 months ago

I have a project that ships multiple versions of the same JDBC drivers in an asset bundle and to achieve that I had to disable GCV and add each driver into its own gradle configuration. If we used gradle native locking as a default, that would allow me to lock each configuration and make sure the dependencies of drivers dont change over time.