From 5789a7ef77a25330e2e5c99ec61ab3d2b1c0e1e0 Mon Sep 17 00:00:00 2001 From: calm <148254234+calm329@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:05:43 -0600 Subject: [PATCH] [2.x] feat: Implement ivyless publishLocal (#8634) Fixes #8631 **Changes:** - Add `useIvy` setting key (defaults to `true`) - Add `ivylessPublishLocalImpl` helper that publishes without Ivy - Modify `publishLocal` to use ivyless publisher when `useIvy := false` - Generate ivy.xml via `lmcoursier.IvyXml` - Generate MD5/SHA-1 checksums for all files - Add scripted test `dependency-management/ivyless-publish-local` --- main/src/main/scala/sbt/Defaults.scala | 3 +- main/src/main/scala/sbt/Keys.scala | 1 + .../sbt/internal/LibraryManagement.scala | 116 ++++++++++++++++++ .../ivyless-publish-local/build.sbt | 97 +++++++++++++++ .../ivyless-publish-local/project/plugins.sbt | 1 + .../src/main/scala/Lib.scala | 5 + .../ivyless-publish-local/test | 15 +++ .../src/main/scala/sbt/util/Digest.scala | 1 + 8 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/build.sbt create mode 100644 sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/project/plugins.sbt create mode 100644 sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/src/main/scala/Lib.scala create mode 100644 sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/test diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 2073f6feb..e0ffeae2f 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -2866,6 +2866,7 @@ object Classpaths { Seq( publishMavenStyle :== true, sbtPluginPublishLegacyMavenStyle :== false, + useIvy :== true, publishArtifact :== true, (Test / publishArtifact) :== false ) @@ -2976,7 +2977,7 @@ object Classpaths { deliverLocal := deliverTask(makeIvyXmlLocalConfiguration).value, makeIvyXml := deliverTask(makeIvyXmlConfiguration).value, publish := publishOrSkip(publishConfiguration, publish / skip).value, - publishLocal := publishOrSkip(publishLocalConfiguration, publishLocal / skip).value, + publishLocal := LibraryManagement.ivylessPublishLocalTask.value, publishM2 := publishOrSkip(publishM2Configuration, publishM2 / skip).value, credentials ++= Def.uncached { val alreadyContainsCentralCredentials: Boolean = credentials.value.exists { diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index ed7180d11..711cc2af8 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -550,6 +550,7 @@ object Keys { val packagedArtifacts = taskKey[Map[Artifact, HashedVirtualFileRef]]("Packages all artifacts for publishing and maps the Artifact definition to the generated file.").withRank(CTask) val publishMavenStyle = settingKey[Boolean]("Configures whether to generate and publish a pom (true) or Ivy file (false).").withRank(BSetting) val sbtPluginPublishLegacyMavenStyle = settingKey[Boolean]("Configuration for generating the legacy pom of sbt plugins, to publish to Maven").withRank(CSetting) + val useIvy = settingKey[Boolean]("Use Ivy for publishing. When false, use the ivyless publisher.").withRank(BSetting) @transient val credentials = taskKey[Seq[Credentials]]("The credentials to use for updating and publishing.").withRank(BMinusTask) diff --git a/main/src/main/scala/sbt/internal/LibraryManagement.scala b/main/src/main/scala/sbt/internal/LibraryManagement.scala index f8de54826..2394fed67 100644 --- a/main/src/main/scala/sbt/internal/LibraryManagement.scala +++ b/main/src/main/scala/sbt/internal/LibraryManagement.scala @@ -22,6 +22,7 @@ import sbt.io.syntax.* import sbt.ProjectExtra.* import sjsonnew.JsonFormat import scala.concurrent.duration.FiniteDuration +import lmcoursier.definitions.Project as CsrProject private[sbt] object LibraryManagement { given linter: sbt.dsl.LinterLevel.Ignore.type = sbt.dsl.LinterLevel.Ignore @@ -543,4 +544,119 @@ private[sbt] object LibraryManagement { def isDynamicScalaVersion(version: String): Boolean = { version == "3-latest.candidate" } + + /** + * 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 / version + val moduleDir = 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") + + // Publish each artifact + artifacts.foreach: (artifact, sourceFile) => + val folder = typeToFolder(artifact.`type`) + val targetDir = moduleDir / folder + + // Construct filename using module name (includes Scala version suffix) + classifier + extension + val classifier = artifact.classifier.map("-" + _).getOrElse("") + val fileName = s"$moduleName$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)") + + // Generate and write ivy.xml + val ivyXmlContent = lmcoursier.IvyXml(project, Nil, Nil) + val ivysDir = moduleDir / "ivys" + val ivyXmlFile = ivysDir / "ivy.xml" + if !ivyXmlFile.exists || overwrite then + IO.createDirectory(ivysDir) + IO.write(ivyXmlFile, ivyXmlContent) + log.info(s"Published $ivyXmlFile") + writeChecksums(ivyXmlFile) + else log.warn(s"$ivyXmlFile already exists, skipping (overwrite=$overwrite)") + end ivylessPublishLocal + + /** + * 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 + 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.map { case (a, f) => (a, f) } + 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) + } + ) + ) } diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/build.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/build.sbt new file mode 100644 index 000000000..ccd5993e6 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/build.sbt @@ -0,0 +1,97 @@ +ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache" + +name := "lib1" +organization := "com.example" +version := "0.1.0-SNAPSHOT" +scalaVersion := "3.8.1" + +// Use a fixed path for local ivy repo to avoid sbt 2.x output sharding +val ivyLocalBase = settingKey[File]("Local Ivy repository base") +ivyLocalBase := baseDirectory.value / "local-ivy-repo" + +// Custom ivy paths to write under a fixed directory +ivyPaths := IvyPaths( + baseDirectory.value.toString, + Some(ivyLocalBase.value.toString) +) + +// Use ivyless publisher +useIvy := false + +// Disable doc generation to speed up the test +Compile / packageDoc / publishArtifact := false +Compile / packageSrc / publishArtifact := false + +// Task to print debug info +val printPaths = taskKey[Unit]("Print paths for debugging") +printPaths := { + val log = streams.value.log + val ip = ivyPaths.value + log.info(s"ivyPaths.baseDirectory = ${ip.baseDirectory}") + log.info(s"ivyPaths.ivyHome = ${ip.ivyHome}") + log.info(s"ivyLocalBase = ${ivyLocalBase.value}") + log.info(s"useIvy = ${useIvy.value}") +} + +// Task to check that files were published correctly +val checkIvylessPublish = taskKey[Unit]("Check that ivyless publish produced the expected files") +checkIvylessPublish := { + val log = streams.value.log + val base = ivyLocalBase.value / "local" + val org = organization.value + val moduleName = normalizedName.value + "_3" + val ver = version.value + + val moduleDir = base / org / moduleName / ver + + log.info(s"ivyLocalBase = ${ivyLocalBase.value}") + log.info(s"Checking published files in $moduleDir") + + // List what's actually in the ivy-repo directory + def listDir(dir: File, indent: String = ""): Unit = { + if (dir.exists) { + dir.listFiles.foreach { f => + log.info(s"$indent${f.getName}") + if (f.isDirectory) listDir(f, indent + " ") + } + } else { + log.info(s"${indent}Directory does not exist: $dir") + } + } + log.info("Contents of ivyLocalBase:") + listDir(ivyLocalBase.value) + + // Check that the main artifacts exist + val expectedDirs = Seq("jars", "poms", "ivys") + expectedDirs.foreach { dir => + val d = moduleDir / dir + assert(d.exists && d.isDirectory, s"Expected directory $d to exist") + } + + // Check jar file and checksums + val jarFile = moduleDir / "jars" / s"$moduleName.jar" + assert(jarFile.exists, s"Expected $jarFile to exist") + assert((moduleDir / "jars" / s"$moduleName.jar.md5").exists, s"Expected md5 checksum to exist") + assert((moduleDir / "jars" / s"$moduleName.jar.sha1").exists, s"Expected sha1 checksum to exist") + + // Check ivy.xml and checksums + val ivyFile = moduleDir / "ivys" / "ivy.xml" + assert(ivyFile.exists, s"Expected $ivyFile to exist") + assert((moduleDir / "ivys" / "ivy.xml.md5").exists, s"Expected ivy.xml md5 checksum to exist") + assert((moduleDir / "ivys" / "ivy.xml.sha1").exists, s"Expected ivy.xml sha1 checksum to exist") + + // Check ivy.xml content + val ivyContent = IO.read(ivyFile) + assert(ivyContent.contains(s"""organisation="$org""""), s"ivy.xml should contain organisation") + assert(ivyContent.contains(s"""module="$moduleName""""), s"ivy.xml should contain module name") + assert(ivyContent.contains(s"""revision="$ver""""), s"ivy.xml should contain revision") + + log.info("All ivyless publish checks passed!") +} + +// Task to clean the local ivy repo +val cleanLocalIvy = taskKey[Unit]("Clean the local ivy repo") +cleanLocalIvy := { + val base = ivyLocalBase.value / "local" + IO.delete(base) +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/project/plugins.sbt b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/project/plugins.sbt new file mode 100644 index 000000000..adc5e970b --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/project/plugins.sbt @@ -0,0 +1 @@ +// empty plugins file diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/src/main/scala/Lib.scala b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/src/main/scala/Lib.scala new file mode 100644 index 000000000..798d21706 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/src/main/scala/Lib.scala @@ -0,0 +1,5 @@ +package com.example + +object Lib { + def hello: String = "Hello from lib1!" +} diff --git a/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/test b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/test new file mode 100644 index 000000000..16ca6aeff --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/ivyless-publish-local/test @@ -0,0 +1,15 @@ +# Test ivyless publishLocal +# This test verifies that when useIvy is set to false in build.sbt, +# publishLocal produces the expected file structure + +# Clean any previous test state +> cleanLocalIvy + +# Print paths for debugging (should show useIvy = false) +> printPaths + +# Run publishLocal with ivyless publisher +> publishLocal + +# Verify the published files +> checkIvylessPublish diff --git a/util-cache/src/main/scala/sbt/util/Digest.scala b/util-cache/src/main/scala/sbt/util/Digest.scala index b7c1a5ee6..3cc29d226 100644 --- a/util-cache/src/main/scala/sbt/util/Digest.scala +++ b/util-cache/src/main/scala/sbt/util/Digest.scala @@ -23,6 +23,7 @@ object Digest: val tokens = parse(d) s"${tokens._1}-${tokens._2}" def algo: String = parse(d)._1 + def hashHexString: String = parse(d)._2 def toBytes: Array[Byte] = parse(d)._4 def sizeBytes: Long = parse(d)._3