Include versions in published POM file when using Gradle platforms (BOM)

May 20, 2020

Gradle platforms are a nice feature introduced back in Gradle 5. They allow you to align the versions of the dependencies across multiple projects by declaring them explicitly in a “platform module”. This is no more than Gradle implementing Maven’s BOM (bill of materials) pattern.

This post is not intended to sell you the idea of Gradle platforms. In fact, it assumes familiarity with this feature.

Instead, we will discuss one of the downsides and a potential solution: when publishing an artifact that uses a platform, the generated POM file will not, by default, include the version numbers of the transitive dependencies.

e.g. say we have a library that uses a platform published at foo:bar:0.1 :

dependencies {
    implementation(platform("foo:bar:0.1"))

    // versions of these deps are provided by the platform
    api("org.apache.avro:avro-compiler")
    api("net.postgis:postgis-jdbc")
}

If you happen to publish this using Gradle’s Maven Publish plugin, this is how the resulting POM looks like:

<?xml version="1.0" encoding="UTF-8"?>
<!-- REDACTED -->
  <groupId>your.company</groupId>
  <artifactId>the-artifact</artifactId>
  <version>9.9.9</version>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>foo</groupId>
        <artifactId>bar</artifactId>
        <version>0.1</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.apache.avro</groupId>
      <artifactId>avro-compiler</artifactId>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>net.postgis</groupId>
      <artifactId>postgis-jdbc</artifactId>
      <scope>compile</scope>
    </dependency>
  </dependencies>
</project>

There are some subtle issues with this. The first one is that consuming such artifact from a different project might break on certain versions of Gradle, because apparently there’s a bug when parsing the dependencyManagement (see this and this).

The solution in that case is to remove the dependencyManagement block from the POM before publishing it. Something like this does the trick:

publishing {
  publications {
    maven(MavenPublication) {
      from components.java
      artifact(tasks["sourcesJar"])
      artifact(tasks["javadocJar"])

      pom.withXml {
        asNode().dependencyManagement.dependencies.dependency.findAll { node ->
        node.scope[0].text().equals('import')
        }.each { node -> node.replaceNode {} }
      }
    }
  }
}

Basically, we remove the dependencies whose scope is equals to import . Now publishing the artifact produces a POM like this one:

<?xml version="1.0" encoding="UTF-8"?>
<!-- REDACTED -->
  <groupId>your.company</groupId>
  <artifactId>the-artifact</artifactId>
  <version>9.9.9</version>
  <dependencies>
    <dependency>
      <groupId>org.apache.avro</groupId>
      <artifactId>avro-compiler</artifactId>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>net.postgis</groupId>
      <artifactId>postgis-jdbc</artifactId>
      <scope>compile</scope>
    </dependency>
  </dependencies>
</project>

This looks better, and it could work if the consumers of this artifact use the same platform. e.g. this would work:

// some consumer of the library
dependencies {
    implementation(platform("foo:bar:0.1"))
    // consuming library
    api("you.company:the-artifact:9.9.9")
}

But what if the consumer of the library either can’t or shouldn’t access the platform? Maybe it can’t because the platform is a private artifact.

Including a platform could also cause problems because they participate in the dependency conflict resolution algorithm, which means it could break your build by forcing the version of some unrelated dependency to one not supported by the consumer, this could cause the build to fail or, even worse, compile fine and then break at runtime.

The solution is to make sure that you include an explicit version of each dependency in the POM file. This can be done by explicitly setting the version in your build.gradle , but that defeats the purpose of using a platform.

Instead, I found this to help:

publishing {
  publications {
    maven(MavenPublication) {
      from components.java
      artifact(tasks["sourcesJar"])
      artifact(tasks["javadocJar"])

      pom.withXml {
        asNode().dependencyManagement.dependencies.dependency.findAll { node ->
        node.scope[0].text().equals('import')
        }.each { node -> node.replaceNode {} }
      }

      versionMapping {
        usage('java-api') {
          fromResolutionOf('runtimeClasspath')
        }
        usage('java-runtime') {
          fromResolutionResult()
        }
      }
    }
  }
}

By using this versionMapping configuration, we instruct Gradle to include the resolved version for each dependency in the generated POM file. It now looks like this:

<?xml version="1.0" encoding="UTF-8"?>
  <!-- REDACTED   -->
  <groupId>your.company</groupId>
  <artifactId>the-artifact</artifactId>
  <version>9.9.9</version>
  <dependencies>
    <dependency>
      <groupId>org.apache.avro</groupId>
      <artifactId>avro-compiler</artifactId>
      <version>1.9.0</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>net.postgis</groupId>
      <artifactId>postgis-jdbc</artifactId>
      <version>2.2.0</version>
      <scope>compile</scope>
    </dependency>
  </dependencies>
</project>

This is a POM that can be safely consumed, regardless of whether the consumer is also using a BOM or not.

© 2017 | Powered by Hugo ♥ | Art by Clip Art ETC