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