[2.x] feat: Support Maven BOM (Bill of Materials) (#8675)

**Consuming BOMs**

- You can declare a BOM with `.pomOnly()` and versionless deps with `"*"`:
  - `libraryDependencies += ("com.fasterxml.jackson" % "jackson-bom" % "2.21.0").pomOnly()`
  - `libraryDependencies += "com.fasterxml.jackson.core" % "jackson-core" % "*"`
- BOMs are passed to Coursier via `Resolve.addBom()`; version `"*"` is resolved from the BOM.

**makePom**

- POM-only dependencies are emitted under `<dependencyManagement><dependencies>...</dependencies></dependencyManagement>` with `<type>pom</type>` and `<scope>import</scope>`.
- Dependencies with version `"*"` are emitted without a `<version>` element so Maven uses the BOM-managed version.

**Ivy / publishLocal emulation**

- When publishing to Ivy (e.g. `publishLocal`), BOM-resolved versions (deps that had `"*"`) are written into the published `ivy.xml` as forced dependencies (`force="true"`), so consumers that depend on this module get those versions.
This commit is contained in:
bitloi 2026-02-02 10:54:43 -05:00 committed by GitHub
parent 40ef381fa2
commit 4c16466672
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 218 additions and 36 deletions

View File

@ -5,6 +5,7 @@ import local.Scripted
import java.nio.file.{ Files, Path => JPath }
import java.util.Locale
import sbt.internal.inc.Analysis
import sbt.Tags
import com.eed3si9n.jarjarabrams.ModuleCoordinate
// ThisBuild settings take lower precedence,
@ -74,6 +75,10 @@ def commonSettings: Seq[Setting[?]] = Def.settings(
testFrameworks += TestFramework("hedgehog.sbt.Framework"),
testFrameworks += TestFramework("verify.runner.Framework"),
Global / concurrentRestrictions += Utils.testExclusiveRestriction,
// On Windows, limit to one task at a time to avoid OverlappingFileLockException when
// multiple tasks (e.g. scalafix plugin and sbt Coursier) write to the same cache.
Global / concurrentRestrictions ++= (if (scala.util.Properties.isWin) Seq(Tags.limitAll(1))
else Nil),
Test / testOptions += Tests.Argument(TestFrameworks.ScalaCheck, "-w", "1"),
Test / testOptions += Tests.Argument(TestFrameworks.ScalaCheck, "-verbosity", "2"),
compile / javacOptions ++= Seq("-Xlint", "-Xlint:-serial"),

View File

@ -28,8 +28,7 @@ import lmcoursier.syntax.*
import sbt.internal.librarymanagement.IvySbt
import sbt.librarymanagement.*
import sbt.util.Logger
import coursier.core.Dependency
import coursier.core.Publication
import coursier.core.{ BomDependency, Dependency, Publication }
import scala.util.{ Try, Failure }
@ -217,14 +216,42 @@ class CoursierDependencyResolution(
val interProjectRepo = InterProjectRepository(interProjectDependencies)
val extraProjectsRepo = InterProjectRepository(extraProjects)
val dependencies = module0.dependencies
// BOM (Bill of Materials): deps with only pom artifact (e.g. .pomOnly()) go to Resolve.addBom (sbt#4531)
def isBom(m: ModuleID): Boolean =
m.explicitArtifacts.nonEmpty && m.explicitArtifacts.forall(_.`type` == "pom")
val (bomModules, regularModules) = module0.dependencies.partition(isBom)
val boms: Seq[BomDependency] = bomModules.map { m =>
val (mod, ver) =
FromSbt.moduleVersion(
m,
sv,
sbv,
optionalCrossVer = true,
projectPlatform = projectPlatform
)
BomDependency(ToCoursier.module(mod), ver, Configuration.empty)
}
// Coursier fills version from BOM only when versionConstraint is empty (Resolution.processedRootDependencies).
// So for deps with "*" or "" and BOMs present, pass empty version so BOM can supply it (sbt#4531).
val dependencies = regularModules
.flatMap { d =>
// crossVersion sometimes already taken into account (when called via the update task), sometimes not
// (e.g. sbt-dotty 0.13.0-RC1)
FromSbt.dependencies(d, sv, sbv, optionalCrossVer = true)
FromSbt.dependencies(d, sv, sbv, optionalCrossVer = true, projectPlatform = projectPlatform)
}
.map { (config, dep) =>
(ToCoursier.configuration(config), ToCoursier.dependency(dep))
val depForResolve =
if (boms.nonEmpty && (dep.version == "*" || dep.version.isEmpty))
lmcoursier.definitions.Dependency(
dep.module,
"",
dep.configuration,
dep.exclusions,
dep.publication,
dep.optional,
dep.transitive
)
else
dep
(ToCoursier.configuration(config), ToCoursier.dependency(depForResolve))
}
val orderedConfigs = Inputs
@ -275,6 +302,7 @@ class CoursierDependencyResolution(
strictOpt = conf.strict.map(ToCoursier.strict),
missingOk = conf.missingOk,
retry = conf.retry.getOrElse(ResolutionParams.defaultRetry),
boms = boms,
)
def artifactsParams(resolutions: Map[Configuration, Resolution]): ArtifactsParams =

View File

@ -31,7 +31,8 @@ final case class ResolutionParams(
params: coursier.params.ResolutionParams,
strictOpt: Option[Strict],
missingOk: Boolean,
retry: (FiniteDuration, Int)
retry: (FiniteDuration, Int),
boms: Seq[BomDependency] = Nil
) {
lazy val allConfigExtends: Map[Configuration, Set[Configuration]] = {
@ -97,9 +98,10 @@ final case class ResolutionParams(
a14,
a15,
a16,
a17
a17,
a18
) =>
(a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17).##
(a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18).##
}
// ResolutionParams.unapply(this).get.##

View File

@ -99,6 +99,7 @@ object ResolutionRun {
dep
}
)
.withBoms(params.boms)
.withRepositories(repositories)
.withResolutionParams(
params.params

View File

@ -97,8 +97,18 @@ class MakePom(val log: Logger) {
{extra}
{
val deps = depsInConfs(module, configurations)
val (bomDeps, regularDeps) =
deps.partition(d =>
d.getAllDependencyArtifacts.nonEmpty &&
d.getAllDependencyArtifacts.forall(_.getType == Artifact.PomType)
)
makeProperties(module, deps) ++
makeDependencies(deps, includeTypes, ArraySeq.unsafeWrapArray(module.getAllExcludeRules))
makeDependencyManagement(bomDeps) ++
makeDependencies(
regularDeps,
includeTypes,
ArraySeq.unsafeWrapArray(module.getAllExcludeRules)
)
}
{makeRepositories(ivy.getSettings, allRepositories, filterRepositories)}
</project>)
@ -238,6 +248,28 @@ class MakePom(val log: Logger) {
}
val IgnoreTypes: Set[String] = Set(Artifact.SourceType, Artifact.DocType, Artifact.PomType)
/** BOM (Bill of Materials) deps: output under &lt;dependencyManagement&gt; with type pom, scope import (sbt#4531). */
def makeDependencyManagement(dependencies: Seq[DependencyDescriptor]): NodeSeq =
if (dependencies.isEmpty)
NodeSeq.Empty
else
<dependencyManagement>
<dependencies>
{dependencies.map(makeBomDependencyElem)}
</dependencies>
</dependencyManagement>
def makeBomDependencyElem(dependency: DependencyDescriptor): Elem = {
val mrid = dependency.getDependencyRevisionId
<dependency>
<groupId>{mrid.getOrganisation}</groupId>
<artifactId>{mrid.getName}</artifactId>
<version>{makeDependencyVersion(mrid.getRevision)}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
}
def makeDependencies(
dependencies: Seq[DependencyDescriptor],
includeTypes: Set[String],
@ -301,16 +333,22 @@ class MakePom(val log: Logger) {
excludes: Seq[ExcludeRule]
): Elem = {
val mrid = dependency.getDependencyRevisionId
<dependency>
<groupId>{mrid.getOrganisation}</groupId>
<artifactId>{mrid.getName}</artifactId>
<version>{makeDependencyVersion(mrid.getRevision)}</version>
{scopeElem(scope)}
{optionalElem(optional)}
{classifierElem(classifier)}
{typeElem(tpe)}
{exclusions(dependency, excludes)}
</dependency>
val rev = mrid.getRevision
val versionNode: NodeSeq =
if (rev == null || rev == "*" || rev.isEmpty) NodeSeq.Empty
else <version>{makeDependencyVersion(rev)}</version>
val result: Elem =
<dependency>
<groupId>{mrid.getOrganisation}</groupId>
<artifactId>{mrid.getName}</artifactId>
{versionNode}
{scopeElem(scope)}
{optionalElem(optional)}
{classifierElem(classifier)}
{typeElem(tpe)}
{exclusions(dependency, excludes)}
</dependency>
result
}
def artifactType(artifact: DependencyArtifactDescriptor): Option[String] =

View File

@ -2973,6 +2973,24 @@ object Classpaths {
deliver := deliverTask(makeIvyXmlConfiguration).value,
deliverLocal := deliverTask(makeIvyXmlLocalConfiguration).value,
makeIvyXml := deliverTask(makeIvyXmlConfiguration).value,
resolvedDependencies := Def.task {
val report = update.value
val deps = allDependencies.value
val starDeps = deps.filter(d => d.revision == "*" || d.revision.isEmpty)
val compileModules = report.configurations
.find(_.configuration.name == "compile")
.toVector
.flatMap(_.modules)
starDeps.flatMap { d =>
compileModules
.find(m =>
m.module.organization == d.organization &&
m.module.name == d.name &&
!m.evicted
)
.map(m => d.withRevision(m.module.revision))
}.distinct
}.value,
publish := publishOrSkip(publishConfiguration, publish / skip).value,
publishLocal := LibraryManagement.ivylessPublishLocalTask.value,
publishM2 := publishOrSkip(publishM2Configuration, publishM2 / skip).value,

View File

@ -566,6 +566,8 @@ object Keys {
val deliverLocal = taskKey[File]("Generates the Ivy file for publishing to the local repository.").withRank(BTask)
// makeIvyXml is currently identical to the confusingly-named "deliver", which may be deprecated in the future
val makeIvyXml = taskKey[File]("Generates the Ivy file for publishing to a repository.").withRank(BTask)
/** BOM-resolved ModuleIDs for deps that had \"*\" (version from BOM); emitted as forced deps in published ivy.xml (sbt#4531). */
val resolvedDependencies = taskKey[Seq[ModuleID]]("")
val publish = taskKey[Unit]("Publishes artifacts to a repository.").withRank(APlusTask)
val publishLocal = taskKey[Unit]("Publishes artifacts to the local Ivy repository.").withRank(APlusTask)
val publishM2 = taskKey[Unit]("Publishes artifacts to the local Maven repository.").withRank(ATask)

View File

@ -153,6 +153,7 @@ object LMCoursier {
val sv = scalaVersion.value
val lockFile = dependencyLockFile.value
val lockFileOpt = if (lockFile.exists()) Some(lockFile) else None
val ivyHomeOpt = ivyPaths.value.ivyHome.map(new File(_))
coursierConfiguration(
csrRecursiveResolvers.value,
csrInterProjectDependencies.value.toVector,
@ -170,7 +171,7 @@ object LMCoursier {
csrLogger.value,
csrCacheDirectory.value,
csrReconciliations.value,
ivyPaths.value.ivyHome.map(new File(_)),
ivyHomeOpt,
CoursierInputsTasks.strictTask.value,
dependencyOverrides.value,
Some(updateConfiguration.value),

View File

@ -26,7 +26,8 @@ object IvyXml {
private def rawContent(
currentProject: Project,
shadedConfigOpt: Option[Configuration]
shadedConfigOpt: Option[Configuration],
bomForcedDeps: Seq[(String, String, String)]
): String = {
// Important: width = Int.MaxValue, so that no tag gets truncated.
@ -38,7 +39,7 @@ object IvyXml {
val printer = new scala.xml.PrettyPrinter(Int.MaxValue, 2)
"""<?xml version="1.0" encoding="UTF-8"?>""" + '\n' +
printer.format(content(currentProject, shadedConfigOpt))
printer.format(content(currentProject, shadedConfigOpt, bomForcedDeps))
}
// These are required for publish to be fine, later on.
@ -46,8 +47,10 @@ object IvyXml {
currentProject: Project,
shadedConfigOpt: Option[Configuration],
ivySbt: IvySbt,
log: sbt.util.Logger
log: sbt.util.Logger,
resolvedDeps: Seq[sbt.librarymanagement.ModuleID]
): Unit = {
val bomForcedDeps = resolvedDeps.map(m => (m.organization, m.name, m.revision))
val ivyCacheManager = ivySbt.withIvy(log)(ivy => ivy.getResolutionCacheManager)
@ -61,7 +64,7 @@ object IvyXml {
val cacheIvyFile = ivyCacheManager.getResolvedIvyFileInCache(ivyModule)
val cacheIvyPropertiesFile = ivyCacheManager.getResolvedIvyPropertiesInCache(ivyModule)
val content0 = rawContent(currentProject, shadedConfigOpt)
val content0 = rawContent(currentProject, shadedConfigOpt, bomForcedDeps)
cacheIvyFile.getParentFile.mkdirs()
log.debug(s"writing Ivy file $cacheIvyFile")
Files.write(cacheIvyFile.toPath, content0.getBytes(UTF_8))
@ -72,7 +75,11 @@ object IvyXml {
()
}
private def content(project0: Project, shadedConfigOpt: Option[Configuration]): Node = {
private def content(
project0: Project,
shadedConfigOpt: Option[Configuration],
bomForcedDeps: Seq[(String, String, String)]
): Node = {
val filterOutDependencies =
shadedConfigOpt.toSet[Configuration].flatMap { shadedConfig =>
@ -144,6 +151,7 @@ object IvyXml {
n
}
val bomForcedSet = bomForcedDeps.toSet
val dependencyElems = project.dependencies.toVector.map { (conf, dep) =>
val classifier = {
val pub = dep.publication
@ -165,10 +173,18 @@ object IvyXml {
} name="*" type="*" ext="*" conf="" matcher="exact"/>
}
val org0 = dep.module.organization.value
val name0 = dep.module.name.value
val rev0 = dep.version
val forced = bomForcedSet((org0, name0, rev0))
val forceAttr =
if (forced) new scala.xml.UnprefixedAttribute("force", "true", scala.xml.Null)
else scala.xml.Null
val n =
<dependency org={dep.module.organization.value} name={dep.module.name.value} rev={
dep.version
} conf={s"${conf.value}->${dep.configuration.value}"}>
<dependency org={org0} name={name0} rev={rev0} conf={
s"${conf.value}->${dep.configuration.value}"
}>
{classifier}
{excludes}
</dependency>
@ -178,7 +194,7 @@ object IvyXml {
new PrefixedAttribute("e", k, v, acc)
}
n % moduleAttrs
n % moduleAttrs % forceAttr
}
<ivy-module version="2.0" xmlns:e="http://ant.apache.org/ivy/extra">
@ -200,11 +216,13 @@ object IvyXml {
val publications = csrPublications.value
proj.withPublications(publications)
}
val resolved = sbt.Keys.resolvedDependencies.value
IvyXml.writeFiles(
currentProject,
shadedConfigOpt,
sbt.Keys.ivySbt.value,
sbt.Keys.streams.value.log
sbt.Keys.streams.value.log,
resolved
)
}
}.value)

View File

@ -0,0 +1,38 @@
// BOM + publishLocal (sbt#4531): a uses BOM + jackson-core "*"; b depends on a.
// For publishLocal, a's published ivy may still list jackson-core:*; so b also needs the BOM
// to resolve that transitive * (per eed3si9n: BOM needs to be added to all subprojects).
// Use `common,` not `common*`—compiler's vararg hint is misleading; sbt accepts Seq here.
ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache"
ThisBuild / organization := "org.example"
ThisBuild / version := "1.0"
ThisBuild / scalaVersion := "2.12.18"
lazy val a = project
.settings(
common,
libraryDependencies += ("com.fasterxml.jackson" % "jackson-bom" % "2.21.0").pomOnly(),
libraryDependencies += "com.fasterxml.jackson.core" % "jackson-core" % "*",
)
lazy val b = project
.settings(
common,
libraryDependencies += ("com.fasterxml.jackson" % "jackson-bom" % "2.21.0").pomOnly(),
libraryDependencies += organization.value %% "a" % version.value,
TaskKey[Unit]("checkBomFromA") := {
val report = (Compile / updateFull).value
val compileConfig = report.configurations.find(_.configuration.name == "compile").getOrElse(
sys.error("compile configuration not found")
)
val jacksonCore = compileConfig.modules.find(_.module.name == "jackson-core").getOrElse(
sys.error("jackson-core not found in update report (expected from a's published ivy)")
)
val expected = "2.21.0"
if (jacksonCore.module.revision != expected)
sys.error(s"Expected jackson-core $expected from a's BOM-resolved ivy, got ${jacksonCore.module.revision}")
},
)
lazy val common = Seq(
ivyPaths := IvyPaths(baseDirectory.value.toString, Some(((ThisBuild / baseDirectory).value / "ivy" / "cache").toString)),
)

View File

@ -0,0 +1,3 @@
> a/publishLocal
> b/update
> b/checkBomFromA

View File

@ -0,0 +1,22 @@
ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache"
// BOM (Bill of Materials) consumption: .pomOnly() + version "*" (sbt#4531)
scalaVersion := "2.12.18"
libraryDependencies ++= Seq(
("com.fasterxml.jackson" % "jackson-bom" % "2.17.0").pomOnly(),
"com.fasterxml.jackson.core" % "jackson-core" % "*"
)
TaskKey[Unit]("checkBomResolved") := {
val report = (Compile / updateFull).value
val compileConfig = report.configurations.find(_.configuration.name == "compile").getOrElse(
sys.error("compile configuration not found")
)
val jacksonCoreReport = compileConfig.modules.find(_.module.name == "jackson-core").getOrElse(
sys.error("jackson-core not found in update report")
)
val expectedVersion = "2.17.0"
if (jacksonCoreReport.module.revision != expectedVersion)
sys.error(s"Expected jackson-core version $expectedVersion from BOM, got ${jacksonCoreReport.module.revision}")
}

View File

@ -0,0 +1,2 @@
> update
> checkBomResolved

View File

@ -1,6 +1,6 @@
lazy val p1 = (project in file("p1")).
settings(
checkTask(expectedMongo),
checkTask(expectedMongo, fromDependencyManagement = true),
libraryDependencies += ("org.mongodb" %% "casbah" % "2.4.1").pomOnly(),
inThisBuild(List(
organization := "org.example",
@ -16,12 +16,14 @@ lazy val p2 = (project in file("p2")).
checkTask(expectedInter)
)
// BOM (sbt#4531): .pomOnly() deps are emitted under <dependencyManagement> with type=pom, scope=import
lazy val expectedMongo =
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>casbah_2.9.2</artifactId>
<version>2.4.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
lazy val expectedInter =
@ -31,15 +33,15 @@ lazy val expectedInter =
<version>1.0</version>
</dependency>
def checkTask(expectedDep: xml.Elem) = TaskKey[Unit]("checkPom") := {
def checkTask(expectedDep: xml.Elem, fromDependencyManagement: Boolean = false) = TaskKey[Unit]("checkPom") := {
val vf = makePom.value
val converter = fileConverter.value
val pom = xml.XML.loadFile(converter.toPath(vf).toFile)
val actual = pom \\ "dependencies"
val actual = if (fromDependencyManagement) pom \ "dependencyManagement" \ "dependencies" else pom \ "dependencies"
val expected = <d>
{expectedDep}
</d>
def dropTopElem(s:String): String = s.split("""\n""").drop(1).dropRight(1).mkString("\n")
def dropTopElem(s: String): String = s.split("""\n""").drop(1).dropRight(1).mkString("\n")
val pp = new xml.PrettyPrinter(Int.MaxValue, 0)
val expectedString = dropTopElem(pp.format(expected))
val actualString = dropTopElem(pp.formatNodes(actual))