[2.x] feat: Ivyless Maven repo publish (#8692)

- Add ivylessPublishMavenToFile and ivylessPublishMavenToUrl for Maven layout
- Handle MavenCache and MavenRepo in ivylessPublishTask (file + HTTP)
- Add credentialFor for realm+host credential matching per Publishing docs
- Consume HTTP response body on success to avoid connection leak
- Add scripted tests: ivyless-publish-maven, ivyless-publish-maven-http
This commit is contained in:
bitloi 2026-02-05 11:21:31 -05:00 committed by GitHub
parent 2f27b5cecd
commit 54548041cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 433 additions and 12 deletions

View File

@ -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

View File

@ -0,0 +1,5 @@
package com.example
object Lib {
def hello: String = "Hello from a!"
}

View File

@ -0,0 +1,3 @@
object B {
def msg: String = com.example.Lib.hello
}

View File

@ -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>$groupId</groupId>"), s"POM should contain groupId")
assert(pomContent.contains(s"<artifactId>$artifactId</artifactId>"), s"POM should contain artifactId")
assert(pomContent.contains(s"<version>$ver</version>"), 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)
}

View File

@ -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
}
}
}

View File

@ -0,0 +1 @@
// empty plugins file

View File

@ -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

View File

@ -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>$groupId</groupId>"), s"POM should contain groupId")
assert(pomContent.contains(s"<artifactId>$artifactId</artifactId>"), s"POM should contain artifactId")
assert(pomContent.contains(s"<version>$ver</version>"), 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)
}

View File

@ -0,0 +1 @@
// empty plugins file

View File

@ -0,0 +1,5 @@
package com.example
object Lib {
def hello: String = "Hello from lib1!"
}

View File

@ -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