[2.x] Fix ivyless sbt plugin publish cross paths (#9416)

Problem:
When useIvy := false, ivyless publishing dropped the optional
scala_[scalaVersion]/ and sbt_[sbtVersion]/ path segments from Ivy-style
plugin publish patterns. This caused sbt 1 plugins published from sbt 2 builds
to land under non-plugin Ivy paths, so consumers looking under
scala_2.12/sbt_1.0/ could not resolve them.

Solution:
Read the plugin cross-version attributes from the CsrProject module and use
them when constructing ivyless Ivy-layout publish paths. Apply the same
substitution for local/file Ivy publishing and remote Ivy-style URL publishing,
while continuing to omit those optional segments when the attributes are absent.
Add scripted regressions for local and HTTP Ivy-style publishing of an sbt 1
plugin with useIvy := false.
This commit is contained in:
Anatolii Kmetiuk 2026-07-13 11:24:41 +09:00 committed by Eugene Yokota
parent f80e2c38c8
commit e00efb9d9c
10 changed files with 270 additions and 4 deletions

View File

@ -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 }
@ -560,6 +562,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]
@ -576,8 +583,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")
@ -638,6 +646,7 @@ private[sbt] object LibraryManagement {
*/
private def substituteIvyArtifactPattern(
pattern: String,
project: CsrProject,
org: String,
moduleName: String,
version: String,
@ -655,8 +664,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
}
@ -750,6 +769,7 @@ private[sbt] object LibraryManagement {
val artifactName = moduleName
val pathPattern = substituteIvyArtifactPattern(
artifactPattern,
project,
org,
moduleName,
version,
@ -771,6 +791,7 @@ private[sbt] object LibraryManagement {
val ivyXmlContent = lmcoursier.IvyXml(project, Nil, Nil)
val ivyPathPattern = substituteIvyArtifactPattern(
ivyPattern,
project,
org,
moduleName,
version,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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!")
}

View File

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

View File

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

View File

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