diff --git a/build.sbt b/build.sbt index 3b5694bc7..ef647f110 100644 --- a/build.sbt +++ b/build.sbt @@ -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"), diff --git a/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala b/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala index f0334c543..87e4fb3f1 100644 --- a/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala +++ b/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala @@ -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 = diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala index b1e6aab50..784d58e31 100644 --- a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala +++ b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala @@ -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.## diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala index d74bfbd21..28506498b 100644 --- a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala +++ b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala @@ -99,6 +99,7 @@ object ResolutionRun { dep } ) + .withBoms(params.boms) .withRepositories(repositories) .withResolutionParams( params.params diff --git a/lm-ivy/src/main/scala/sbt/internal/librarymanagement/MakePom.scala b/lm-ivy/src/main/scala/sbt/internal/librarymanagement/MakePom.scala index 17246e699..819e8fe57 100644 --- a/lm-ivy/src/main/scala/sbt/internal/librarymanagement/MakePom.scala +++ b/lm-ivy/src/main/scala/sbt/internal/librarymanagement/MakePom.scala @@ -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)} ) @@ -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 <dependencyManagement> with type pom, scope import (sbt#4531). */ + def makeDependencyManagement(dependencies: Seq[DependencyDescriptor]): NodeSeq = + if (dependencies.isEmpty) + NodeSeq.Empty + else + + + {dependencies.map(makeBomDependencyElem)} + + + + def makeBomDependencyElem(dependency: DependencyDescriptor): Elem = { + val mrid = dependency.getDependencyRevisionId + + {mrid.getOrganisation} + {mrid.getName} + {makeDependencyVersion(mrid.getRevision)} + pom + import + + } + 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 - - {mrid.getOrganisation} - {mrid.getName} - {makeDependencyVersion(mrid.getRevision)} - {scopeElem(scope)} - {optionalElem(optional)} - {classifierElem(classifier)} - {typeElem(tpe)} - {exclusions(dependency, excludes)} - + val rev = mrid.getRevision + val versionNode: NodeSeq = + if (rev == null || rev == "*" || rev.isEmpty) NodeSeq.Empty + else {makeDependencyVersion(rev)} + val result: Elem = + + {mrid.getOrganisation} + {mrid.getName} + {versionNode} + {scopeElem(scope)} + {optionalElem(optional)} + {classifierElem(classifier)} + {typeElem(tpe)} + {exclusions(dependency, excludes)} + + result } def artifactType(artifact: DependencyArtifactDescriptor): Option[String] = diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 78c19c441..abe96430b 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -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, diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 855f5c467..c0e2049f8 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -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) diff --git a/main/src/main/scala/sbt/coursierint/LMCoursier.scala b/main/src/main/scala/sbt/coursierint/LMCoursier.scala index 46bfd8b91..685481bf8 100644 --- a/main/src/main/scala/sbt/coursierint/LMCoursier.scala +++ b/main/src/main/scala/sbt/coursierint/LMCoursier.scala @@ -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), diff --git a/main/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala b/main/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala index 00635addc..4c61b40a5 100644 --- a/main/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala +++ b/main/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala @@ -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) """""" + '\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 = - ${dep.configuration.value}"}> + ${dep.configuration.value}" + }> {classifier} {excludes} @@ -178,7 +194,7 @@ object IvyXml { new PrefixedAttribute("e", k, v, acc) } - n % moduleAttrs + n % moduleAttrs % forceAttr } @@ -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) diff --git a/sbt-app/src/sbt-test/dependency-management/bom-publish-local/a/src/main/scala/A.scala b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/a/src/main/scala/A.scala new file mode 100644 index 000000000..69c493db2 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/a/src/main/scala/A.scala @@ -0,0 +1 @@ +object A diff --git a/sbt-app/src/sbt-test/dependency-management/bom-publish-local/b/src/main/scala/B.scala b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/b/src/main/scala/B.scala new file mode 100644 index 000000000..251ef7397 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/b/src/main/scala/B.scala @@ -0,0 +1 @@ +object B diff --git a/sbt-app/src/sbt-test/dependency-management/bom-publish-local/build.sbt b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/build.sbt new file mode 100644 index 000000000..29793fdbd --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/build.sbt @@ -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)), +) diff --git a/sbt-app/src/sbt-test/dependency-management/bom-publish-local/test b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/test new file mode 100644 index 000000000..f78b5bc93 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/test @@ -0,0 +1,3 @@ +> a/publishLocal +> b/update +> b/checkBomFromA diff --git a/sbt-app/src/sbt-test/dependency-management/bom/build.sbt b/sbt-app/src/sbt-test/dependency-management/bom/build.sbt new file mode 100644 index 000000000..4b8888d3a --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/bom/build.sbt @@ -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}") +} diff --git a/sbt-app/src/sbt-test/dependency-management/bom/test b/sbt-app/src/sbt-test/dependency-management/bom/test new file mode 100644 index 000000000..5e1b5cb61 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/bom/test @@ -0,0 +1,2 @@ +> update +> checkBomResolved diff --git a/sbt-app/src/sbt-test/dependency-management/make-pom-type/build.sbt b/sbt-app/src/sbt-test/dependency-management/make-pom-type/build.sbt index f216d47d8..664981e17 100644 --- a/sbt-app/src/sbt-test/dependency-management/make-pom-type/build.sbt +++ b/sbt-app/src/sbt-test/dependency-management/make-pom-type/build.sbt @@ -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 with type=pom, scope=import lazy val expectedMongo = org.mongodb casbah_2.9.2 2.4.1 pom + import lazy val expectedInter = @@ -31,15 +33,15 @@ lazy val expectedInter = 1.0 -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 = {expectedDep} - 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))