diff --git a/main/src/main/scala/sbt/internal/LibraryManagement.scala b/main/src/main/scala/sbt/internal/LibraryManagement.scala
index fe82db2ac..23239a51d 100644
--- a/main/src/main/scala/sbt/internal/LibraryManagement.scala
+++ b/main/src/main/scala/sbt/internal/LibraryManagement.scala
@@ -650,6 +650,19 @@ private[sbt] object LibraryManagement {
s
}
+ /**
+ * Picks credentials for a URL. Matches host; when realm is given, prefers credential with matching realm (per Publishing docs).
+ */
+ private def credentialFor(
+ url: URL,
+ credentials: Seq[Credentials.DirectCredentials],
+ realm: Option[String] = None
+ ): Option[Credentials.DirectCredentials] =
+ val byHost = credentials.filter(_.host == url.getHost)
+ realm match
+ case Some(r) => byHost.find(_.realm == r).orElse(byHost.headOption)
+ case None => byHost.headOption
+
/**
* HTTP PUT a file to a URL with optional Basic auth.
* Uses Gigahorse (Apache HttpClient) per sbt tech stack.
@@ -659,22 +672,19 @@ private[sbt] object LibraryManagement {
sourceFile: File,
credentials: Option[Credentials.DirectCredentials],
log: Logger
- ): Unit = {
+ ): Unit =
val baseReq = Gigahorse.url(url.toString).put(sourceFile)
- val req = credentials.filter(_.host == url.getHost) match {
+ val req = credentials match
case Some(dc) => baseReq.withAuth(dc.userName, dc.passwd, AuthScheme.Basic)
case None => baseReq
- }
val f = sbt.librarymanagement.Http.http.processFull(req)
val response = Await.result(f, 5.minutes)
- if (response.status < 200 || response.status >= 300) {
- val body = response.bodyAsString
+ val body = response.bodyAsString
+ if response.status < 200 || response.status >= 300 then
throw new IOException(
s"PUT $url failed: ${response.status} ${response.statusText}$body"
)
- }
log.info(s"Published $url")
- }
/**
* Publishes artifacts to a remote Ivy repo (URLRepository) without using Apache Ivy.
@@ -738,11 +748,11 @@ private[sbt] object LibraryManagement {
artifact.extension
)
val url = URI.create(pathPattern).toURL()
- httpPut(url, sourceFile, directCreds.find(_.host == url.getHost), log)
+ httpPut(url, sourceFile, credentialFor(url, directCreds, None), log)
val checksums = writeChecksums(sourceFile)
checksums.foreach { case (cf, suffix) =>
val checksumUrl = URI.create(pathPattern + suffix).toURL()
- try httpPut(checksumUrl, cf, directCreds.find(_.host == url.getHost), log)
+ try httpPut(checksumUrl, cf, credentialFor(checksumUrl, directCreds, None), log)
finally cf.delete()
}
}
@@ -762,16 +772,131 @@ private[sbt] object LibraryManagement {
val ivyTmp = File.createTempFile("ivy", ".xml")
try {
IO.write(ivyTmp, ivyXmlContent)
- httpPut(ivyUrl, ivyTmp, directCreds.find(_.host == ivyUrl.getHost), log)
+ httpPut(ivyUrl, ivyTmp, credentialFor(ivyUrl, directCreds, None), log)
val checksums = writeChecksums(ivyTmp)
checksums.foreach { case (cf, suffix) =>
val checksumUrl = URI.create(ivyPathPattern + suffix).toURL()
- try httpPut(checksumUrl, cf, directCreds.find(_.host == ivyUrl.getHost), log)
+ try httpPut(checksumUrl, cf, credentialFor(checksumUrl, directCreds, None), log)
finally cf.delete()
}
} finally ivyTmp.delete()
}
+ /**
+ * Maven layout path: groupId/artifactId/version/artifactId-version[-classifier].ext
+ */
+ private def mavenLayoutPath(
+ groupId: String,
+ artifactId: String,
+ version: String,
+ artifact: Artifact
+ ): String =
+ val groupPath = groupId.replace('.', '/')
+ val classifierPart = artifact.classifier.map("-" + _).getOrElse("")
+ val fileName = s"$artifactId-$version$classifierPart.${artifact.extension}"
+ s"$groupPath/$artifactId/$version/$fileName"
+
+ private def writeChecksumsForFile(
+ targetFile: File,
+ algorithms: Vector[String],
+ log: Logger
+ ): Unit =
+ algorithms.foreach: algo =>
+ val digestAlgo = algo.toLowerCase match
+ case "md5" => sbt.util.Digest.Md5
+ case "sha1" => sbt.util.Digest.Sha1
+ case other =>
+ throw new IllegalArgumentException(s"Unsupported checksum algorithm: $other")
+ val digest = sbt.util.Digest(digestAlgo, targetFile.toPath)
+ val checksumFile = new File(targetFile.getPath + "." + algo.toLowerCase)
+ IO.write(checksumFile, digest.hashHexString)
+ log.debug(s"Wrote checksum: $checksumFile")
+
+ /**
+ * Publishes artifacts to a local Maven repo (Maven layout) without using Apache Ivy.
+ * Layout: groupId/artifactId/version/artifactId-version[-classifier].ext
+ */
+ def ivylessPublishMavenToFile(
+ project: CsrProject,
+ artifacts: Vector[(Artifact, File)],
+ checksumAlgorithms: Vector[String],
+ repoBase: File,
+ overwrite: Boolean,
+ log: Logger
+ ): Unit =
+ if repoBase == null then throw new IllegalArgumentException("repoBase must not be null")
+ val groupId = project.module.organization.value
+ val artifactId = project.module.name.value
+ val version = project.version
+ val groupPath = groupId.replace('.', '/')
+ val versionDir = new File(repoBase, s"$groupPath/$artifactId/$version")
+ log.info(s"Publishing to Maven repo: $versionDir")
+
+ artifacts.foreach:
+ case (artifact, sourceFile) =>
+ val path = mavenLayoutPath(groupId, artifactId, version, artifact)
+ val targetFile = new File(repoBase, path.replace('/', File.separatorChar))
+ if !targetFile.exists || overwrite then
+ targetFile.getParentFile.mkdirs()
+ IO.copyFile(sourceFile, targetFile)
+ log.info(s"Published $targetFile")
+ writeChecksumsForFile(targetFile, checksumAlgorithms, log)
+ else log.warn(s"$targetFile already exists, skipping (overwrite=$overwrite)")
+
+ /**
+ * Publishes artifacts to a remote Maven repo (HTTP) without using Apache Ivy.
+ * Same layout as ivylessPublishMavenToFile; uses HTTP PUT with optional Basic auth.
+ */
+ def ivylessPublishMavenToUrl(
+ project: CsrProject,
+ artifacts: Vector[(Artifact, File)],
+ checksumAlgorithms: Vector[String],
+ baseUrl: String,
+ credentials: Seq[Credentials],
+ overwrite: Boolean,
+ log: Logger
+ ): Unit =
+ if baseUrl == null || baseUrl.trim.isEmpty then
+ throw new IllegalArgumentException("baseUrl must not be null or empty")
+ val groupId = project.module.organization.value
+ val artifactId = project.module.name.value
+ val version = project.version
+ val directCreds = credentials.collect:
+ case d: Credentials.DirectCredentials => d
+
+ def writeChecksums(file: File): Vector[(File, String)] =
+ checksumAlgorithms
+ .map: algo =>
+ val digestAlgo = algo.toLowerCase match
+ case "md5" => sbt.util.Digest.Md5
+ case "sha1" => sbt.util.Digest.Sha1
+ case other =>
+ throw new IllegalArgumentException(s"Unsupported checksum algorithm: $other")
+ val digest = sbt.util.Digest(digestAlgo, file.toPath)
+ val content = digest.hashHexString
+ val suffix = "." + algo.toLowerCase
+ val tmpFile = File.createTempFile("checksum", suffix)
+ IO.write(tmpFile, content)
+ (tmpFile, suffix)
+ .toVector
+
+ val base = baseUrl.stripSuffix("/") + "/"
+ artifacts.foreach:
+ case (artifact, sourceFile) =>
+ val path = mavenLayoutPath(groupId, artifactId, version, artifact)
+ val url = URI.create(base + path).toURL()
+ try
+ httpPut(url, sourceFile, credentialFor(url, directCreds, None), log)
+ val checksums = writeChecksums(sourceFile)
+ checksums.foreach:
+ case (cf, suffix) =>
+ val checksumUrl = URI.create(base + path + suffix).toURL()
+ try httpPut(checksumUrl, cf, credentialFor(checksumUrl, directCreds, None), log)
+ finally cf.delete()
+ catch
+ case e: IOException =>
+ throw new IOException(s"Failed to publish $path: ${e.getMessage}", e)
+
/**
* Publishes artifacts to a local file repo (FileRepository) without using Apache Ivy.
* Same layout as ivylessPublishLocal; used for testing without an HTTP server.
@@ -869,9 +994,46 @@ private[sbt] object LibraryManagement {
config.overwrite,
log
)
+ case mavenCache: sbt.librarymanagement.MavenCache =>
+ ivylessPublishMavenToFile(
+ project,
+ artifacts,
+ config.checksums,
+ mavenCache.rootFile,
+ config.overwrite,
+ log
+ )
+ case mavenRepo: sbt.librarymanagement.MavenRepo =>
+ val root = mavenRepo.root.stripSuffix("/")
+ if root.startsWith("http://") || root.startsWith("https://") then
+ val creds = allCredentials.value
+ ivylessPublishMavenToUrl(
+ project,
+ artifacts,
+ config.checksums,
+ root,
+ creds,
+ config.overwrite,
+ log
+ )
+ else if root.startsWith("file:") then
+ val repoBase = new File(URI.create(root))
+ ivylessPublishMavenToFile(
+ project,
+ artifacts,
+ config.checksums,
+ repoBase,
+ config.overwrite,
+ log
+ )
+ else
+ log.warn(s"Ivyless Maven publish: unsupported root '$root'. Falling back to Ivy.")
+ val conf = publishConfiguration.value
+ val module = ivyModule.value
+ publisher.value.publish(module, conf, log)
case _ =>
log.warn(
- "Ivyless publish only supports URLRepository (Resolver.url) or FileRepository (Resolver.file). Falling back to Ivy."
+ "Ivyless publish only supports URLRepository, FileRepository, or MavenRepository. Falling back to Ivy."
)
val conf = publishConfiguration.value
val module = ivyModule.value
diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/a/src/main/scala/Lib.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/a/src/main/scala/Lib.scala
new file mode 100644
index 000000000..4cb417e3c
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/a/src/main/scala/Lib.scala
@@ -0,0 +1,5 @@
+package com.example
+
+object Lib {
+ def hello: String = "Hello from a!"
+}
diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/b/src/main/scala/B.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/b/src/main/scala/B.scala
new file mode 100644
index 000000000..ee7a90103
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/b/src/main/scala/B.scala
@@ -0,0 +1,3 @@
+object B {
+ def msg: String = com.example.Lib.hello
+}
diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/build.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/build.sbt
new file mode 100644
index 000000000..d7c8ded1b
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/build.sbt
@@ -0,0 +1,97 @@
+ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache"
+ThisBuild / organization := "com.example"
+ThisBuild / version := "0.1.0-SNAPSHOT"
+ThisBuild / scalaVersion := "3.8.1"
+
+val publishRepoBase = settingKey[File]("Base directory for Maven publish repo (HTTP server writes here)")
+ThisBuild / publishRepoBase := (ThisBuild / baseDirectory).value / "repo"
+val publishPort = 3031
+
+lazy val root = (project in file("."))
+ .aggregate(a, b)
+ .settings(
+ publish / skip := true,
+ )
+
+lazy val a = project
+ .settings(
+ publishMavenStyle := true,
+ publishTo := Some(
+ sbt.librarymanagement.MavenRepo("test-repo", s"http://localhost:$publishPort/")
+ .withAllowInsecureProtocol(true)
+ ),
+ useIvy := false,
+ Compile / packageDoc / publishArtifact := false,
+ Compile / packageSrc / publishArtifact := false,
+ )
+
+lazy val b = project
+ .settings(
+ libraryDependencies += organization.value %% "a" % version.value,
+ resolvers += sbt.librarymanagement.MavenRepo(
+ "test-repo",
+ s"http://localhost:$publishPort/"
+ ).withAllowInsecureProtocol(true),
+ )
+
+val startPublishServer = taskKey[Unit]("Start HTTP server that accepts PUT to repo directory")
+Global / startPublishServer := {
+ HttpPutServer.start(publishPort, (ThisBuild / publishRepoBase).value)
+ streams.value.log.info(s"HTTP PUT server started on port $publishPort, writing to ${(ThisBuild / publishRepoBase).value}")
+}
+
+val stopPublishServer = taskKey[Unit]("Stop HTTP server")
+Global / stopPublishServer := {
+ HttpPutServer.stop()
+ streams.value.log.info("HTTP PUT server stopped")
+}
+
+val checkMavenPublish = taskKey[Unit]("Check that ivyless Maven publish produced the expected files")
+Global / checkMavenPublish := {
+ val log = streams.value.log
+ val base = (ThisBuild / publishRepoBase).value
+ val groupId = (ThisBuild / organization).value
+ val artifactId = "a_3"
+ val ver = (ThisBuild / version).value
+ val groupPath = groupId.replace('.', '/')
+ val versionDir = base / groupPath / artifactId / ver
+
+ log.info(s"Checking published Maven layout in $versionDir")
+
+ 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(versionDir.exists && versionDir.isDirectory, s"Expected version dir $versionDir to exist")
+
+ val pomFile = versionDir / s"$artifactId-$ver.pom"
+ assert(pomFile.exists, s"Expected $pomFile to exist")
+ assert(new File(pomFile.getPath + ".md5").exists, s"Expected pom md5 checksum")
+ assert(new File(pomFile.getPath + ".sha1").exists, s"Expected pom sha1 checksum")
+
+ val jarFile = versionDir / s"$artifactId-$ver.jar"
+ assert(jarFile.exists, s"Expected $jarFile to exist")
+ assert(new File(jarFile.getPath + ".md5").exists, s"Expected jar md5 checksum")
+ assert(new File(jarFile.getPath + ".sha1").exists, s"Expected jar sha1 checksum")
+
+ val pomContent = IO.read(pomFile)
+ assert(pomContent.contains(s"$groupId"), s"POM should contain groupId")
+ assert(pomContent.contains(s"$artifactId"), s"POM should contain artifactId")
+ assert(pomContent.contains(s"$ver"), s"POM should contain version")
+
+ log.info("All ivyless Maven publish (HTTP) checks passed!")
+}
+
+val cleanPublishRepo = taskKey[Unit]("Clean the publish repo")
+Global / cleanPublishRepo := {
+ IO.delete((ThisBuild / publishRepoBase).value)
+}
diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/HttpPutServer.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/HttpPutServer.scala
new file mode 100644
index 000000000..ccbc66d57
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/HttpPutServer.scala
@@ -0,0 +1,65 @@
+import java.io._
+import java.net.InetSocketAddress
+import com.sun.net.httpserver.{ HttpExchange, HttpHandler, HttpServer }
+
+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
+ val path = ex.getRequestURI.getRawPath
+ val relativePath = if (path.startsWith("/")) path.substring(1) else path
+ val targetFile = new File(baseDir, relativePath.replace("/", File.separator))
+
+ if ("PUT".equalsIgnoreCase(method)) {
+ targetFile.getParentFile.mkdirs()
+ val in = ex.getRequestBody
+ val out = new FileOutputStream(targetFile)
+ try {
+ val buf = new Array[Byte](8192)
+ var n = 0
+ while ({ n = in.read(buf); n } != -1) out.write(buf, 0, n)
+ } finally {
+ out.close()
+ in.close()
+ }
+ ex.sendResponseHeaders(200, -1)
+ ex.close()
+ } else if ("GET".equalsIgnoreCase(method)) {
+ val baseCanon = baseDir.getCanonicalPath + File.separator
+ val fileCanon = targetFile.getCanonicalPath
+ if (!fileCanon.startsWith(baseCanon)) {
+ ex.sendResponseHeaders(403, -1)
+ ex.close()
+ } else if (targetFile.isFile) {
+ val bytes = java.nio.file.Files.readAllBytes(targetFile.toPath)
+ ex.sendResponseHeaders(200, bytes.length)
+ val out = ex.getResponseBody
+ try out.write(bytes)
+ finally out.close()
+ ex.close()
+ } else {
+ ex.sendResponseHeaders(404, -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-maven-http/project/plugins.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/plugins.sbt
new file mode 100644
index 000000000..adc5e970b
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/project/plugins.sbt
@@ -0,0 +1 @@
+// empty plugins file
diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/test b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/test
new file mode 100644
index 000000000..74ec5c082
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven-http/test
@@ -0,0 +1,9 @@
+# Test ivyless publish to Maven repo via HTTP - issue #8688
+# a publishes to HTTP server; b consumes a from the same Maven repo (server stays up for resolve)
+
+> cleanPublishRepo
+> startPublishServer
+> a/publish
+> checkMavenPublish
+> b/compile
+> stopPublishServer
diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/build.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/build.sbt
new file mode 100644
index 000000000..b786665db
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/build.sbt
@@ -0,0 +1,67 @@
+ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache"
+
+name := "lib1"
+organization := "com.example"
+version := "0.1.0-SNAPSHOT"
+scalaVersion := "3.8.1"
+
+publishMavenStyle := true
+val publishRepoBase = settingKey[File]("Base directory for Maven publish repo")
+publishRepoBase := baseDirectory.value / "repo"
+
+publishTo := Some(sbt.librarymanagement.MavenCache("local-maven", publishRepoBase.value))
+
+useIvy := false
+
+Compile / packageDoc / publishArtifact := false
+Compile / packageSrc / publishArtifact := false
+
+val checkMavenPublish = taskKey[Unit]("Check that ivyless Maven publish produced the expected files")
+checkMavenPublish := {
+ val log = streams.value.log
+ val base = publishRepoBase.value
+ val groupId = organization.value
+ val artifactId = normalizedName.value + "_3"
+ val ver = version.value
+ val groupPath = groupId.replace('.', '/')
+ val versionDir = base / groupPath / artifactId / ver
+
+ log.info(s"Checking published Maven layout in $versionDir")
+
+ 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(versionDir.exists && versionDir.isDirectory, s"Expected version dir $versionDir to exist")
+
+ val pomFile = versionDir / s"$artifactId-$ver.pom"
+ assert(pomFile.exists, s"Expected $pomFile to exist")
+ assert(new File(pomFile.getPath + ".md5").exists, s"Expected pom md5 checksum")
+ assert(new File(pomFile.getPath + ".sha1").exists, s"Expected pom sha1 checksum")
+
+ val jarFile = versionDir / s"$artifactId-$ver.jar"
+ assert(jarFile.exists, s"Expected $jarFile to exist")
+ assert(new File(jarFile.getPath + ".md5").exists, s"Expected jar md5 checksum")
+ assert(new File(jarFile.getPath + ".sha1").exists, s"Expected jar sha1 checksum")
+
+ val pomContent = IO.read(pomFile)
+ assert(pomContent.contains(s"$groupId"), s"POM should contain groupId")
+ assert(pomContent.contains(s"$artifactId"), s"POM should contain artifactId")
+ assert(pomContent.contains(s"$ver"), s"POM should contain version")
+
+ log.info("All ivyless Maven publish 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-maven/project/plugins.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/project/plugins.sbt
new file mode 100644
index 000000000..adc5e970b
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/project/plugins.sbt
@@ -0,0 +1 @@
+// empty plugins file
diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/src/main/scala/Lib.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/src/main/scala/Lib.scala
new file mode 100644
index 000000000..798d21706
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/src/main/scala/Lib.scala
@@ -0,0 +1,5 @@
+package com.example
+
+object Lib {
+ def hello: String = "Hello from lib1!"
+}
diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/test b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/test
new file mode 100644
index 000000000..cc1659d25
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-maven/test
@@ -0,0 +1,6 @@
+# Test ivyless publish to Maven repo (file) - issue #8688
+# When useIvy is false and publishTo is MavenCache, publish uses ivyless Maven layout
+
+> cleanPublishRepo
+> publish
+> checkMavenPublish