diff --git a/main/src/main/scala/sbt/internal/LibraryManagement.scala b/main/src/main/scala/sbt/internal/LibraryManagement.scala index 172401c83..73e5f3295 100644 --- a/main/src/main/scala/sbt/internal/LibraryManagement.scala +++ b/main/src/main/scala/sbt/internal/LibraryManagement.scala @@ -12,11 +12,13 @@ package internal import java.io.{ File, IOException } import java.net.{ URI, URL } import java.util.concurrent.Callable +import java.util.regex.Matcher import gigahorse.AuthScheme import gigahorse.support.apachehttp.Gigahorse import sbt.Def.ScopedKey import sbt.internal.librarymanagement.* +import sbt.internal.librarymanagement.mavenint.PomExtraDependencyAttributes import sbt.librarymanagement.* import sbt.librarymanagement.syntax.* import sbt.util.{ CacheStore, CacheStoreFactory, Level, Logger, Tracked } @@ -549,6 +551,11 @@ private[sbt] object LibraryManagement { version == "3-latest.candidate" } + private def pluginCrossPath(project: CsrProject): Seq[String] = + val attrs = project.module.attributes + attrs.get(PomExtraDependencyAttributes.ScalaVersionKey).map("scala_" + _).toSeq ++ + attrs.get(PomExtraDependencyAttributes.SbtVersionKey).map("sbt_" + _).toSeq + /** * Publishes artifacts to the local Ivy repository without using Apache Ivy. * Uses the pattern: [org]/[module]/[revision]/[types]/[artifact](-[classifier]).[ext] @@ -565,8 +572,9 @@ private[sbt] object LibraryManagement { val moduleName = project.module.name.value val version = project.version - // Base directory: localRepoBase / org / module / version - val moduleDir = localRepoBase / org / moduleName / version + // Base directory: localRepoBase / org / module / (scala_V/)(sbt_V/) / version + val moduleDir = + pluginCrossPath(project).foldLeft(localRepoBase / org / moduleName)(_ / _) / version log.info(s"Publishing to $moduleDir") @@ -636,6 +644,7 @@ private[sbt] object LibraryManagement { */ private def substituteIvyArtifactPattern( pattern: String, + project: CsrProject, org: String, moduleName: String, version: String, @@ -653,8 +662,18 @@ private[sbt] object LibraryManagement { s = s.replace("[ext]", ext) if (classifier.nonEmpty) s = s.replace("(-[classifier])", s"-$classifier") else s = s.replace("(-[classifier])", "") - // Remove optional Ivy pattern parts (scala/sbt version, branch) for ivyless layout - s = s.replaceAll("\\(scala_[^)]+/\\)", "").replaceAll("\\(sbt_[^)]+/\\)", "") + // Substitute or drop optional Ivy pattern parts (scala/sbt version), remove branch for ivyless layout + val attrs = project.module.attributes + val scalaV = attrs.get(PomExtraDependencyAttributes.ScalaVersionKey) + val sbtV = attrs.get(PomExtraDependencyAttributes.SbtVersionKey) + s = s.replaceAll( + "\\(scala_[^)]+/\\)", + scalaV.map(v => Matcher.quoteReplacement(s"scala_$v/")).getOrElse("") + ) + s = s.replaceAll( + "\\(sbt_[^)]+/\\)", + sbtV.map(v => Matcher.quoteReplacement(s"sbt_$v/")).getOrElse("") + ) s = s.replaceAll("\\(\\[branch\\]/\\)", "") s } @@ -748,6 +767,7 @@ private[sbt] object LibraryManagement { val artifactName = moduleName val pathPattern = substituteIvyArtifactPattern( artifactPattern, + project, org, moduleName, version, @@ -769,6 +789,7 @@ private[sbt] object LibraryManagement { val ivyXmlContent = lmcoursier.IvyXml(project, Nil, Nil) val ivyPathPattern = substituteIvyArtifactPattern( ivyPattern, + project, org, moduleName, version, diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/build.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/build.sbt new file mode 100644 index 000000000..5c2826103 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/build.sbt @@ -0,0 +1,100 @@ +ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache" + +val publishRepoBase = settingKey[File]("Base directory for publish repo (HTTP server writes here)") + +val publishPort = 3032 + +lazy val root = (project in file(".")) + .enablePlugins(SbtPlugin) + .settings( + name := "sbt-test-plugin", + organization := "com.example", + version := "0.1.0-SNAPSHOT", + scalaVersion := "2.12.21", + pluginCrossBuild / sbtVersion := "1.5.8", + publishRepoBase := baseDirectory.value / "repo", + publishTo := Some( + Resolver + .url("test-repo", new java.net.URI(s"http://localhost:$publishPort/").toURL)(using + Resolver.ivyStylePatterns + ) + .withAllowInsecureProtocol(true) + ), + useIvy := false, + Compile / packageDoc / publishArtifact := false, + Compile / packageSrc / publishArtifact := false, + ) + +val startPublishServer = taskKey[Unit]("Start HTTP server that accepts PUT to repo directory") +startPublishServer := { + HttpPutServer.start(publishPort, publishRepoBase.value) + streams.value.log.info(s"HTTP PUT server started on port $publishPort, writing to ${publishRepoBase.value}") +} + +val stopPublishServer = taskKey[Unit]("Stop HTTP server") +stopPublishServer := { + HttpPutServer.stop() + streams.value.log.info("HTTP server stopped") +} + +val publishToHttp = taskKey[Unit]("Publish to HTTP server (start server, publish, stop server)") +publishToHttp := { + startPublishServer.value + try publish.value + finally stopPublishServer.value +} + +val checkPublished = taskKey[Unit]("Check ivyless publish wrote the plugin cross-suffix PUT path") +checkPublished := { + val log = streams.value.log + val base = publishRepoBase.value + val org = organization.value + val moduleName = normalizedName.value + val ver = version.value + + val moduleDir = base / org / moduleName / "scala_2.12" / "sbt_1.0" / ver + log.info(s"Checking published files in $moduleDir") + + def listDir(dir: File, indent: String = ""): Unit = + if (dir.exists) + dir.listFiles.foreach { f => + log.info(s"$indent${f.getName}") + if (f.isDirectory) listDir(f, indent + " ") + } + else log.info(s"${indent}Directory does not exist: $dir") + log.info("Contents of publish repo:") + listDir(base) + + assert(moduleDir.isDirectory, s"Expected plugin cross-suffix directory $moduleDir to exist") + + val expectedDirs = Seq("jars", "poms", "ivys") + expectedDirs.foreach { dir => + val d = moduleDir / dir + assert(d.exists && d.isDirectory, s"Expected directory $d to exist") + } + + val jarFile = moduleDir / "jars" / s"$moduleName.jar" + assert(jarFile.exists, s"Expected $jarFile to exist") + assert((moduleDir / "jars" / s"$moduleName.jar.md5").exists, s"Expected md5 checksum to exist") + assert((moduleDir / "jars" / s"$moduleName.jar.sha1").exists, s"Expected sha1 checksum to exist") + + val ivyFile = moduleDir / "ivys" / "ivy.xml" + assert(ivyFile.exists, s"Expected $ivyFile to exist") + assert((moduleDir / "ivys" / "ivy.xml.md5").exists, s"Expected ivy.xml md5 checksum to exist") + assert((moduleDir / "ivys" / "ivy.xml.sha1").exists, s"Expected ivy.xml sha1 checksum to exist") + + val ivyContent = IO.read(ivyFile) + assert(ivyContent.contains(s"""organisation="$org""""), s"ivy.xml should contain organisation") + assert(ivyContent.contains(s"""module="$moduleName""""), s"ivy.xml should contain module name") + assert(ivyContent.contains(s"""revision="$ver""""), s"ivy.xml should contain revision") + + val nonCrossDir = base / org / moduleName / ver + assert(!nonCrossDir.exists, s"Non-cross directory $nonCrossDir should not exist; cross suffixes are missing") + + log.info("All ivyless publish (HTTP plugin) checks passed!") +} + +val cleanPublishRepo = taskKey[Unit]("Clean the publish repo") +cleanPublishRepo := { + IO.delete(publishRepoBase.value) +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/project/HttpPutServer.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/project/HttpPutServer.scala new file mode 100644 index 000000000..ce0526ceb --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/project/HttpPutServer.scala @@ -0,0 +1,46 @@ +import java.io._ +import java.net.InetSocketAddress +import com.sun.net.httpserver.{ HttpExchange, HttpHandler, HttpServer } + +/** Minimal HTTP server that accepts PUT and writes to a base directory (for ivyless publish scripted test). */ +object HttpPutServer { + private var server: HttpServer = null + + def start(port: Int, baseDir: java.io.File): Unit = { + if (server != null) stop() + server = HttpServer.create(new InetSocketAddress(port), 0) + server.createContext("/", new HttpHandler { + override def handle(ex: HttpExchange): Unit = { + val method = ex.getRequestMethod + if ("PUT".equalsIgnoreCase(method)) { + val path = ex.getRequestURI.getRawPath + val relativePath = if (path.startsWith("/")) path.substring(1) else path + val targetFile = new File(baseDir, relativePath.replace("/", File.separator)) + targetFile.getParentFile.mkdirs() + val in = ex.getRequestBody + val out = new FileOutputStream(targetFile) + try { + in.transferTo(out) + } finally { + out.close() + in.close() + } + ex.sendResponseHeaders(200, -1) + ex.close() + } else { + ex.sendResponseHeaders(405, -1) + ex.close() + } + } + }) + server.setExecutor(null) + server.start() + } + + def stop(): Unit = { + if (server != null) { + server.stop(0) + server = null + } + } +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/project/plugins.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/project/plugins.sbt new file mode 100644 index 000000000..adc5e970b --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/project/plugins.sbt @@ -0,0 +1 @@ +// empty plugins file diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/src/main/scala/com/example/Plugin.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/src/main/scala/com/example/Plugin.scala new file mode 100644 index 000000000..0f43a637d --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/src/main/scala/com/example/Plugin.scala @@ -0,0 +1,5 @@ +package com.example + +object Plugin { + def hello: String = "Hello from sbt 1 plugin!" +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/test b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/test new file mode 100644 index 000000000..0cb2abf74 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-http-plugin/test @@ -0,0 +1,7 @@ +# Test ivyless publish to HTTP Ivy-style repo for an sbt 1 plugin (issue #9383) +# When useIvy is false, the Ivy-pattern PUT path must include the +# scala_2.12/sbt_1.0 cross suffixes for sbt 1 plugins. + +> cleanPublishRepo +> publishToHttp +> checkPublished diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/build.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/build.sbt new file mode 100644 index 000000000..0229a5d2d --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/build.sbt @@ -0,0 +1,73 @@ +ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache" + +val ivyLocalBase = settingKey[File]("Local Ivy repository base") + +lazy val root = (project in file(".")) + .enablePlugins(SbtPlugin) + .settings( + name := "sbt-test-plugin", + organization := "com.example", + version := "0.1.0-SNAPSHOT", + scalaVersion := "2.12.21", + pluginCrossBuild / sbtVersion := "1.5.8", + ivyLocalBase := baseDirectory.value / "local-ivy-repo", + ivyPaths := IvyPaths(baseDirectory.value.toString, Some(ivyLocalBase.value.toString)), + useIvy := false, + Compile / packageDoc / publishArtifact := false, + Compile / packageSrc / publishArtifact := false, + ) + +val cleanLocalIvy = taskKey[Unit]("Clean the local ivy repo") +cleanLocalIvy := { + IO.delete(ivyLocalBase.value / "local") +} + +val checkPublished = taskKey[Unit]("Check ivyless publishLocal wrote the plugin cross-suffix path") +checkPublished := { + val log = streams.value.log + val base = ivyLocalBase.value / "local" + val org = organization.value + val moduleName = normalizedName.value + val ver = version.value + + val moduleDir = base / org / moduleName / "scala_2.12" / "sbt_1.0" / ver + log.info(s"Checking published files in $moduleDir") + + def listDir(dir: File, indent: String = ""): Unit = + if (dir.exists) + dir.listFiles.foreach { f => + log.info(s"$indent${f.getName}") + if (f.isDirectory) listDir(f, indent + " ") + } + else log.info(s"${indent}Directory does not exist: $dir") + log.info("Contents of ivyLocalBase:") + listDir(ivyLocalBase.value) + + assert(moduleDir.isDirectory, s"Expected plugin cross-suffix directory $moduleDir to exist") + + val expectedDirs = Seq("jars", "poms", "ivys") + expectedDirs.foreach { dir => + val d = moduleDir / dir + assert(d.exists && d.isDirectory, s"Expected directory $d to exist") + } + + val jarFile = moduleDir / "jars" / s"$moduleName.jar" + assert(jarFile.exists, s"Expected $jarFile to exist") + assert((moduleDir / "jars" / s"$moduleName.jar.md5").exists, s"Expected md5 checksum to exist") + assert((moduleDir / "jars" / s"$moduleName.jar.sha1").exists, s"Expected sha1 checksum to exist") + + val ivyFile = moduleDir / "ivys" / "ivy.xml" + assert(ivyFile.exists, s"Expected $ivyFile to exist") + assert((moduleDir / "ivys" / "ivy.xml.md5").exists, s"Expected ivy.xml md5 checksum to exist") + assert((moduleDir / "ivys" / "ivy.xml.sha1").exists, s"Expected ivy.xml sha1 checksum to exist") + + val ivyContent = IO.read(ivyFile) + assert(ivyContent.contains(s"""organisation="$org""""), s"ivy.xml should contain organisation") + assert(ivyContent.contains(s"""module="$moduleName""""), s"ivy.xml should contain module name") + assert(ivyContent.contains(s"""revision="$ver""""), s"ivy.xml should contain revision") + + val nonCrossDir = base / org / moduleName / ver + assert(!nonCrossDir.exists, s"Non-cross directory $nonCrossDir should not exist; cross suffixes are missing") + + log.info("All ivyless publishLocal plugin checks passed!") +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/project/plugins.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/project/plugins.sbt new file mode 100644 index 000000000..adc5e970b --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/project/plugins.sbt @@ -0,0 +1 @@ +// empty plugins file diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/src/main/scala/com/example/Plugin.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/src/main/scala/com/example/Plugin.scala new file mode 100644 index 000000000..0f43a637d --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/src/main/scala/com/example/Plugin.scala @@ -0,0 +1,5 @@ +package com.example + +object Plugin { + def hello: String = "Hello from sbt 1 plugin!" +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/test b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/test new file mode 100644 index 000000000..fbc608d0d --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local-plugin/test @@ -0,0 +1,7 @@ +# Test ivyless publishLocal for an sbt 1 plugin (issue #9383) +# When useIvy is false, publishLocal must write artifacts under the +# scala_2.12/sbt_1.0 cross-suffix path, matching the Ivy PluginPattern. + +> cleanLocalIvy +> publishLocal +> checkPublished