Migrate the plugin to support SBT 2. #2

Merged
pfm merged 1 commit from sbt2 into main 2026-07-21 02:37:02 +00:00
14 changed files with 366 additions and 224 deletions

View file

@ -54,7 +54,8 @@ values for `release` are:
- `patch`
```
sbt -Drelease=minor publish
sbt setVersion minor release
sbt publish
```
## Supported Setting Keys

View file

@ -1,39 +1,21 @@
ThisBuild / scalaVersion := "3.8.4"
ThisBuild / organizationName := "garrity software"
ThisBuild / organization := "gs"
ThisBuild / versionScheme := Some("semver-spec")
externalResolvers := Seq(
"Garrity Software Releases" at "https://maven.garrity.co/gs",
"Garrity Software Maven Mirror" at "https://maven.garrity.co/releases"
)
/* externalResolvers := Seq( "Garrity Software Releases" at
* "https://maven.garrity.co/gs", "Garrity Software Maven Mirror" at
* "https://maven.garrity.co/releases" ) */
resolvers += Resolver.mavenLocal
val ProjectName: String = "sbt-gs-semver"
val Description: String = "SBT 1.9.0+ plugin for Git-based semantic versioning."
val Description: String = "SBT 2.0.0+ plugin for Git-based semantic versioning."
def getProperty[A](
name: String,
conv: String => A
): Option[A] =
Option(System.getProperty(name)).map(conv)
val CurrentVersion: String = "0.4.0-SNAPSHOT"
val VersionProperty: String = "version"
val ReleaseProperty: String = "release"
lazy val InputVersion: Option[String] =
getProperty(VersionProperty, identity)
lazy val IsRelease: Boolean =
getProperty(ReleaseProperty, _.toBoolean).getOrElse(false)
lazy val Modifier: String =
if (IsRelease) "" else "-SNAPSHOT"
val DefaultVersion: String = "0.1.0-SNAPSHOT"
lazy val SelectedVersion: String =
InputVersion
.map(v => s"$v$Modifier")
.getOrElse(DefaultVersion)
def isRelease(): Boolean =
!CurrentVersion.contains("SNAPSHOT")
lazy val publishSettings = Seq(
publishMavenStyle := true,
@ -47,14 +29,16 @@ lazy val publishSettings = Seq(
),
description := Description,
licenses := List(
"MIT" -> url("https://garrity.co/MIT.html")
"MIT" -> url(
s"https://git.garrity.co/garrity-software/$ProjectName/LICENSE"
)
),
homepage := Some(
url(s"https://git.garrity.co/garrity-software/$ProjectName")
),
publishTo := {
val repo = "https://maven.garrity.co/"
if (!IsRelease)
if (!isRelease())
Some("Garrity Software Maven" at repo + "snapshots")
else
Some("Garrity Software Maven" at repo + "gs")
@ -66,21 +50,26 @@ lazy val root = Project(ProjectName, file("."))
.settings(publishSettings)
.settings(
name := ProjectName,
version := SelectedVersion,
version := CurrentVersion,
libraryDependencies ++= Seq(
"com.lihaoyi" %% "os-lib" % "0.9.3"
),
pluginCrossBuild / sbtVersion := {
scalaBinaryVersion.value match {
// Minimum SBT version compatible with this plugin.
case "2.12" => "1.9.0"
}
"2.0.0"
},
scalacOptions := Seq(
"-unchecked",
"-encoding",
"utf8",
"-deprecation",
"-feature",
"-encoding",
"utf8"
"-explain",
"-unchecked",
"-explain-types",
"-language:strictEquality",
"-Wunused:implicits",
"-Wunused:explicits",
"-Wunused:imports",
"-Wunused:locals",
"-Wunused:privates"
)
)

View file

@ -1 +1 @@
sbt.version=1.9.9
sbt.version=2.0.3

View file

@ -0,0 +1,43 @@
package gs
/** Defines build behavior.
*
* - `snapshot`: Produce a snapshot, appends `-SNAPSHOT` to the version.
* - `release`: Produce a release build. No version string augmentations.
*
* @param name
* The formal name of the build type.
*/
sealed abstract class BuildType(val name: String):
override def equals(obj: Any): Boolean =
obj match
case other: BuildType => name == other.name
case _ => false
override def hashCode(): Int = name.hashCode()
override def toString(): String = name
object BuildType:
given CanEqual[BuildType, BuildType] = CanEqual.derived
case object Release extends BuildType("release")
case object Snapshot extends BuildType("snapshot")
def parse(candidate: String): Option[BuildType] =
candidate.trim().toLowerCase() match {
case Release.name => Some(Release)
case Snapshot.name => Some(Snapshot)
case _ => None
}
def parse(
candidate: String,
defaultValue: BuildType
): BuildType =
parse(candidate).getOrElse(defaultValue)
def isValid(candidate: String): Boolean =
candidate.equalsIgnoreCase(Release.name)
|| candidate.equalsIgnoreCase(Snapshot.name)

View file

@ -1,9 +1,6 @@
package gs
import java.io.File
import scala.util.Try
object Git {
object Git:
/** Get the latest tag on this repository and attempt to parse it as a
* [[SemVer]]. This value is retrieved using the `git` command. If for some
@ -16,7 +13,7 @@ object Git {
* @return
* The latest semantic version according to the current Git repository.
*/
def getLatestSemVer(): SemVer = {
def getLatestSemVer(): SemVer =
// Suppress all error output. Suppress exceptions for the failed process.
// Capture the standard output and parse it if the command succeeds.
val result = os
@ -38,6 +35,3 @@ object Git {
val processOutput = result.out.text().trim()
SemVer.parse(processOutput).getOrElse(SemVer.DefaultVersion)
}
}
}

View file

@ -1,8 +0,0 @@
package gs
case class InvalidSemVerException(candidate: String) extends RuntimeException {
override def getMessage(): String =
s"The candidate SemVer '$candidate' is not valid."
}

View file

@ -1,65 +0,0 @@
package gs
import sbt._
/** Helper for managing all SBT command line properties (`-Dproperty=value`).
*/
object PluginProperties {
/** Helper to extract the value from `-Dproperty=value`.
*
* @param name
* The property name.
* @param conv
* The conversion function to the output type.
* @return
* The converted value, or `None` if no value exists.
*/
def getProperty[A](
name: String,
conv: String => A
): Option[A] =
Option(System.getProperty(name)).map(conv)
/** Use one of the following to calculate an incremented release version:
*
* - `sbt -Drelease=major`
* - `sbt -Drelease=minor`
* - `sbt -Drelease=patch`
*/
val ReleaseProperty: String = "release"
/** If true, or no release type is set, this build is treated as a
* pre-release, and the suffix `-SNAPSHOT` is appended to the version.
*/
val SnapshotProperty: String = "snapshot"
/** The value of `-Drelease=<value>`, parsed and validated. Influences the
* final calculated [[SemVer]].
*
* @return
* The version passed as input to SBT.
*/
def getReleaseType(): Option[ReleaseType] =
getProperty(
ReleaseProperty,
raw =>
ReleaseType.parse(raw) match {
case scala.util.Success(value) => value
case scala.util.Failure(e) => throw e
}
)
/** The value of `-Dsnapshot=<value>`, parsed and validated. If parsing
* fails or no value is present, `false` is returned. Influences whether
* the pre-release suffix (`-SNAPSHOT`) is appended to the version.
*
* @return
* `true` if `-Dsnapshot=true` is specified at the command line, `false`
* otherwise.
*/
def isSnapshot(): Boolean =
getProperty(SnapshotProperty, raw => raw.trim().equalsIgnoreCase("true"))
.getOrElse(false)
}

View file

@ -1,25 +1,47 @@
package gs
import scala.util.Failure
import scala.util.Success
import scala.util.Try
/** Defines versioning behavior.
*
* - `major`: Increment the major version. `1.2.3 => 2.0.0`
* - `minor`: Increment the minor version. `1.2.3 => 1.3.0`
* - `patch`: Increment the patch version. `1.2.3 => 1.2.4`
*
* @param name
* The formal name of the release type.
*/
sealed abstract class ReleaseType(val name: String):
sealed abstract class ReleaseType(val name: String)
override def equals(obj: Any): Boolean =
obj match
case other: ReleaseType => name == other.name
case _ => false
override def hashCode(): Int = name.hashCode()
override def toString(): String = name
object ReleaseType:
given CanEqual[ReleaseType, ReleaseType] = CanEqual.derived
object ReleaseType {
case object Major extends ReleaseType("major")
case object Minor extends ReleaseType("minor")
case object Patch extends ReleaseType("patch")
def parse(candidate: String): Try[ReleaseType] =
def parse(candidate: String): Option[ReleaseType] =
candidate.trim().toLowerCase() match {
case Major.name => Success(Major)
case Minor.name => Success(Minor)
case Patch.name => Success(Patch)
case _ =>
Failure(
new IllegalArgumentException(s"Invalid release type: '$candidate'")
)
case Major.name => Some(Major)
case Minor.name => Some(Minor)
case Patch.name => Some(Patch)
case _ => None
}
}
def parse(
candidate: String,
defaultValue: ReleaseType
): ReleaseType =
parse(candidate).getOrElse(defaultValue)
def isValid(candidate: String): Boolean =
candidate.equalsIgnoreCase(Major.name)
|| candidate.equalsIgnoreCase(Minor.name)
|| candidate.equalsIgnoreCase(Patch.name)

View file

@ -1,6 +1,8 @@
package gs
import scala.util.matching.Regex
import sjsonnew.*
import sjsonnew.BasicJsonProtocol.*
/** Simplified representation of SemVer. Only supports major, minor and patch
* versions. Not intended to be used as a generalized SemVer implementation.
@ -35,10 +37,10 @@ case class SemVer(
* @return
* The string rendition of this version.
*/
def render(isSnapshot: Boolean): String = {
val version = toString()
if (isSnapshot) version + "-SNAPSHOT" else version
}
def render(buildType: BuildType): String =
buildType match
case BuildType.Release => s"$major.$minor.$patch"
case BuildType.Snapshot => s"$major.$minor.$patch-SNAPSHOT"
/** @return
* Copy of this SemVer with the major version incremented by 1.
@ -63,18 +65,58 @@ case class SemVer(
* Copy of this SemVer with the patch version incremented by 1.
*/
def incrementPatch(): SemVer = copy(patch = this.patch + 1)
def applyReleaseType(releaseType: ReleaseType): SemVer =
releaseType match
case ReleaseType.Major => incrementMajor()
case ReleaseType.Minor => incrementMinor()
case ReleaseType.Patch => incrementPatch()
}
object SemVer {
/** Version 0.1.0
object SemVer:
/** Version 0.1.0 -- the default version.
*/
val DefaultVersion: SemVer = SemVer(0, 1, 0)
given CanEqual[SemVer, SemVer] = CanEqual.derived
// Required for SBT serialization.
given JsonFormat[SemVer] = new JsonFormat[SemVer] {
override def write[J](
obj: SemVer,
builder: Builder[J]
): Unit =
builder.beginObject()
builder.addField("major", obj.major)
builder.addField("minor", obj.minor)
builder.addField("patch", obj.patch)
builder.endObject()
override def read[J](
jsOpt: Option[J],
unbuilder: Unbuilder[J]
): SemVer =
jsOpt match
case Some(js) =>
unbuilder.beginObject(js)
val major = unbuilder.readField[Int]("major")
val minor = unbuilder.readField[Int]("minor")
val patch = unbuilder.readField[Int]("patch")
unbuilder.endObject()
SemVer(major, minor, patch)
case None =>
deserializationError("Expected JsObject but found None")
}
private val SemVerPattern: Regex =
"^(0|(?:[1-9][0-9]*))\\.(0|(?:[1-9][0-9]*))\\.(0|(?:[1-9][0-9]*))$".r
/** Attempt to parse the input string as a [[SemVer]].
*
* This does not handle or produce snapshot versions.
*
* @param candidate
* The candidate string to parse.
@ -82,11 +124,8 @@ object SemVer {
* The parsed [[SemVer]], or `None` if parsing failed.
*/
def parse(candidate: String): Option[SemVer] =
candidate match {
candidate match
case SemVerPattern(major, minor, patch) =>
Some(SemVer(major.toInt, minor.toInt, patch.toInt))
case _ =>
None
}
}

View file

@ -0,0 +1,19 @@
package gs
object SemVerCalculator:
def update(
baseVersion: SemVer,
releaseType: ReleaseType,
buildType: BuildType
): (SemVer, String) =
val updated = baseVersion.applyReleaseType(releaseType)
updated -> updated.render(buildType)
def updateGit(
releaseType: ReleaseType,
buildType: BuildType
): (SemVer, String) =
update(Git.getLatestSemVer(), releaseType, buildType)
end SemVerCalculator

View file

@ -1,10 +1,17 @@
package gs
import sbt._
import sbt.*
/** Defines all setting and task keys for the GS SemVer SBT Plugin.
*/
object SemVerKeys {
object SemVerKeys:
/** SBT Setting for the calculated SemVer string, to be used for all project
* versions.
*/
lazy val semVer = settingKey[String](
"Rendered version calculated by the SemVer plugin."
)
/** SBT Setting for the latest known project version (before this build).
*
@ -19,30 +26,9 @@ object SemVerKeys {
/** SBT Setting for the **selected** project version.
*
* Use this value to set your project version:
*
* {{{
* version := semVerSelected.value
* }}}
*
* This value should be used as the version for all project artifacts. It is
* automatically derived based on the `semVerCurrent` setting, the value of
* the `release` property, and the value of the `snapshot` property:
*
* Consider the example current version of `1.2.3`:
*
* - `-Drelease=major`: `semVerSelected = 2.0.0`
* - `-Drelease=major`, `-Dsnapshot=true`: `semVerSelected =
* 2.0.0-SNAPSHOT`
* - `-Drelease=minor`: `semVerSelected = 1.3.0`
* - `-Drelease=minor`, `-Dsnapshot=true`: `semVerSelected =
* 1.3.0-SNAPSHOT`
* - `-Drelease=patch`: `semVerSelected = 1.2.4`
* - `-Drelease=patch`, `-Dsnapshot=true`: `semVerSelected =
* 1.2.4-SNAPSHOT`
* - `release` not set: `semVerSelected = 1.2.4-SNAPSHOT`
* Use the `semVer` setting to assign project versions.
*/
lazy val semVerSelected = settingKey[String](
lazy val semVerSelected = settingKey[SemVer](
"Version selected for the current build. Depends on the release type and current version."
)
@ -79,5 +65,3 @@ object SemVerKeys {
lazy val semVerWriteVersionToFile = taskKey[Unit](
"Write the selected SemVer to a file."
)
}

View file

@ -3,76 +3,77 @@ package gs
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Paths
import sbt._
import sbt.*
import sbt.Keys.*
object SemVerPlugin extends AutoPlugin {
object SemVerPlugin extends AutoPlugin:
override def trigger = allRequirements
val autoImport = SemVerKeys
import autoImport._
import autoImport.*
val DefaultOutputFile: String = ".version"
object Defaults:
/** The version is persisted to this file when the
* `semVerWriteVersionToFile` task is invoked.
*/
val VersionOutputFile: String = ".version"
end Defaults
// Perform all version calculations and expose as a variable.
lazy val semVerDefaults: Seq[Setting[_]] = {
// The latest semantic version relevant to this project, calculated by
// looking at the latest Git tag.
val latestSemVer =
Git.getLatestSemVer()
/** Default settings used to initialize projects.
*
* Calculates the default semantic version based on the repository state (git
* tags) and configured release type and build type.
*/
lazy val semVerDefaults: Seq[Setting[?]] = {
val latestSemVer = Git.getLatestSemVer()
val releaseType = SemVerProps.getReleaseType()
val buildType = SemVerProps.getBuildType()
val calculated = SemVerCalculator.update(
baseVersion = latestSemVer,
releaseType = releaseType,
buildType = buildType
)
// The selected release type (if any), determined by looking at the SBT
// command line and checking if `-Drelease=<release-type>` was specified.
val releaseType =
PluginProperties.getReleaseType()
// See whether this is a pre-release build or not. If it is (if this value
// is true), the pre-release suffix `-SNAPSHOT` will be appended to the
// calculated version.
val isSnapshot =
PluginProperties.isSnapshot() || releaseType.isEmpty
// Calculate the appropriate semantic version for this build of the project.
val selectedSemVer = releaseType match {
case Some(ReleaseType.Major) => latestSemVer.incrementMajor()
case Some(ReleaseType.Minor) => latestSemVer.incrementMinor()
case Some(ReleaseType.Patch) => latestSemVer.incrementPatch()
case None =>
// Note: this doesn't play nicely with repositories that have
// pre-release versions before 0.1.0.
if (latestSemVer == SemVer.DefaultVersion)
latestSemVer
else
latestSemVer.incrementPatch()
}
// Expose the relevant values as setting keys.
Seq(
commands += SetVersion.command,
semVer := calculated._2,
semVerCurrent := latestSemVer.toString(),
semVerSelected := selectedSemVer.render(isSnapshot),
semVerMajor := selectedSemVer.major,
semVerSnapshot := isSnapshot,
semVerOutputFile := Some(DefaultOutputFile)
semVerSelected := calculated._1,
semVerSnapshot := buildType == BuildType.Snapshot,
semVerMajor := calculated._1.major,
semVerOutputFile := Some(Defaults.VersionOutputFile),
version := calculated._2
)
}
// Automatically exposed globally.
override lazy val globalSettings: Seq[Setting[_]] = semVerDefaults
/** @inheritDocs
*/
override lazy val globalSettings: Seq[Setting[?]] = semVerDefaults
// Add the custom task.
override lazy val buildSettings: Seq[Setting[_]] = Seq(
semVerInfo := {
val log = Keys.streams.value.log
/** @inheritDocs
*/
override lazy val buildSettings: Seq[Setting[?]] = Seq(
// Display information about the build.
// Do not cache this task, so that the updated version is reported each
// time it is called.
semVerInfo := Def.uncached {
val log = streams.value.log
log.info(s"[SemVer] Current: ${semVerCurrent.value}")
log.info(s"[SemVer] Selected: ${semVerSelected.value}")
log.info(s"[SemVer] Snapshot: ${semVerSnapshot.value}")
log.info(s"[SemVer] Final String: ${semVer.value}")
log.info(s"[SemVer] Project Version: ${version.value}")
},
semVerWriteVersionToFile := {
val outputFile = semVerOutputFile.value.getOrElse(DefaultOutputFile)
// Dump the build information to file.
// Do not cache this task, so that it can run multiple times within the same
// session.
semVerWriteVersionToFile := Def.uncached {
val outputFile =
semVerOutputFile.value.getOrElse(Defaults.VersionOutputFile)
Files.write(
Paths.get(outputFile),
semVerSelected.value.getBytes(StandardCharsets.UTF_8)
semVerSelected.value.toString().getBytes(StandardCharsets.UTF_8)
)
}
)
}

View file

@ -0,0 +1,20 @@
package gs
object SemVerProps:
val SemVerReleaseType: String = "semver.release_type"
val SemVerBuildType: String = "semver.build_type"
def getReleaseType(): ReleaseType =
sys.props
.get(SemVerReleaseType)
.flatMap(ReleaseType.parse)
.getOrElse(ReleaseType.Patch)
def getBuildType(): BuildType =
sys.props
.get(SemVerBuildType)
.flatMap(BuildType.parse)
.getOrElse(BuildType.Snapshot)
end SemVerProps

View file

@ -0,0 +1,103 @@
package gs
import sbt.*
import sbt.Keys.*
/** Custom command used to update semantic versions for the project.
* Recalculates versions using the input [[ReleaseType]] and [[BuildType]].
*/
object SetVersion:
/** The unique name of the command. Used to invoke the command using SBT.
*
* For example: `sbt:project-name> setVersion patch snapshot`
*/
val commandName: String = "setVersion"
/** CLI help documentation for this command.
*/
val detailedHelpString: String =
"""|setVersion [release-type] [build-type]
|
| Reconfigures the build version using SemVer. Based on the latest
| version as detected from Git tags. If no Git tags exist, the default
| version 0.1.0 is used as a baseline.
|
| - [release-type] one of major|minor|patch (default=patch)
| + major: 1.2.3 becomes 2.0.0
| + minor: 1.2.3 becomes 1.3.0
| + patch: 1.2.3 becomes 1.2.4
| - [build-type] one of release|snapshot (default=snapshot)
| + release: The SemVer is emitted as-is.
| + snapshot: The SemVer has the string "-SNAPSHOT" appended.
|
| Snapshot builds look like 1.2.3-SNAPSHOT.
|""".stripMargin
val help: Help = Help(
name = commandName,
briefHelp = (commandName, "Reconfigures the build version using SemVer."),
detail = detailedHelpString
)
/** The Command implementation. Added to projects via `semVerDefaults` within
* the [[SemVerPlugin]].
*/
val command: Command = Command.args(commandName, commandName, help) {
(
state,
args
) =>
val logger = state.log
val (calculated, rendered, rt, bt) = calculateVersion(args)
logger.info(
s"Calculated new SemVer: '$rendered' [Release Type = $rt, Build Type = $bt]"
)
import SemVerKeys.*
// "Update" the project state by appending new values to the session DAG.
Project
.extract(state)
.appendWithSession(
settings = Seq(
ThisBuild / version := rendered,
ThisBuild / semVer := rendered,
ThisBuild / semVerSelected := calculated,
ThisBuild / semVerMajor := calculated.major,
ThisBuild / semVerSnapshot := bt == BuildType.Snapshot
),
state = state
)
}
private def calculateVersion(args: Seq[String])
: (SemVer, String, ReleaseType, BuildType) =
// These are the current values cached in state. Depending on the
// arguments to this command, these properties may be overridden.
val currentReleaseType = SemVerProps.getReleaseType()
val currentBuildType = SemVerProps.getBuildType()
val (newReleaseType, newBuildType) = args match {
case r :: b :: _ =>
(
ReleaseType.parse(r, currentReleaseType),
BuildType.parse(b, currentBuildType)
)
case r :: Nil =>
(ReleaseType.parse(r, currentReleaseType), currentBuildType)
case Nil => (currentReleaseType, currentBuildType)
}
sys.props(SemVerProps.SemVerReleaseType) = newReleaseType.name
sys.props(SemVerProps.SemVerBuildType) = newBuildType.name
val calculated = SemVerCalculator.updateGit(
releaseType = newReleaseType,
buildType = newBuildType
)
(calculated._1, calculated._2, newReleaseType, newBuildType)
end SetVersion