diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala
index b3bed8b3f..9c536b826 100644
--- a/main/src/main/scala/sbt/Defaults.scala
+++ b/main/src/main/scala/sbt/Defaults.scala
@@ -3096,9 +3096,9 @@ object Classpaths {
.map(m => d.withRevision(m.module.revision))
}.distinct
}.value,
- publish := LibraryManagement.ivylessPublishTask.tag(Tags.Publish, Tags.Network).value,
- publishLocal := LibraryManagement.ivylessPublishLocalTask.value,
- publishM2 := LibraryManagement.ivylessPublishM2Task.tag(Tags.Publish, Tags.Network).value,
+ publish := publishOrSkip(publishConfiguration, publish / skip).value,
+ publishLocal := publishOrSkip(publishLocalConfiguration, publishLocal / skip).value,
+ publishM2 := publishOrSkip(publishM2Configuration, publishM2 / skip).value,
credentials ++= Def.uncached {
val alreadyContainsCentralCredentials: Boolean = credentials.value.exists {
case d: Credentials.DirectCredentials => d.host == Sona.host
@@ -3509,9 +3509,24 @@ object Classpaths {
},
ivySbt := Def.uncached((): Any),
ivyModule := Def.uncached((): Any),
- publisher := Def.uncached(
- Classpaths.defaultPublisher(dependencyResolution.value, fullResolvers.value.toVector)
- ),
+ publisher := Def.uncached {
+ val ivyHome = ivyPaths.value.ivyHome.map(new File(_)).getOrElse {
+ new File(sys.props("user.home")) / ".ivy2"
+ }
+ val localResolver = Resolver.file("local", ivyHome / "local")(using Resolver.ivyStylePatterns)
+ // publishLocal/publishM2/publish target these by name (see publishConfig's resolverName
+ // default and publishM2Configuration below).
+ val knownResolvers = localResolver +: otherResolvers.value
+ Publisher(
+ GenericPublisher(
+ dependencyResolution.value,
+ fullResolvers.value.toVector,
+ csrProject.value.withPublications(csrPublications.value),
+ allCredentials.value,
+ knownResolvers
+ )
+ )
+ },
allCredentials := Def.uncached(LMCoursier.allCredentialsTask.value),
transitiveUpdate := Def.uncached(transitiveUpdateTask.value),
updateCacheName := {
@@ -3934,15 +3949,17 @@ object Classpaths {
): Initialize[Task[Unit]] =
Def
.taskIf {
- if (skip.value) {
+ if skip.value then
val log = streams.value.log
val ref = thisProjectRef.value
logSkipPublish(log, ref)
- } else {
- sys.error(
- "publishOrSkip requires the sbt-ivy plugin. Use publish/publishLocal for ivyless publishing."
- )
- }
+ else
+ val conf = config.value
+ val log = streams.value.log
+ val intf = publisher.value
+ val module =
+ intf.moduleDescriptor(moduleSettings.value.asInstanceOf[ModuleDescriptorConfiguration])
+ intf.publish(module, conf, log)
}
.tag(Tags.Publish, Tags.Network)
@@ -4395,53 +4412,6 @@ object Classpaths {
val name = "inter-project"
override def toString: String = name
- /** Default publisher that delegates moduleDescriptor to Coursier and generates POM without Ivy. */
- private[sbt] def defaultPublisher(
- lm: DependencyResolution,
- resolvers: Vector[Resolver] = Vector.empty,
- ): Publisher =
- Publisher(new PublisherInterface {
- def moduleDescriptor(moduleSetting: ModuleDescriptorConfiguration): ModuleDescriptor =
- lm.moduleDescriptor(moduleSetting)
- def publish(
- module: ModuleDescriptor,
- configuration: PublishConfiguration,
- log: Logger
- ): Unit =
- sys.error("Ivy-based publish requires the sbt-ivy plugin or useIvy := true")
- def makePomFile(
- module: ModuleDescriptor,
- configuration: MakePomConfiguration,
- log: Logger
- ): java.io.File =
- val file = configuration.file.getOrElse(sys.error("makePom file must be specified."))
- val ms = module.moduleSettings.asInstanceOf[ModuleDescriptorConfiguration]
- val mid = ms.module
- val info = configuration.moduleInfo.orElse(Option(ms.moduleInfo))
- val deps = module.directDependencies
- val extra = configuration.extra.getOrElse(scala.xml.NodeSeq.Empty)
- val confs = configuration.configurations
- val scalaInfo = ms.scalaModuleInfo
- val pomXml =
- sbt.internal.PomGenerator.makePom(
- mid,
- info,
- deps,
- confs,
- extra,
- scalaInfo,
- resolvers,
- configuration.filterRepositories,
- configuration.allRepositories,
- )
- val processed = configuration.process(pomXml)
- val printer = new scala.xml.PrettyPrinter(1000, 4)
- val formatted = scala.xml.XML.loadString(printer.format(processed))
- scala.xml.XML.save(file.getAbsolutePath, formatted, "UTF-8", xmlDecl = true)
- log.info("Wrote " + file.getAbsolutePath)
- file
- })
-
def makeProducts: Initialize[Task[Seq[File]]] = Def.task {
val c = fileConverter.value
val resourceDirs = resourceDirectories.value
diff --git a/main/src/main/scala/sbt/internal/LibraryManagement.scala b/main/src/main/scala/sbt/internal/LibraryManagement.scala
index 935677cd9..fcf57f227 100644
--- a/main/src/main/scala/sbt/internal/LibraryManagement.scala
+++ b/main/src/main/scala/sbt/internal/LibraryManagement.scala
@@ -9,16 +9,12 @@
package sbt
package internal
-import java.io.{ File, IOException }
-import java.net.{ URI, URL }
+import java.io.File
import java.util.concurrent.Callable
-import java.util.regex.Matcher
-import gigahorse.AuthScheme
-import gigahorse.support.apachehttp.Gigahorse
+import scala.concurrent.duration.FiniteDuration
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 }
@@ -26,9 +22,6 @@ import sbt.io.IO
import sbt.io.syntax.*
import sbt.ProjectExtra.*
import sjsonnew.JsonFormat
-import scala.concurrent.*
-import scala.concurrent.duration.*
-import lmcoursier.definitions.Project as CsrProject
private[sbt] object LibraryManagement {
given linter: sbt.dsl.LinterLevel.Ignore.type = sbt.dsl.LinterLevel.Ignore
@@ -556,664 +549,4 @@ 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]
- */
- def ivylessPublishLocal(
- project: CsrProject,
- artifacts: Vector[(Artifact, File)],
- checksumAlgorithms: Vector[String],
- localRepoBase: File,
- overwrite: Boolean,
- log: Logger
- ): Unit =
- val org = project.module.organization.value
- val moduleName = project.module.name.value
- val version = project.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")
-
- // Helper to map artifact type to folder name
- def typeToFolder(tpe: String): String = tpe match
- case "jar" => "jars"
- case "src" | "source" | "sources" => "srcs"
- case "doc" | "docs" | "javadoc" | "javadocs" => "docs"
- case "pom" => "poms"
- case "ivy" => "ivys"
- case other => other + "s"
-
- // Helper to write checksums for a file using sbt.util.Digest
- def writeChecksums(file: File): Unit =
- checksumAlgorithms.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, file.toPath)
- val checksumFile = new File(file.getPath + "." + algo.toLowerCase)
- IO.write(checksumFile, digest.hashHexString)
- log.debug(s"Wrote checksum: $checksumFile")
-
- // Write ivy.xml first (so ivys/ exists even if artifact copy fails)
- val ivysDir = moduleDir / "ivys"
- val ivyXmlFile = ivysDir / "ivy.xml"
- IO.createDirectory(ivysDir)
- val ivyXmlContent = lmcoursier.IvyXml(project, Nil, Nil)
- if !ivyXmlFile.exists || overwrite then
- IO.write(ivyXmlFile, ivyXmlContent)
- log.info(s"Published $ivyXmlFile")
- writeChecksums(ivyXmlFile)
- else log.warn(s"$ivyXmlFile already exists, skipping (overwrite=$overwrite)")
-
- // Build a lookup from (type, classifier, ext) to cross-versioned publication name
- val pubNameLookup: Map[(String, String, String), String] =
- project.publications.map { (_, pub) =>
- (pub.`type`.value, pub.classifier.value, pub.ext.value) -> pub.name
- }.toMap
-
- // Publish each artifact
- artifacts.foreach: (artifact, sourceFile) =>
- val folder = typeToFolder(artifact.`type`)
- val targetDir = moduleDir / folder
-
- // Look up the cross-versioned artifact name from publications, fall back to module name
- val classifierStr = artifact.classifier.getOrElse("")
- val artName = pubNameLookup
- .getOrElse((artifact.`type`, classifierStr, artifact.extension), moduleName)
- val classifier = artifact.classifier.map("-" + _).getOrElse("")
- val fileName = s"$artName$classifier.${artifact.extension}"
- val targetFile = targetDir / fileName
-
- if !targetFile.exists || overwrite then
- IO.createDirectory(targetDir)
- IO.copyFile(sourceFile, targetFile)
- log.info(s"Published $targetFile")
- writeChecksums(targetFile)
- else log.warn(s"$targetFile already exists, skipping (overwrite=$overwrite)")
- end ivylessPublishLocal
-
- /**
- * Substitutes Ivy pattern placeholders for artifact URL.
- * Matches ivylessPublishLocal layout: [organisation]/[module]/[revision]/[type]s/[artifact](-[classifier]).[ext]
- */
- private def substituteIvyArtifactPattern(
- pattern: String,
- project: CsrProject,
- org: String,
- moduleName: String,
- version: String,
- typeFolder: String,
- artifactName: String,
- classifier: String,
- ext: String
- ): String = {
- var s = pattern
- s = s.replace("[organisation]", org)
- s = s.replace("[module]", moduleName)
- s = s.replace("[revision]", version)
- s = s.replace("[type]s", typeFolder)
- s = s.replace("[artifact]", artifactName)
- s = s.replace("[ext]", ext)
- if (classifier.nonEmpty) s = s.replace("(-[classifier])", s"-$classifier")
- else s = s.replace("(-[classifier])", "")
- // 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
- }
-
- /**
- * 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.
- */
- private def httpPut(
- url: URL,
- sourceFile: File,
- credentials: Option[Credentials.DirectCredentials],
- log: Logger
- ): Unit =
- val baseReq = Gigahorse.url(url.toString).put(sourceFile)
- 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)
- 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.
- * Uses HTTP PUT; supports credentials. Produces the same layout as ivylessPublishLocal.
- */
- def ivylessPublish(
- project: CsrProject,
- artifacts: Vector[(Artifact, File)],
- checksumAlgorithms: Vector[String],
- urlRepo: sbt.librarymanagement.URLRepository,
- credentials: Seq[Credentials],
- overwrite: Boolean,
- log: Logger
- ): Unit = {
- val org = project.module.organization.value
- val moduleName = project.module.name.value
- val version = project.version
- val artifactPattern = urlRepo.patterns.artifactPatterns.headOption.getOrElse(
- sys.error("URLRepository has no artifact pattern")
- )
- val ivyPattern = urlRepo.patterns.ivyPatterns.headOption.getOrElse(
- sys.error("URLRepository has no ivy pattern")
- )
- val directCreds = credentials.collect { case d: Credentials.DirectCredentials => d }
-
- def typeToFolder(tpe: String): String = tpe match
- case "jar" => "jars"
- case "src" | "source" | "sources" => "srcs"
- case "doc" | "docs" | "javadoc" | "javadocs" => "docs"
- case "pom" => "poms"
- case "ivy" => "ivys"
- case other => other + "s"
-
- 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)
- }
-
- artifacts.foreach { case (artifact, sourceFile) =>
- val folder = typeToFolder(artifact.`type`)
- val classifier = artifact.classifier.map("-" + _).getOrElse("")
- val artifactName = moduleName
- val pathPattern = substituteIvyArtifactPattern(
- artifactPattern,
- project,
- org,
- moduleName,
- version,
- folder,
- artifactName,
- classifier,
- artifact.extension
- )
- val url = URI.create(pathPattern).toURL()
- 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, credentialFor(checksumUrl, directCreds, None), log)
- finally cf.delete()
- }
- }
-
- val ivyXmlContent = lmcoursier.IvyXml(project, Nil, Nil)
- val ivyPathPattern = substituteIvyArtifactPattern(
- ivyPattern,
- project,
- org,
- moduleName,
- version,
- "ivys",
- "ivy",
- "",
- "xml"
- )
- val ivyUrl = URI.create(ivyPathPattern).toURL()
- val ivyTmp = File.createTempFile("ivy", ".xml")
- try {
- IO.write(ivyTmp, ivyXmlContent)
- 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, 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
- // Derive artifactId: for sbt 2 plugins, module.name has cross-version (e.g. sbt-example_sbt2_3).
- // For sbt 1 plugins, mavenArtifactsOfSbtPlugin cross-versions the POM artifact name (e.g. sbt-example_2.12_1.0).
- val baseModuleName = project.module.name.value
- val pomArtName = artifacts.collectFirst { case (a, _) if a.`type` == "pom" => a.name }
- val artifactId = pomArtName match
- case Some(name) if name.startsWith(baseModuleName) && name != baseModuleName => name
- case _ => baseModuleName
- 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)")
-
- if version.endsWith("-SNAPSHOT") then
- writeMavenMetadataLocal(versionDir, groupId, artifactId, version, log)
-
- private def writeMavenMetadataLocal(
- versionDir: File,
- groupId: String,
- artifactId: String,
- version: String,
- log: Logger
- ): Unit =
- val timestamp = new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new java.util.Date())
- val metadata =
- s"""|
- |
- | $groupId
- | $artifactId
- | $version
- |
- |
- | true
- |
- | $timestamp
- |
- |
- |""".stripMargin
- val metadataFile = new File(versionDir, "maven-metadata-local.xml")
- IO.write(metadataFile, metadata)
- log.info(s"Published $metadataFile")
-
- /**
- * 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
- // Derive artifactId: for sbt 2 plugins, module.name has cross-version (e.g. sbt-example_sbt2_3).
- // For sbt 1 plugins, mavenArtifactsOfSbtPlugin cross-versions the POM artifact name (e.g. sbt-example_2.12_1.0).
- val baseModuleName = project.module.name.value
- val pomArtName = artifacts.collectFirst { case (a, _) if a.`type` == "pom" => a.name }
- val artifactId = pomArtName match
- case Some(name) if name.startsWith(baseModuleName) && name != baseModuleName => name
- case _ => baseModuleName
- 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)
-
- 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.
- */
- def ivylessPublishToFile(
- project: CsrProject,
- artifacts: Vector[(Artifact, File)],
- checksumAlgorithms: Vector[String],
- fileRepo: sbt.librarymanagement.FileRepository,
- overwrite: Boolean,
- log: Logger
- ): Unit = {
- val pattern = fileRepo.patterns.artifactPatterns.headOption.getOrElse(
- sys.error("FileRepository has no artifact pattern")
- )
- val baseStr =
- if (pattern.contains("[organisation]"))
- pattern.substring(0, pattern.indexOf("[organisation]"))
- else pattern
- val normalized = baseStr.replace('\\', '/').stripSuffix("/")
- val localRepoBase =
- if (normalized.startsWith("file:")) new File(new java.net.URI(normalized))
- else new File(normalized)
- val repoDir = localRepoBase.getAbsoluteFile
- val isMavenLayout = fileRepo.patterns.isMavenCompatible
- if isMavenLayout then
- log.info(s"Ivyless publish (Maven layout) to file repo: $repoDir")
- ivylessPublishMavenToFile(project, artifacts, checksumAlgorithms, repoDir, overwrite, log)
- else
- log.info(s"Ivyless publish (Ivy layout) to file repo: $repoDir")
- ivylessPublishLocal(project, artifacts, checksumAlgorithms, repoDir, overwrite, log)
- }
-
- /**
- * Task initializer for ivyless publish (remote Ivy repo or file repo).
- * When useIvy is false and publishTo is URLRepository or FileRepository, uses ivyless publish; otherwise uses Ivy.
- */
- def ivylessPublishTask: Def.Initialize[Task[Unit]] =
- import Keys.*
- Def.ifS(Def.task { (publish / skip).value })(
- Def.task {
- val log = streams.value.log
- val ref = thisProjectRef.value
- log.debug(s"Skipping publish for ${Reference.display(ref)}")
- }
- )(
- Def.ifS(Def.task { useIvy.value })(
- Def.task {
- val log = streams.value.log
- val conf = publishConfiguration.value
- val module = ivyModule.value.asInstanceOf[ModuleDescriptor]
- val publisherInterface = publisher.value
- publisherInterface.publish(module, conf, log)
- }
- )(
- Def.task {
- val log = streams.value.log
- val resolver = sbt.Classpaths.getPublishTo(publishTo.value)
- val project = csrProject.value.withPublications(csrPublications.value)
- val config = publishConfiguration.value
- val artifacts = config.artifacts
- resolver match {
- case urlRepo: sbt.librarymanagement.URLRepository =>
- val creds = allCredentials.value
- ivylessPublish(
- project,
- artifacts,
- config.checksums,
- urlRepo,
- creds,
- config.overwrite,
- log
- )
- case fileRepo: sbt.librarymanagement.FileRepository =>
- ivylessPublishToFile(
- project,
- artifacts,
- config.checksums,
- fileRepo,
- config.overwrite,
- log
- )
- case pbr: sbt.librarymanagement.PatternsBasedRepository
- if pbr.patterns.artifactPatterns.headOption.exists { pat =>
- pat.contains("[organisation]") && !pat.trim.startsWith("http")
- } =>
- // File repo detected by pattern (e.g. scripted classloader makes type match fail)
- val pat = pbr.patterns.artifactPatterns.head
- val baseStr =
- pat.substring(0, pat.indexOf("[organisation]")).replace('\\', '/').stripSuffix("/")
- val repoDir =
- (if (baseStr.startsWith("file:")) new File(new java.net.URI(baseStr))
- else new File(baseStr)).getAbsoluteFile
- if pbr.patterns.isMavenCompatible then
- log.info(s"Ivyless publish (Maven layout) to file repo: $repoDir")
- ivylessPublishMavenToFile(
- project,
- artifacts,
- config.checksums,
- repoDir,
- config.overwrite,
- log
- )
- else
- log.info(s"Ivyless publish (Ivy layout) to file repo: $repoDir")
- ivylessPublishLocal(
- project,
- artifacts,
- config.checksums,
- repoDir,
- 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
- sys.error(
- s"Ivyless Maven publish: unsupported root '$root'. Set useIvy := true or use a supported repository (http/https/file)."
- )
- case other =>
- sys.error(
- s"Ivyless publish does not support ${other.getClass.getName}. Set useIvy := true or use URLRepository, FileRepository, or MavenRepository."
- )
- }
- }
- )
- )
-
- /**
- * Task initializer for ivyless publishLocal.
- * Uses Def.ifS for proper selective functor behavior.
- */
- def ivylessPublishLocalTask: Def.Initialize[Task[Unit]] =
- import Keys.*
- Def.ifS(Def.task { (publishLocal / skip).value })(
- // skip = true
- Def.task {
- val log = streams.value.log
- val ref = thisProjectRef.value
- log.debug(s"Skipping publishLocal for ${Reference.display(ref)}")
- }
- )(
- // skip = false
- Def.ifS(Def.task { useIvy.value })(
- // useIvy = true: use Ivy-based publisher
- Def.task {
- val log = streams.value.log
- val conf = publishLocalConfiguration.value
- val module = ivyModule.value.asInstanceOf[ModuleDescriptor]
- val publisherInterface = publisher.value
- publisherInterface.publish(module, conf, log)
- }
- )(
- // useIvy = false: use ivyless publisher
- Def.task {
- val log = streams.value.log
- val project = csrProject.value.withPublications(csrPublications.value)
- val config = publishLocalConfiguration.value
- val artifacts = config.artifacts
- val checksumAlgos = config.checksums
- val ivyHome = ivyPaths.value.ivyHome.map(new File(_)).getOrElse {
- val userHome = new File(System.getProperty("user.home"))
- userHome / ".ivy2"
- }
- val localRepoBase = ivyHome / "local"
- val overwriteFlag = config.overwrite
- ivylessPublishLocal(project, artifacts, checksumAlgos, localRepoBase, overwriteFlag, log)
- }
- )
- )
-
- /**
- * Task initializer for ivyless publishM2 (publish to local Maven ~/.m2 repository).
- * Uses Def.ifS for proper selective functor behavior.
- */
- def ivylessPublishM2Task: Def.Initialize[Task[Unit]] =
- import Keys.*
- Def.ifS(Def.task { (publishM2 / skip).value })(
- // skip = true
- Def.task {
- val log = streams.value.log
- val ref = thisProjectRef.value
- log.debug(s"Skipping publishM2 for ${Reference.display(ref)}")
- }
- )(
- // skip = false
- Def.ifS(Def.task { useIvy.value })(
- // useIvy = true: use Ivy-based publisher
- Def.task {
- val log = streams.value.log
- val conf = publishM2Configuration.value
- val module = ivyModule.value.asInstanceOf[ModuleDescriptor]
- val publisherInterface = publisher.value
- publisherInterface.publish(module, conf, log)
- }
- )(
- // useIvy = false: use ivyless publisher to Maven local
- Def.task {
- val log = streams.value.log
- val project = csrProject.value.withPublications(csrPublications.value)
- val config = publishM2Configuration.value
- val artifacts = config.artifacts
- val m2Repo = Resolver.publishMavenLocal
- ivylessPublishMavenToFile(
- project,
- artifacts,
- config.checksums,
- m2Repo.rootFile,
- config.overwrite,
- log
- )
- }
- )
- )
}
diff --git a/main/src/main/scala/sbt/internal/librarymanagement/GenericPublisher.scala b/main/src/main/scala/sbt/internal/librarymanagement/GenericPublisher.scala
new file mode 100644
index 000000000..335f4f499
--- /dev/null
+++ b/main/src/main/scala/sbt/internal/librarymanagement/GenericPublisher.scala
@@ -0,0 +1,601 @@
+/*
+ * sbt
+ * Copyright 2023, Scala center
+ * Copyright 2011 - 2022, Lightbend, Inc.
+ * Copyright 2008 - 2010, Mark Harrah
+ * Licensed under Apache License 2.0 (see LICENSE)
+ */
+
+package sbt
+package internal
+package librarymanagement
+
+import java.io.{ File, IOException }
+import java.net.{ URI, URL }
+import java.util.regex.Matcher
+
+import gigahorse.AuthScheme
+import gigahorse.support.apachehttp.Gigahorse
+import sbt.internal.librarymanagement.mavenint.PomExtraDependencyAttributes
+import sbt.librarymanagement.*
+import sbt.util.Logger
+import sbt.io.IO
+import sbt.io.syntax.*
+import scala.concurrent.Await
+import scala.concurrent.duration.*
+import lmcoursier.definitions.Project as CsrProject
+
+/**
+ * Publishes artifacts without Apache Ivy.
+ */
+class GenericPublisher private[sbt] (
+ dependencyResolution: DependencyResolution,
+ pomRepositories: Vector[Resolver],
+ project: CsrProject,
+ credentials: Seq[Credentials],
+ resolvers: Seq[Resolver]
+) extends PublisherInterface:
+
+ // Extension used for PGP signature files; checksums are not generated for these.
+ private val signatureExt = ".asc"
+
+ override def moduleDescriptor(moduleSetting: ModuleDescriptorConfiguration): ModuleDescriptor =
+ dependencyResolution.moduleDescriptor(moduleSetting)
+
+ override def makePomFile(
+ module: ModuleDescriptor,
+ configuration: MakePomConfiguration,
+ log: Logger
+ ): File =
+ val file = configuration.file.getOrElse(sys.error("makePom file must be specified."))
+ val ms = module.moduleSettings.asInstanceOf[ModuleDescriptorConfiguration]
+ val mid = ms.module
+ val info = configuration.moduleInfo.orElse(Option(ms.moduleInfo))
+ val deps = module.directDependencies
+ val extra = configuration.extra.getOrElse(scala.xml.NodeSeq.Empty)
+ val confs = configuration.configurations
+ val scalaInfo = ms.scalaModuleInfo
+ val pomXml =
+ sbt.internal.PomGenerator.makePom(
+ mid,
+ info,
+ deps,
+ confs,
+ extra,
+ scalaInfo,
+ pomRepositories,
+ configuration.filterRepositories,
+ configuration.allRepositories,
+ )
+ val processed = configuration.process(pomXml)
+ val printer = new scala.xml.PrettyPrinter(1000, 4)
+ val formatted = scala.xml.XML.loadString(printer.format(processed))
+ scala.xml.XML.save(file.getAbsolutePath, formatted, "UTF-8", xmlDecl = true)
+ log.info("Wrote " + file.getAbsolutePath)
+ file
+
+ override def publish(
+ module: ModuleDescriptor,
+ configuration: PublishConfiguration,
+ log: Logger
+ ): Unit =
+ val name = configuration.resolverName.getOrElse(
+ sys.error("GenericPublisher.publish requires PublishConfiguration.resolverName to be set")
+ )
+ val target = resolvers.filter(_.name == name) match
+ case Seq(r) => r
+ case Seq() => sys.error(s"no resolver named '$name' is configured")
+ case _ =>
+ sys.error(
+ s"multiple resolvers are named '$name'; " +
+ s"'local' and '${Resolver.publishMavenLocal.name}' are reserved for " +
+ "publishLocal and publishM2 respectively"
+ )
+ val artifacts = configuration.artifacts
+ target match
+ case urlRepo: URLRepository =>
+ ivylessPublish(artifacts, configuration.checksums, urlRepo, configuration.overwrite, log)
+ case fileRepo: FileRepository =>
+ ivylessPublishToFile(
+ artifacts,
+ configuration.checksums,
+ fileRepo,
+ configuration.overwrite,
+ log
+ )
+ case pbr: PatternsBasedRepository if pbr.patterns.artifactPatterns.headOption.exists { pat =>
+ pat.contains("[organisation]") && !pat.trim.startsWith("http")
+ } =>
+ // File repo detected by pattern (e.g. scripted classloader makes type match fail)
+ val pat = pbr.patterns.artifactPatterns.head
+ val baseStr =
+ pat.substring(0, pat.indexOf("[organisation]")).replace('\\', '/').stripSuffix("/")
+ val repoDir =
+ (if (baseStr.startsWith("file:")) new File(new java.net.URI(baseStr))
+ else new File(baseStr)).getAbsoluteFile
+ if pbr.patterns.isMavenCompatible then
+ log.info(s"Ivyless publish (Maven layout) to file repo: $repoDir")
+ ivylessPublishMavenToFile(
+ artifacts,
+ configuration.checksums,
+ repoDir,
+ configuration.overwrite,
+ log
+ )
+ else
+ log.info(s"Ivyless publish (Ivy layout) to file repo: $repoDir")
+ ivylessPublishLocal(
+ artifacts,
+ configuration.checksums,
+ repoDir,
+ configuration.overwrite,
+ log
+ )
+ case mavenCache: MavenCache =>
+ ivylessPublishMavenToFile(
+ artifacts,
+ configuration.checksums,
+ mavenCache.rootFile,
+ configuration.overwrite,
+ log
+ )
+ case mavenRepo: MavenRepo =>
+ val root = mavenRepo.root.stripSuffix("/")
+ if root.startsWith("http://") || root.startsWith("https://") then
+ ivylessPublishMavenToUrl(
+ artifacts,
+ configuration.checksums,
+ root,
+ configuration.overwrite,
+ log
+ )
+ else if root.startsWith("file:") then
+ val repoBase = new File(URI.create(root))
+ ivylessPublishMavenToFile(
+ artifacts,
+ configuration.checksums,
+ repoBase,
+ configuration.overwrite,
+ log
+ )
+ else
+ sys.error(
+ s"ivyless Maven publish: unsupported root '$root'; use a supported repository (http/https/file)."
+ )
+ case other =>
+ sys.error(
+ s"ivyless publish does not support ${other.getClass.getName}; use URLRepository, FileRepository, or MavenRepository."
+ )
+ end publish
+
+ private def pluginCrossPath: Seq[String] =
+ val attrs = project.module.attributes
+ attrs.get(PomExtraDependencyAttributes.ScalaVersionKey).map("scala_" + _).toSeq ++
+ attrs.get(PomExtraDependencyAttributes.SbtVersionKey).map("sbt_" + _).toSeq
+
+ private def typeToFolder(tpe: String): String = tpe match
+ case "jar" => "jars"
+ case "src" | "source" | "sources" => "srcs"
+ case "doc" | "docs" | "javadoc" | "javadocs" => "docs"
+ case "pom" => "poms"
+ case "ivy" => "ivys"
+ case other => other + "s"
+
+ /**
+ * Publishes artifacts to the local Ivy repository without using Apache Ivy.
+ * Uses the pattern: [org]/[module]/[revision]/[types]/[artifact](-[classifier]).[ext]
+ */
+ private def ivylessPublishLocal(
+ artifacts: Vector[(Artifact, File)],
+ checksumAlgorithms: Vector[String],
+ localRepoBase: File,
+ overwrite: Boolean,
+ log: Logger
+ ): Unit =
+ val org = project.module.organization.value
+ val moduleName = project.module.name.value
+ val version = project.version
+
+ // Base directory: localRepoBase / org / module / (scala_V/)(sbt_V/) / version
+ val moduleDir =
+ pluginCrossPath.foldLeft(localRepoBase / org / moduleName)(_ / _) / version
+
+ log.info(s"Publishing to $moduleDir")
+
+ // Write ivy.xml first (so ivys/ exists even if artifact copy fails)
+ val ivysDir = moduleDir / "ivys"
+ val ivyXmlFile = ivysDir / "ivy.xml"
+ IO.createDirectory(ivysDir)
+ val ivyXmlContent = lmcoursier.IvyXml(project, Nil, Nil)
+ if !ivyXmlFile.exists || overwrite then
+ IO.write(ivyXmlFile, ivyXmlContent)
+ log.info(s"published $ivyXmlFile")
+ writeChecksumsForFile(ivyXmlFile, checksumAlgorithms, log)
+ else log.warn(s"$ivyXmlFile already exists, skipping (overwrite=$overwrite)")
+
+ // Build a lookup from (type, classifier, ext) to cross-versioned publication name
+ val pubNameLookup: Map[(String, String, String), String] =
+ project.publications.map { (_, pub) =>
+ (pub.`type`.value, pub.classifier.value, pub.ext.value) -> pub.name
+ }.toMap
+
+ // Publish each artifact
+ artifacts.foreach: (artifact, sourceFile) =>
+ val folder = typeToFolder(artifact.`type`)
+ val targetDir = moduleDir / folder
+
+ // Look up the cross-versioned artifact name from publications, fall back to module name
+ val classifierStr = artifact.classifier.getOrElse("")
+ val artName = pubNameLookup
+ .getOrElse((artifact.`type`, classifierStr, artifact.extension), moduleName)
+ val classifier = artifact.classifier.map("-" + _).getOrElse("")
+ val fileName = s"$artName$classifier.${artifact.extension}"
+ val targetFile = targetDir / fileName
+
+ if !targetFile.exists || overwrite then
+ IO.createDirectory(targetDir)
+ IO.copyFile(sourceFile, targetFile)
+ log.info(s"published $targetFile")
+ if !targetFile.getName.endsWith(signatureExt) then
+ writeChecksumsForFile(targetFile, checksumAlgorithms, log)
+ else log.warn(s"$targetFile already exists, skipping (overwrite=$overwrite)")
+ end ivylessPublishLocal
+
+ /**
+ * Substitutes Ivy pattern placeholders for artifact URL.
+ * Matches ivylessPublishLocal layout: [organisation]/[module]/[revision]/[type]s/[artifact](-[classifier]).[ext]
+ */
+ private def substituteIvyArtifactPattern(
+ pattern: String,
+ org: String,
+ moduleName: String,
+ version: String,
+ typeFolder: String,
+ artifactName: String,
+ classifier: String,
+ ext: String
+ ): String = {
+ var s = pattern
+ s = s.replace("[organisation]", org)
+ s = s.replace("[module]", moduleName)
+ s = s.replace("[revision]", version)
+ s = s.replace("[type]s", typeFolder)
+ s = s.replace("[artifact]", artifactName)
+ s = s.replace("[ext]", ext)
+ if (classifier.nonEmpty) s = s.replace("(-[classifier])", s"-$classifier")
+ else s = s.replace("(-[classifier])", "")
+ // 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
+ }
+
+ /**
+ * 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.
+ */
+ private def httpPut(
+ url: URL,
+ sourceFile: File,
+ credentials: Option[Credentials.DirectCredentials],
+ log: Logger
+ ): Unit =
+ val baseReq = Gigahorse.url(url.toString).put(sourceFile)
+ 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)
+ 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.
+ * Uses HTTP PUT; supports credentials. Produces the same layout as ivylessPublishLocal.
+ */
+ private def ivylessPublish(
+ artifacts: Vector[(Artifact, File)],
+ checksumAlgorithms: Vector[String],
+ urlRepo: URLRepository,
+ overwrite: Boolean,
+ log: Logger
+ ): Unit = {
+ val org = project.module.organization.value
+ val moduleName = project.module.name.value
+ val version = project.version
+ val artifactPattern = urlRepo.patterns.artifactPatterns.headOption.getOrElse(
+ sys.error("URLRepository has no artifact pattern")
+ )
+ val ivyPattern = urlRepo.patterns.ivyPatterns.headOption.getOrElse(
+ sys.error("URLRepository has no ivy pattern")
+ )
+ val directCreds = credentials.collect { case d: Credentials.DirectCredentials => d }
+
+ artifacts.foreach { case (artifact, sourceFile) =>
+ val folder = typeToFolder(artifact.`type`)
+ val classifier = artifact.classifier.map("-" + _).getOrElse("")
+ val artifactName = moduleName
+ val pathPattern = substituteIvyArtifactPattern(
+ artifactPattern,
+ org,
+ moduleName,
+ version,
+ folder,
+ artifactName,
+ classifier,
+ artifact.extension
+ )
+ val url = URI.create(pathPattern).toURL()
+ httpPut(url, sourceFile, credentialFor(url, directCreds, None), log)
+ if !url.toString.endsWith(signatureExt) then
+ val checksums = writeChecksumsToTempFiles(sourceFile, checksumAlgorithms)
+ checksums.foreach { case (cf, suffix) =>
+ val checksumUrl = URI.create(pathPattern + suffix).toURL()
+ try httpPut(checksumUrl, cf, credentialFor(checksumUrl, directCreds, None), log)
+ finally cf.delete()
+ }
+ }
+
+ val ivyXmlContent = lmcoursier.IvyXml(project, Nil, Nil)
+ val ivyPathPattern = substituteIvyArtifactPattern(
+ ivyPattern,
+ org,
+ moduleName,
+ version,
+ "ivys",
+ "ivy",
+ "",
+ "xml"
+ )
+ val ivyUrl = URI.create(ivyPathPattern).toURL()
+ val ivyTmp = File.createTempFile("ivy", ".xml")
+ try {
+ IO.write(ivyTmp, ivyXmlContent)
+ httpPut(ivyUrl, ivyTmp, credentialFor(ivyUrl, directCreds, None), log)
+ val checksums = writeChecksumsToTempFiles(ivyTmp, checksumAlgorithms)
+ checksums.foreach { case (cf, suffix) =>
+ val checksumUrl = URI.create(ivyPathPattern + suffix).toURL()
+ try httpPut(checksumUrl, cf, credentialFor(checksumUrl, directCreds, None), log)
+ finally cf.delete()
+ }
+ } finally ivyTmp.delete()
+ }
+
+ /**
+ * Publishes artifacts to a local file repo (FileRepository) without using Apache Ivy.
+ * Same layout as ivylessPublishLocal; used for testing without an HTTP server.
+ */
+ private def ivylessPublishToFile(
+ artifacts: Vector[(Artifact, File)],
+ checksumAlgorithms: Vector[String],
+ fileRepo: FileRepository,
+ overwrite: Boolean,
+ log: Logger
+ ): Unit = {
+ val pattern = fileRepo.patterns.artifactPatterns.headOption.getOrElse(
+ sys.error("FileRepository has no artifact pattern")
+ )
+ val baseStr =
+ if (pattern.contains("[organisation]"))
+ pattern.substring(0, pattern.indexOf("[organisation]"))
+ else pattern
+ val normalized = baseStr.replace('\\', '/').stripSuffix("/")
+ val localRepoBase =
+ if (normalized.startsWith("file:")) new File(new java.net.URI(normalized))
+ else new File(normalized)
+ val repoDir = localRepoBase.getAbsoluteFile
+ val isMavenLayout = fileRepo.patterns.isMavenCompatible
+ if isMavenLayout then
+ log.info(s"Ivyless publish (Maven layout) to file repo: $repoDir")
+ ivylessPublishMavenToFile(artifacts, checksumAlgorithms, repoDir, overwrite, log)
+ else
+ log.info(s"Ivyless publish (Ivy layout) to file repo: $repoDir")
+ ivylessPublishLocal(artifacts, checksumAlgorithms, repoDir, overwrite, log)
+ }
+
+ /**
+ * 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 normalizedChecksumAlgorithm(algo: String): String =
+ algo.toLowerCase match
+ case a @ ("md5" | "sha1") => a
+ case other =>
+ throw new IllegalArgumentException(s"Unsupported checksum algorithm: $other")
+
+ /**
+ * Writes a `targetFile.` checksum file alongside `targetFile` for each algorithm.
+ */
+ private def writeChecksumsForFile(
+ targetFile: File,
+ algorithms: Vector[String],
+ log: Logger
+ ): Unit =
+ algorithms.foreach: algo =>
+ val digestAlgo = normalizedChecksumAlgorithm(algo)
+ val digest = sbt.util.Digest(digestAlgo, targetFile.toPath)
+ val checksumFile = new File(targetFile.getPath + "." + digestAlgo)
+ IO.write(checksumFile, digest.hashHexString)
+ log.debug(s"Wrote checksum: $checksumFile")
+
+ /**
+ * Computes checksums for `file` and writes each to its own temp file, paired with its
+ * suffix (e.g. ".md5"), for callers that need to HTTP PUT them elsewhere before discarding.
+ */
+ private def writeChecksumsToTempFiles(
+ file: File,
+ algorithms: Vector[String]
+ ): Vector[(File, String)] =
+ algorithms.map: algo =>
+ val digestAlgo = normalizedChecksumAlgorithm(algo)
+ val digest = sbt.util.Digest(digestAlgo, file.toPath)
+ val suffix = "." + digestAlgo
+ val tmpFile = File.createTempFile("checksum", suffix)
+ IO.write(tmpFile, digest.hashHexString)
+ (tmpFile, suffix)
+
+ /**
+ * Publishes artifacts to a local Maven repo (Maven layout) without using Apache Ivy.
+ * Layout: groupId/artifactId/version/artifactId-version[-classifier].ext
+ */
+ private def ivylessPublishMavenToFile(
+ 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
+ // Derive artifactId: for sbt 2 plugins, module.name has cross-version (e.g. sbt-example_sbt2_3).
+ // For sbt 1 plugins, mavenArtifactsOfSbtPlugin cross-versions the POM artifact name (e.g. sbt-example_2.12_1.0).
+ val baseModuleName = project.module.name.value
+ val pomArtName = artifacts.collectFirst { case (a, _) if a.`type` == "pom" => a.name }
+ val artifactId = pomArtName match
+ case Some(name) if name.startsWith(baseModuleName) && name != baseModuleName => name
+ case _ => baseModuleName
+ 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")
+ if !targetFile.toString.endsWith(signatureExt) then
+ writeChecksumsForFile(targetFile, checksumAlgorithms, log)
+ else log.warn(s"$targetFile already exists, skipping (overwrite=$overwrite)")
+
+ if version.endsWith("-SNAPSHOT") then
+ writeMavenMetadataLocal(versionDir, groupId, artifactId, version, log)
+
+ private def writeMavenMetadataLocal(
+ versionDir: File,
+ groupId: String,
+ artifactId: String,
+ version: String,
+ log: Logger
+ ): Unit =
+ val timestamp = new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new java.util.Date())
+ val metadata =
+ s"""|
+ |
+ | $groupId
+ | $artifactId
+ | $version
+ |
+ |
+ | true
+ |
+ | $timestamp
+ |
+ |
+ |""".stripMargin
+ val metadataFile = new File(versionDir, "maven-metadata-local.xml")
+ IO.write(metadataFile, metadata)
+ log.info(s"published $metadataFile")
+
+ /**
+ * Publishes artifacts to a remote Maven repo (HTTP) without using Apache Ivy.
+ * Same layout as ivylessPublishMavenToFile; uses HTTP PUT with optional Basic auth.
+ */
+ private def ivylessPublishMavenToUrl(
+ artifacts: Vector[(Artifact, File)],
+ checksumAlgorithms: Vector[String],
+ baseUrl: String,
+ 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
+ // Derive artifactId: for sbt 2 plugins, module.name has cross-version (e.g. sbt-example_sbt2_3).
+ // For sbt 1 plugins, mavenArtifactsOfSbtPlugin cross-versions the POM artifact name (e.g. sbt-example_2.12_1.0).
+ val baseModuleName = project.module.name.value
+ val pomArtName = artifacts.collectFirst { case (a, _) if a.`type` == "pom" => a.name }
+ val artifactId = pomArtName match
+ case Some(name) if name.startsWith(baseModuleName) && name != baseModuleName => name
+ case _ => baseModuleName
+ val version = project.version
+ val directCreds = credentials.collect:
+ case d: Credentials.DirectCredentials => d
+
+ 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)
+ if !sourceFile.toString.endsWith(signatureExt) then
+ val checksums = writeChecksumsToTempFiles(sourceFile, checksumAlgorithms)
+ 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)
+end GenericPublisher
+
+object GenericPublisher:
+ def apply(
+ dependencyResolution: DependencyResolution,
+ pomRepositories: Vector[Resolver],
+ project: CsrProject,
+ credentials: Seq[Credentials]
+ ): GenericPublisher =
+ apply(dependencyResolution, pomRepositories, project, credentials, Nil)
+
+ def apply(
+ dependencyResolution: DependencyResolution,
+ pomRepositories: Vector[Resolver],
+ project: CsrProject,
+ credentials: Seq[Credentials],
+ resolvers: Seq[Resolver]
+ ): GenericPublisher =
+ new GenericPublisher(dependencyResolution, pomRepositories, project, credentials, resolvers)
+end GenericPublisher
diff --git a/sbt-app/src/sbt-test/plugins/pgp/build.sbt b/sbt-app/src/sbt-test/plugins/pgp/build.sbt
index e48f8ce4b..f9a2d45c5 100644
--- a/sbt-app/src/sbt-test/plugins/pgp/build.sbt
+++ b/sbt-app/src/sbt-test/plugins/pgp/build.sbt
@@ -1,2 +1,17 @@
+Global / credentials := Seq(Credentials("", "pgp", "", "test password"))
+Global / pgpSecretRing := baseDirectory.value / "secring.pgp"
+Global / pgpPublicRing := baseDirectory.value / "pubring.pgp"
+Global / useGpg := false
+
scalaVersion := "3.8.4"
+organization := "com.example"
+name := "app"
+version := "1.0"
publishLocal := {}
+
+publishTo := {
+ val centralSnapshots = "https://central.sonatype.com/repository/maven-snapshots/"
+ if isSnapshot.value then Some("central-snapshots" at centralSnapshots)
+ else localStaging.value
+}
+usePgpKeyHex("AA2DBC9295B91B7A")
diff --git a/sbt-app/src/sbt-test/plugins/pgp/pubring.pgp b/sbt-app/src/sbt-test/plugins/pgp/pubring.pgp
new file mode 100644
index 000000000..7a8aff5a0
--- /dev/null
+++ b/sbt-app/src/sbt-test/plugins/pgp/pubring.pgp
@@ -0,0 +1,17 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: BCPG v1.51
+
+mQENBFVcLH0BCACmERkKh73zr+0nFl7/6WWP3QcNa3msWDlArP6GzQEOmFBNZEtv
+1BRSKz1fGl4aZzpNPDh2rNcsjVhJPzwVLUzsJuWgBa2EdYafnsUjQ37V998iFhtZ
+ZgbXieJ2yMvaNPPrPjgYBAZFrnQmH0oQDx+GWDd5Y3lQBx0heIJA5YjPN3meDzqZ
+FC0qxSVeCPFYaxAFoMnNDEohpvGV3iiUTAyTgSDofxJqVV20oeMCJzC89VskCjSV
+KIlCnKIRFA+WtND1AsJuIBM/x4JQkRF8xc/2tS5xGXSQllgwxHXEnhDDXcT0o6pY
+Ni2xYSG0gcmwaBGvx8N1RjWcVms/iJ4ViayxABEBAAG0C1Rlc3QgPFRlc3Q+iQEc
+BBMBAgAGBQJVXCx9AAoJEKotvJKVuRt6fQkH/1XhdHxOB5m92TasMkxCqjuK0EtQ
+0HEIkoRC+Z9gOUqlhK58y+8DK9ZAEx/e09CDK1N+x/R3xQUKh2KbUpS7yiNzzqZO
+OejWtmWw6gYNHy1COP9WKT8Qgr+z4d7GBTrGthEhvZftHyyqlN6JZJZ2ZcvE/Odz
+QkMbd4aeRXi+KgzHp4fa1hLaA2BT7TT1trYH2L6OI6VbcEebnz+up738liHq9p/R
+JrJP4JjLzWtEkL83pE6FHhXowsdaG8WKYfWRstX7RixHq5P13PW/iAZEAmJb08ER
+mmguiicjExRk7UaIVwKPa5P1DAOgeK/ejCLsxrUKspIgt6JLQFbprGZHRdE=
+=/1s8
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/sbt-app/src/sbt-test/plugins/pgp/secring.pgp b/sbt-app/src/sbt-test/plugins/pgp/secring.pgp
new file mode 100644
index 000000000..f460fefe6
--- /dev/null
+++ b/sbt-app/src/sbt-test/plugins/pgp/secring.pgp
@@ -0,0 +1,32 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+Version: BCPG v1.51
+
+lQO+BFVcLH0BCACmERkKh73zr+0nFl7/6WWP3QcNa3msWDlArP6GzQEOmFBNZEtv
+1BRSKz1fGl4aZzpNPDh2rNcsjVhJPzwVLUzsJuWgBa2EdYafnsUjQ37V998iFhtZ
+ZgbXieJ2yMvaNPPrPjgYBAZFrnQmH0oQDx+GWDd5Y3lQBx0heIJA5YjPN3meDzqZ
+FC0qxSVeCPFYaxAFoMnNDEohpvGV3iiUTAyTgSDofxJqVV20oeMCJzC89VskCjSV
+KIlCnKIRFA+WtND1AsJuIBM/x4JQkRF8xc/2tS5xGXSQllgwxHXEnhDDXcT0o6pY
+Ni2xYSG0gcmwaBGvx8N1RjWcVms/iJ4ViayxABEBAAH+AwMCQJCBiVce8z5gAZr+
+L8W6NOs8orCIgLbCjKAHuKan9mZXtvOaml/8EDiBjLvwekUQj0OI6S29y5QsQpvn
+lo8vXAsJnA/Q0olrAim2aZPOXVjQOYsOwExo4SAu7zXBQ3w3+jYsM5kHYPWICr3g
+3LzhVUmDTHjein/Xa9X7M8bzAY0Esoabl1aSKZ9K15P2ss7noHXrKZPxRk3jb0XP
+jHE2h5zTLLkdyXnZ74ILVYYEOjqe7P/+eWBL5TIan73ekNCKkgRBKI+pWB8Bc8sA
+ww2WtlkpsogVItjZ7spk03HVykSr/hS2TnVoR1vX+C8bPLjvwksNORWELf9z32vA
+ASgqoqeKMc+qHLPnZOrNIoCwUgBHLq/XL09E4Sav1TnqQt14Ya3oTiWQARMY+6jC
+FGpHhKhx4XjrBjUyGWm8EzC/IPWIXSs9oeOxfSag7QNCf3F1fOjUOYkt32bTpEiM
+X5sxrovkW/bh0U7thnIsHeklDx99U89F3J0K5svN66f7XplN3wYgwBJA/Fjymgj0
+zYN0cibnL1rp6zyMRSu5iDQRq1A9rFzsOQb4+gyNMUvlSM1ajA/sFvHsk0xFXdPG
+ADOx9Tn3f4JN58ylGnMygtBmcS4f+lDVS0Q96lRuyFRTze/+JuhVTPNk2kClNp8y
+98dc4UMawEyZ55EbxhVKx10jZ74Sswk8N15NhSZN5IKBUwQ1JWAoMnn0UDFeniBW
+gjmD/82a0QzosJRkOEKqaCUK02FUgFNcB/6Aauj5Pm6vDehWnk+4Kz4f2QrqdD+Y
+DquqcQ88gPj7jJnRX/+lMwKd73PeK3GfpvWCkRcliUs47LQ585uc03lArxpG2j74
+hG9Lxt/B545sqsYt2ViJ0hNBRgwfUdGy6NVef4F6JxVxkD32eavDDUBfXC/a5zRu
+m7QLVGVzdCA8VGVzdD6JARwEEwECAAYFAlVcLH0ACgkQqi28kpW5G3p9CQf/VeF0
+fE4Hmb3ZNqwyTEKqO4rQS1DQcQiShEL5n2A5SqWErnzL7wMr1kATH97T0IMrU37H
+9HfFBQqHYptSlLvKI3POpk456Na2ZbDqBg0fLUI4/1YpPxCCv7Ph3sYFOsa2ESG9
+l+0fLKqU3olklnZly8T853NCQxt3hp5FeL4qDMenh9rWEtoDYFPtNPW2tgfYvo4j
+pVtwR5ufP66nvfyWIer2n9Emsk/gmMvNa0SQvzekToUeFejCx1obxYph9ZGy1ftG
+LEerk/Xc9b+IBkQCYlvTwRGaaC6KJyMTFGTtRohXAo9rk/UMA6B4r96MIuzGtQqy
+kiC3oktAVumsZkdF0Q==
+=wGX/
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/sbt-app/src/sbt-test/plugins/pgp/test b/sbt-app/src/sbt-test/plugins/pgp/test
index 003ee6843..5b900edf8 100644
--- a/sbt-app/src/sbt-test/plugins/pgp/test
+++ b/sbt-app/src/sbt-test/plugins/pgp/test
@@ -1 +1,4 @@
-> plugins
+> publishSigned
+
+$ exists target/sona-staging/com/example/app_3/1.0/app_3-1.0.jar.asc
+# $ absent target/sona-staging/com/example/app_3/1.0/app_3-1.0-sources.jar.asc.md5
diff --git a/sbt-ivy/src/main/scala/sbt/plugins/IvyDependencyPlugin.scala b/sbt-ivy/src/main/scala/sbt/plugins/IvyDependencyPlugin.scala
index 791d75e05..77929acc9 100644
--- a/sbt-ivy/src/main/scala/sbt/plugins/IvyDependencyPlugin.scala
+++ b/sbt-ivy/src/main/scala/sbt/plugins/IvyDependencyPlugin.scala
@@ -16,7 +16,13 @@ import sbt.Def.{ Initialize, Setting }
import sbt.Keys.*
import sbt.ProjectExtra.*
import sbt.internal.LibraryManagement
-import sbt.internal.librarymanagement.{ IvyActions, IvySbt, IvyXml, ProjectResolver }
+import sbt.internal.librarymanagement.{
+ GenericPublisher,
+ IvyActions,
+ IvySbt,
+ IvyXml,
+ ProjectResolver
+}
import sbt.internal.librarymanagement.ivy.*
import sbt.io.syntax.*
import sbt.librarymanagement.*
@@ -67,7 +73,22 @@ object IvyDependencyPlugin extends AutoPlugin:
}
)(
Def.task {
- Classpaths.defaultPublisher(dependencyResolution.value, fullResolvers.value.toVector)
+ val ivyHome = ivyPaths.value.ivyHome.map(new File(_)).getOrElse {
+ new File(System.getProperty("user.home")) / ".ivy2"
+ }
+ val localResolver =
+ Resolver.file("local", ivyHome / "local")(using Resolver.ivyStylePatterns)
+ // otherResolvers already has Resolver.publishMavenLocal +: publishTo.value.toVector
+ val knownResolvers = localResolver +: otherResolvers.value
+ Publisher(
+ GenericPublisher(
+ dependencyResolution.value,
+ fullResolvers.value.toVector,
+ csrProject.value.withPublications(csrPublications.value),
+ allCredentials.value,
+ knownResolvers
+ )
+ )
}
)
.value