ben-manes / gradle-versions-plugin

Gradle plugin to discover dependency updates
Apache License 2.0
3.82k stars 199 forks source link

More flexible reports for dependencies coming from BOMs #868

Open steve-todorov opened 1 month ago

steve-todorov commented 1 month ago

Feature request

We'd like to suggest adding a flag that would stop showing dependencies in reports for dependencies that are pulled in via a BOM. The flag would probably need to have three modes:

  1. default - print the report as-is.
  2. silent - don't show any dependency update reports that have been pulled in from a BOM.
  3. warn - print a one-liner saying `hey

The different modes would make the reports act slightly differently:

  1. default mode the end report would be as it is now:
    The following dependencies have later release versions:
     - com.google.code.gson:gson [2.10.1 -> 2.11.0]
  2. silent mode would remove the dependency from the report.
  3. warn could output something like this:
    The following dependencies defined in BOM(s) ["com.google.cloud:libraries-bom:26.40.0", "xyz:version"]  have later release versions:
     - com.google.code.gson:gson [2.10.1 -> 2.11.0]

A bit more context

We have multiple projects that enforce bom dependencies. Here's an example:

implementation(enforcedPlatform("com.google.cloud:libraries-bom:26.40.0"))

When we run ./gradlew dependencyUpdates the report will say there's a newer com.google.code.gson:gson version:

The following dependencies have later release versions:
 - com.google.code.gson:gson [2.10.1 -> 2.11.0]

On its own the report is absolutely correct, but kind of lacks some flexibility and additional context. Someone could easily be confused and decide to define implementation("com.google.code.gson:gson:2.11.0") which won't work, because the version 2.10.1 is enforced via the enforcedPlatform.

Our case would fall under the silent mode if there were a flag.

ben-manes commented 1 month ago

I don't think this information is provided in the gradle apis. If I understand correctly, it is internally modeled as a strictly dependency constraint. We'd have to see if this dependency was also listed in the configuration's [getAllDependencyConstraints](https://docs.gradle.org/current/javadoc/org/gradle/api/artifacts/Configuration.html#getAllDependencyConstraints()) to guess that it was a BOM that might reported on differently. However, since we wouldn't know that, it could just as easily be your own constraints to avoid a version with a CVE, etc.

Since builds vary quite a bit and not all of the information is available in the APIs, I find it slightly better to workaround in the build instead of trying to bake assumptions into this plugin. Then you have a few options for how you might coerce this plugin to report on the updates.

One approach is to simply apply a resolution strategy to force the version. This is the easiest even though it is a little surprising when someone forgets about this configuration. Lets say you use version catalogs with an enforcedPlatforms bundle. The report will now show those libraries in the up-to-date section at its declared version.

tasks.named<DependencyUpdatesTask>("dependencyUpdates").configure {
  resolutionStrategy {
    libs.bundles.enforcedPlatforms.get().forEach { library ->
      force(library)
    }
  }
}

The other approach is to rewrite the report before it is printed.

import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
import com.github.benmanes.gradle.versions.reporter.PlainTextReporter

tasks.withType<DependencyUpdatesTask> {
  outputFormatter {
    val reporter = PlainTextReporter(project, revision, gradleReleaseChannel)
    libs.bundles.enforcedPlatforms.get().forEach { library ->
      exceeded.dependencies.removeAll { dependency -> 
        "${dependency.group}:${dependency.name}" == "${library.module}"
      }
    }
    reporter.write(System.out, this)
  }
}

In both cases you would want to centralize your dependency definitions so that you can work with them programmatically. This tends to be a nice build cleanup in general and now idiomatic with version catalogs. You'll still need to opt-in the dependencies to ignore, but it will be very clear the next time you revisit the plugin's configuration what your customizations were and can easily add or remove as you see fit.

steve-todorov commented 4 weeks ago

@ben-manes Thank you very much for your fast reply!

You understood me correctly. The idea is to prevent dependencies defined in a BOM from being suggested in the reports, because they are enforced.

Your suggestion sounds like a possible solution, but from what I understand I should be defining the BOM in a enforcedPlatforms bundle, right? Or do you mean to define each dependency from the BOM into the bundle catalogue and then use those with the force() (which is what your code suggests)

ben-manes commented 4 weeks ago

I'm not really sure the difference between your question options. I can show you what I did in Caffeine to apply constraints to ensure transitives with CVEs were upgraded for security analyzers.

libs.versions.toml

[bundles]
constraints = ["bcel", "bouncycastle-jdk18on", "commons-compress", "commons-text", "h2",
  "httpclient", "guava", "jcommander", "jgit", "jsoup", "protobuf", "snakeyaml" ]

build.gradle.kts

dependencies {
  libs.bundles.constraints.get().forEach { library ->
    constraints.add("implementation", library.module.toString())
      .version { require(library.version!!) }
  }
}

My examples adapted this to your situation. I defined the bundle so that I could group the dependencies and programmatically apply them. I could do it one-by-one, as I did for my report's individual forces, but with only a few (due to jdk compatibility reasons) I didn't generalize it there.

Does those details help?

ben-manes commented 4 weeks ago

re-reading and I see what you mean. You would want force the platform BOM itself instead of rewriting the report where you would need to know the individual dependencies and that would certainly be painful. You would need to somehow do this within the resolution strategy to apply the strictly constraint on the BOM there and force the version. I'm not sure if that can be defined there. maybe something like

    resolutionStrategy.eachDependency { DependencyResolveDetails details ->
        if (details.requested.group == 'com.google.cloud' && details.requested.name == 'libraries-bom') {
            details.useVersion {
                strictly '26.40.0'
            }
        }
    }
steve-todorov commented 4 weeks ago

Yeah, the idea is to avoid reports that suggest updating transitive dependencies which were included from a bom. Having to list them one-by-one in a catalogue would be tedious.

I am not sure your suggestion makes sense to me. The implementation(enforcedPlatform("com.google.cloud:libraries-bom:26.40.0")) line already enforces all transitive dependencies pulled in via the bom. I did try adding the resolutionStrategy you mentioned to the configurations, but that did not really do anything. The report still suggests the newer gson version:

The following dependencies have later release versions:
 - com.google.code.gson:gson [2.10.1 -> 2.11.0]
     https://github.com/google/gson

I was thinking perhaps I could get the pom.xml of the bom and attempt to use your force suggestion but looping through the dependencies from the pom.xml instead of a catalog. Unless you know of a better way to get those?

steve-todorov commented 4 weeks ago

Ahh.. I just realized the pom.xml reading idea won't work as well, because some BOM pom.xml files might import others:

  <dependencyManagement>
    <dependencies>
      <!-- first-party-dependencies is part of java-shared-dependencies
           BOM in https://github.com/googleapis/sdk-platform-java/blob/main/java-shared-dependencies/first-party-dependencies/pom.xml.
           This includes Guava, Protobuf, gRPC, Google Auth Libraries, etc. -->
      <dependency>
        <groupId>com.google.cloud</groupId>
        <artifactId>first-party-dependencies</artifactId>
        <version>3.30.1</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>

      <!-- google-cloud-java from https://github.com/googleapis/java-cloud-bom -->
      <dependency>
        <groupId>com.google.cloud</groupId>
        <artifactId>google-cloud-bom</artifactId>
        <version>0.221.0</version><!-- {x-version-update:google-cloud-bom:current} -->
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
ben-manes commented 4 weeks ago

hmm, yeah this one is tricky. I don't know how using Gradle's apis one can do all of this.

You can get the pom from an ArtifactResolutionQuery (example), but that full evaluation is a lot of messy code. I think that might be the only way though...

When we copy a configuration we clear the dependencies and rewrite them with a dynamic version, +. That loses the enforcedPlatform, which seems to work by adding a dependency attribute. We do copy attributes those as onto our dynamic query, so I am unsure why it wouldn't be forced that way already. There's not any knobs in the resolution strategy or other APIs that I see for us to play with to get your desired result.

ben-manes commented 4 weeks ago

Here is a proof-of-concept that works for you to extend further. It resolves all of the BOM dependencies and rejects any later version found. It is certainly a bit hacky but should be enough for you to play with until you are happy about the code quality.

plugins {
  id "com.github.ben-manes.versions" version "0.51.0"
  id 'java'
}

repositories {
  mavenCentral()
}

dependencies {
  implementation("com.google.code.gson:gson:2.10.1")
}

tasks.named("dependencyUpdates").configure {
  def resolved = []
  doFirst {
    resolveBom("com.google.cloud", "libraries-bom", "26.40.0", resolved)
  }
  resolutionStrategy {
    componentSelection {
      all {
        if (resolved.contains("${candidate.group}:${candidate.module}")
            && (candidate.version != currentVersion)) {
          reject('Bom dependency')
        }
      }
    }
  }
}

def resolveBom(def group, def name, def version, def accumulator) {
  def resolutionResult =
    project.dependencies
      .createArtifactResolutionQuery()
      .forModule(group, name, version)
      .withArtifacts(MavenModule, MavenPomArtifact)
      .execute()
  for (def result : resolutionResult.resolvedComponents) {
    for (def artifact : result.getArtifacts(MavenPomArtifact)) {
      if (artifact instanceof ResolvedArtifactResult) {
        def file = artifact.file

        def pom = new XmlSlurper(/* validating */ false, /* namespaceAware */ false).parse(file)
        def dependencies = pom.dependencyManagement.dependencies.dependency
        dependencies.each {
          def coordinate = "${it.groupId.text()}:${it.artifactId.text()}"
          if (accumulator.add(coordinate)) {
            if ((it.scope.text() == 'import') || (it.type.text() == 'pom')) {
              resolveBom(it.groupId.text(), it.artifactId.text(), it.version.text(), accumulator)
            }
          }
        }
      }
    }
  }
}