[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`
This commit is contained in:
calm 2026-01-26 23:05:43 -06:00 committed by GitHub
parent cf8899919d
commit 5789a7ef77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 238 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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