[2.x] fix: Fixes publishing with sbt-pgp (#9533)

**Problem**
Ivyless publishing doesn't work with sbt-pgp.

**Solution**
This refactors the ivyless publishing code and revives publishOrSkip, which is called by the plugin.
Instead of overriding publish task, implement proper PublisherInterface.
This commit is contained in:
eugene yokota 2026-08-09 00:37:25 -04:00 committed by GitHub
parent b338dfcec2
commit 90582a3438
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 723 additions and 731 deletions

View File

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

View File

@ -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"""|<?xml version="1.0" encoding="UTF-8"?>
|<metadata modelVersion="1.1.0">
| <groupId>$groupId</groupId>
| <artifactId>$artifactId</artifactId>
| <version>$version</version>
| <versioning>
| <snapshot>
| <localCopy>true</localCopy>
| </snapshot>
| <lastUpdated>$timestamp</lastUpdated>
| </versioning>
|</metadata>
|""".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
)
}
)
)
}

View File

@ -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.<algo>` 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"""|<?xml version="1.0" encoding="UTF-8"?>
|<metadata modelVersion="1.1.0">
| <groupId>$groupId</groupId>
| <artifactId>$artifactId</artifactId>
| <version>$version</version>
| <versioning>
| <snapshot>
| <localCopy>true</localCopy>
| </snapshot>
| <lastUpdated>$timestamp</lastUpdated>
| </versioning>
|</metadata>
|""".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

View File

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

View File

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

View File

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

View File

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

View File

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