[2.x] feat: Add "3-latest.candidate" support for Scala 3 release candidates (#8596)

Add support for `"3-latest.candidate"` to automatically resolve to the latest Scala 3 RC from Maven Central.

```scala
scalaVersion := "3-latest.candidate"
```

Ref https://github.com/sbt/sbt/discussions/8590
This commit is contained in:
calm 2026-01-21 00:42:50 -08:00 committed by GitHub
parent 642be58d44
commit 1b8e3317f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 202 additions and 16 deletions

View File

@ -31,6 +31,8 @@ object CrossVersionUtil {
raw"""$basicVersion((?:-$tagPattern)*)""".r // 0-n word suffixes, with leading dashes
private val NonReleaseV_1 = raw"""$basicVersion(-$tagPattern)""".r // 1 word suffix, after a dash
private[sbt] val PartialVersion = raw"""($longPattern)\.($longPattern)(?:\..+)?""".r
// Dynamic Scala version patterns like "3-latest.candidate"
private val DynamicScala3V = raw"""($longPattern)-latest\..*""".r
private[sbt] def isSbtApiCompatible(v: String): Boolean = sbtApiVersion(v).isDefined
@ -113,9 +115,15 @@ object CrossVersionUtil {
}
def binaryScalaVersion(full: String): String =
if full.startsWith("2.") then
binaryVersionWithApi(full, TransitionScalaVersion)(scalaApiVersion) // Scala 2 binary version
else binaryScala3Version(full)
full match {
// Handle dynamic Scala 3 version patterns like "3-latest.candidate"
case DynamicScala3V(maj) => maj
case _ if full.startsWith("2.") =>
binaryVersionWithApi(full, TransitionScalaVersion)(
scalaApiVersion
) // Scala 2 binary version
case _ => binaryScala3Version(full)
}
/**
* Returns the binary version of the Scala, except for
@ -124,14 +132,18 @@ object CrossVersionUtil {
* In Scala 3 onwards, it would be the major version.
*/
def earlyScalaVersion(full: String): String =
if full.startsWith("2.") then
partialVersion(full) match
case Some((major, minor)) => s"$major.$minor"
case None => full
else
partialVersion(full) match
case Some((major, minor)) => major.toString
case None => full
full match {
// Handle dynamic Scala 3 version patterns like "3-latest.candidate"
case DynamicScala3V(maj) => maj
case _ if full.startsWith("2.") =>
partialVersion(full) match
case Some((major, minor)) => s"$major.$minor"
case None => full
case _ =>
partialVersion(full) match
case Some((major, minor)) => major.toString
case None => full
}
def binarySbtVersion(full: String): String =
sbtApiVersion(full) match {

View File

@ -41,7 +41,8 @@ object ScalaArtifacts {
name.startsWith(Scala3ReplPrefix)
}
def isScala3(scalaVersion: String): Boolean = scalaVersion.startsWith("3.")
def isScala3(scalaVersion: String): Boolean =
scalaVersion.startsWith("3.") || scalaVersion.startsWith("3-latest.")
/**
* Returns true for pre-release nightlies intentionally.

View File

@ -264,6 +264,13 @@ class CrossVersionTest extends UnitSpec {
it should "for 4.0.0-M2 return 4.0.0-M2" in {
binaryScalaVersion("4.0.0-M2") shouldBe "4.0.0-M2"
}
// Dynamic Scala version patterns
it should "for 3-latest.candidate return 3" in {
binaryScalaVersion("3-latest.candidate") shouldBe "3"
}
it should "for 4-latest.candidate return 4" in {
binaryScalaVersion("4-latest.candidate") shouldBe "4"
}
"earlyScalaVersion" should "for 2.9.2 return 2.9" in {
assert(earlyScalaVersion("2.9.2") == "2.9")
@ -277,6 +284,13 @@ class CrossVersionTest extends UnitSpec {
it should "for 4.0.0-M1 return 4" in {
assert(earlyScalaVersion("4.0.0-M1") == "4")
}
// Dynamic Scala version patterns
it should "for 3-latest.candidate return 3" in {
assert(earlyScalaVersion("3-latest.candidate") == "3")
}
it should "for 4-latest.candidate return 4" in {
assert(earlyScalaVersion("4-latest.candidate") == "4")
}
private def patchVersion(fullVersion: String) =
CrossVersion(CrossVersion.patch, fullVersion, "dummy") map (fn => fn("artefact"))

View File

@ -734,7 +734,8 @@ object Defaults extends BuildCommon {
val hasSbtBridge = ScalaArtifacts.isScala3(sv) || ZincLmUtil.hasScala2SbtBridge(sv)
hasSbtBridge && managed
})(Def.cachedTask {
val sv = scalaVersion.value
// Use scalaDynVersion to resolve dynamic versions (e.g., "3-latest.candidate" -> "3.8.1-RC1")
val sv = scalaDynVersion.value
val conv = fileConverter.value
val s = streams.value
val t = target.value
@ -774,6 +775,13 @@ object Defaults extends BuildCommon {
javacOptions :== Nil,
scalacOptions :== Nil,
scalaVersion := appConfiguration.value.provider.scalaProvider.version,
derive(
scalaDynVersion := {
val sv = scalaVersion.value
val log = streams.value.log
LibraryManagement.resolveDynamicScalaVersion(sv, log)
}
),
consoleProject := ConsoleProject.consoleProjectTask.value,
consoleProject / scalaInstance := {
val topLoader = classOf[org.jline.terminal.Terminal].getClassLoader
@ -3119,9 +3127,12 @@ object Classpaths {
allExcludeDependencies := excludeDependencies.value,
scalaModuleInfo := (scalaModuleInfo or (
Def.setting {
// Resolve dynamic Scala version for scalaModuleInfo
val resolvedScalaVersion =
LibraryManagement.resolveDynamicScalaVersion((update / scalaVersion).value)
Option(
ScalaModuleInfo(
(update / scalaVersion).value,
resolvedScalaVersion,
(update / scalaBinaryVersion).value,
Vector.empty,
filterImplicit = false,
@ -3385,7 +3396,8 @@ object Classpaths {
autoScalaLibrary.value && scalaHome.value.isEmpty && managedScalaInstance.value,
sbtPlugin.value,
scalaOrganization.value,
scalaVersion.value
// Resolve dynamic Scala version (e.g., "3-latest.candidate" -> "3.8.1-RC1")
LibraryManagement.resolveDynamicScalaVersion(scalaVersion.value)
),
// Override the default to handle mixing in the sbtPlugin + scala dependencies.
allDependencies := Def.uncached {
@ -3397,7 +3409,8 @@ object Classpaths {
if (isPlugin) sbtdeps +: base
else base
val scalaOrg = scalaOrganization.value
val version = scalaVersion.value
// Resolve dynamic Scala version (e.g., "3-latest.candidate" -> "3.8.1-RC1")
val version = LibraryManagement.resolveDynamicScalaVersion(scalaVersion.value)
val extResolvers = externalResolvers.value
val allToolDeps =
if scalaHome.value.isDefined || scalaModuleInfo.value.isEmpty || !managedScalaInstance.value

View File

@ -218,6 +218,7 @@ object Keys {
val scalaInstance = taskKey[ScalaInstance]("Defines the Scala instance to use for compilation, running, and testing.").withRank(DTask)
val scalaOrganization = settingKey[String]("Organization/group ID of the Scala used in the project. Default value is 'org.scala-lang'. This is an advanced setting used for clones of the Scala Language. It should be disregarded in standard use cases.").withRank(CSetting)
val scalaVersion = settingKey[String]("The version of Scala used for building.").withRank(APlusSetting)
val scalaDynVersion = taskKey[String]("Resolves dynamic Scala version strings like '3-latest.candidate' to concrete versions.").withRank(DSetting)
val scalaBinaryVersion = settingKey[String]("The Scala version substring describing binary compatibility.").withRank(BPlusSetting)
val scalaEarlyVersion = settingKey[String]("The Scala version substring describing the binary compatibility, except for prereleases it returns the binary version of the release.").withRank(DSetting)
val crossScalaVersions = settingKey[Seq[String]]("The versions of Scala used when cross-building.").withRank(BPlusSetting)

View File

@ -440,4 +440,107 @@ private[sbt] object LibraryManagement {
def lock(app: xsbti.AppConfiguration): xsbti.GlobalLock =
app.provider.scalaProvider.launcher.globalLock
// Pattern for Scala 3 release candidates: X.Y.Z-RCN (not nightlies)
private val Scala3RCPattern = """^(\d+)\.(\d+)\.(\d+)-RC(\d+)$""".r
// Cache for the latest Scala 3 RC version lookup (24-hour TTL)
private var cachedScala3RC: Option[(String, Long)] = None
private val scala3RCCacheLock = new Object
private val cacheTtlMillis = 24L * 60 * 60 * 1000 // 24 hours
/**
* Fetches the latest Scala 3 release candidate version from Maven Central.
* Results are cached for 24 hours.
*
* @param log Logger for debug output
* @return The latest Scala 3 RC version string (e.g., "3.8.1-RC1")
*/
def fetchLatestScala3RC(log: Logger): String = {
scala3RCCacheLock.synchronized {
val now = System.currentTimeMillis()
cachedScala3RC match {
case Some((version, timestamp)) if (now - timestamp) < cacheTtlMillis =>
log.debug(s"Using cached Scala 3 RC version: $version")
version
case _ =>
log.info("Fetching latest Scala 3 release candidate from Maven Central...")
val version = fetchScala3RCFromMaven(log)
cachedScala3RC = Some((version, now))
version
}
}
}
private def fetchScala3RCFromMaven(log: Logger): String = {
import scala.util.Using
import java.net.{ HttpURLConnection, URI }
import scala.xml.XML
val metadataUrl =
"https://repo1.maven.org/maven2/org/scala-lang/scala3-library_3/maven-metadata.xml"
val uri = new URI(metadataUrl)
val conn = uri.toURL.openConnection().asInstanceOf[HttpURLConnection]
conn.setConnectTimeout(10000)
conn.setReadTimeout(30000)
try {
val xml = Using.resource(conn.getInputStream) { is =>
XML.load(is)
}
val versions = (xml \\ "version").map(_.text)
// Filter for RC versions (not nightlies) and sort to get the latest
val rcVersions = versions.flatMap { v =>
v match {
case Scala3RCPattern(major, minor, patch, rc) =>
Some((major.toInt, minor.toInt, patch.toInt, rc.toInt, v))
case _ => None
}
}
if (rcVersions.isEmpty) {
sys.error("No Scala 3 release candidates found in Maven Central")
}
// Sort by version components (major, minor, patch, rc) descending
val latestRC =
rcVersions.sortBy { case (maj, min, pat, rc, _) => (-maj, -min, -pat, -rc) }.head._5
log.info(s"Latest Scala 3 release candidate: $latestRC")
latestRC
} finally {
conn.disconnect()
}
}
/**
* Resolves a dynamic Scala version string to a concrete version.
* Currently supports:
* - "3-latest.candidate" - resolves to the latest Scala 3 RC
*
* @param version The version string (may be dynamic or concrete)
* @param log Logger for debug output
* @return The resolved concrete version string
*/
def resolveDynamicScalaVersion(version: String, log: Logger): String = {
version match {
case "3-latest.candidate" => fetchLatestScala3RC(log)
case other => other
}
}
/**
* Resolves a dynamic Scala version string to a concrete version.
* Uses a silent logger for background resolution.
*/
def resolveDynamicScalaVersion(version: String): String = {
resolveDynamicScalaVersion(version, Logger.Null)
}
/**
* Checks if a version string is a dynamic version that needs resolution.
*/
def isDynamicScalaVersion(version: String): Boolean = {
version == "3-latest.candidate"
}
}

View File

@ -0,0 +1,34 @@
ThisBuild / scalaVersion := "3-latest.candidate"
lazy val checkDynVersion = taskKey[Unit]("Check that scalaDynVersion resolves correctly")
lazy val p1 = project
.settings(
libraryDependencies += "org.typelevel" %% "cats-effect" % "3.6.3"
)
lazy val p2 = project
.dependsOn(p1)
.settings(
checkDynVersion := {
val log = streams.value.log
val sv = scalaVersion.value
val dynSv = scalaDynVersion.value
val binSv = scalaBinaryVersion.value
log.info(s"scalaVersion: $sv")
log.info(s"scalaDynVersion: $dynSv")
log.info(s"scalaBinaryVersion: $binSv")
// scalaVersion should be the dynamic pattern
assert(sv == "3-latest.candidate", s"Expected scalaVersion '3-latest.candidate', got '$sv'")
// scalaDynVersion should resolve to a concrete RC version (e.g., "3.8.1-RC1")
assert(dynSv.matches("""\d+\.\d+\.\d+-RC\d+"""), s"Expected scalaDynVersion to be a concrete RC version, got '$dynSv'")
// scalaBinaryVersion should be "3"
assert(binSv == "3", s"Expected scalaBinaryVersion '3', got '$binSv'")
log.success("All checks passed!")
}
)

View File

@ -0,0 +1,6 @@
package example
import cats.effect.IO
object Example:
def hello: IO[String] = IO.pure("Hello from Scala 3 RC!")

View File

@ -0,0 +1,2 @@
> p2/checkDynVersion
> p1/compile