From 5f94372a45291db00d72a6c99b70a90912cd2679 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Sun, 7 Jan 2024 22:22:36 -0600 Subject: [PATCH] Completed a fully functional plugin for SBT 1.9.0+. --- build.sbt | 69 ++++++++++++++++++++++-- src/main/scala/gs/Git.scala | 58 ++++++++++---------- src/main/scala/gs/PluginProperties.scala | 65 ++++++++++++++++++++++ src/main/scala/gs/ReleaseType.scala | 25 +++++++++ src/main/scala/gs/SemVer.scala | 55 ++++++++++++++++++- src/main/scala/gs/SemVerKeys.scala | 54 +++++++++++++++---- src/main/scala/gs/SemVerPlugin.scala | 58 +++++++++++++++++--- 7 files changed, 335 insertions(+), 49 deletions(-) create mode 100644 src/main/scala/gs/PluginProperties.scala create mode 100644 src/main/scala/gs/ReleaseType.scala diff --git a/build.sbt b/build.sbt index b9747e9..118dc4b 100644 --- a/build.sbt +++ b/build.sbt @@ -1,5 +1,5 @@ -ThisBuild / version := "0.1.0-SNAPSHOT" -ThisBuild / organization := "gs" +ThisBuild / organizationName := "garrity software" +ThisBuild / organization := "gs" ThisBuild / homepage := Some( url("https://git.garrity.co/garrity-software/gs-semver-sbt-plugin") @@ -9,12 +9,71 @@ ThisBuild / licenses := Seq( "Apache-2.0" -> url("http://www.apache.org/license/LICENSE-2.0") ) -lazy val root = Project("gs-semver-sbt-plugin", file(".")) +externalResolvers := Seq( + "Garrity Software Releases" at "https://maven.garrity.co/releases" +) + +val ProjectName: String = "gs-semver-sbt-plugin" +val Description: String = "SBT 1.9.0+ plugin for Git-based semantic versioning." + +def getProperty[A]( + name: String, + conv: String => A +): Option[A] = + Option(System.getProperty(name)).map(conv) + +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) + +lazy val publishSettings = Seq( + publishMavenStyle := true, + Test / publishArtifact := false, + pomIncludeRepository := Function.const(false), + scmInfo := Some( + ScmInfo( + url(s"https://git.garrity.co/garrity-software/$ProjectName"), + s"git@git.garrity.co:garrity-software/$ProjectName.git" + ) + ), + description := Description, + licenses := List( + "Apache 2.0" -> url("https://www.apache.org/licenses/LICENSE-2.0.html") + ), + homepage := Some( + url(s"https://git.garrity.co/garrity-software/$ProjectName") + ), + publishTo := { + val repo = "https://maven.garrity.co/" + if (!IsRelease) + Some("Garrity Software Maven" at repo + "snapshots") + else + Some("Garrity Software Maven" at repo + "releases") + } +) + +lazy val root = Project(ProjectName, file(".")) .enablePlugins(SbtPlugin) .settings( - name := "gs-semver-sbt-plugin", + name := ProjectName, + version := SelectedVersion, libraryDependencies ++= Seq( - "org.eclipse.jgit" % "org.eclipse.jgit" % "6.8.0.202311291450-r" + "com.lihaoyi" %% "os-lib" % "0.9.3" ), pluginCrossBuild / sbtVersion := { scalaBinaryVersion.value match { diff --git a/src/main/scala/gs/Git.scala b/src/main/scala/gs/Git.scala index b6dd340..7f68e04 100644 --- a/src/main/scala/gs/Git.scala +++ b/src/main/scala/gs/Git.scala @@ -1,38 +1,40 @@ package gs import java.io.File -import org.eclipse.jgit.lib.Constants -import org.eclipse.jgit.lib.Repository -import org.eclipse.jgit.revwalk.RevWalk -import org.eclipse.jgit.storage.file.FileRepositoryBuilder -import scala.collection.JavaConverters._ import scala.util.Try object Git { - private lazy val repo: Repository = { - val builder = new FileRepositoryBuilder() - builder.setGitDir(new File(".")).findGitDir().build() + /** 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 + * reason the call fails, there are no tags, or the tag value cannot be + * parsed, the default [[SemVer]] (`0.1.0`) is used. + * + * This function assumes the current directory is a Git repository. The + * command executed is: `git describe --tags --abbrev=0` + * + * @return + * The latest semantic version according to the current Git repository. + */ + 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 + .proc("git", "describe", "--tags", "--abbrev=0") + .call( + stderr = os.ProcessOutput( + ( + _, + _ + ) => () + ), + check = false + ) + + if (result.exitCode != 0) + SemVer.DefaultVersion + else + SemVer.parse(result.out.text()).getOrElse(SemVer.DefaultVersion) } - def getLatestSemVer(): Option[SemVer] = { - val walk = new RevWalk(repo) - val result = repo - .getRefDatabase() - .getRefsByPrefix(Constants.R_TAGS) - .asScala - .map(ref => walk.parseTag(ref.getObjectId())) - .sortBy(_.getTaggerIdent().getWhenAsInstant()) - .headOption - .map(_.getTagName()) - .map(normalizeTagName) - .flatMap(SemVer.parse) - - walk.dispose() - result - } - - private def normalizeTagName(tag: String): String = - tag.trim().stripPrefix("v") - } diff --git a/src/main/scala/gs/PluginProperties.scala b/src/main/scala/gs/PluginProperties.scala new file mode 100644 index 0000000..f00bed2 --- /dev/null +++ b/src/main/scala/gs/PluginProperties.scala @@ -0,0 +1,65 @@ +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=`, 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=`, 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) + +} diff --git a/src/main/scala/gs/ReleaseType.scala b/src/main/scala/gs/ReleaseType.scala new file mode 100644 index 0000000..5573b08 --- /dev/null +++ b/src/main/scala/gs/ReleaseType.scala @@ -0,0 +1,25 @@ +package gs + +import scala.util.Failure +import scala.util.Success +import scala.util.Try + +sealed abstract class ReleaseType(val name: String) + +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] = + 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'") + ) + } + +} diff --git a/src/main/scala/gs/SemVer.scala b/src/main/scala/gs/SemVer.scala index 06f3a2e..5a1ae44 100644 --- a/src/main/scala/gs/SemVer.scala +++ b/src/main/scala/gs/SemVer.scala @@ -2,23 +2,76 @@ package gs import scala.util.matching.Regex +/** Simplified representation of SemVer. Only supports major, minor and patch + * versions. Not intended to be used as a generalized SemVer implementation. + * + * @param major + * The major version. + * @param minor + * The minor version. + * @param patch + * The patch version. + */ case class SemVer( major: Int, minor: Int, patch: Int ) { + /** Render this SemVer as: `Major.Minor.Patch` + * + * @return + * The `Major.Minor.Patch` representation of this SemVer. + */ override def toString(): String = s"$major.$minor.$patch" + /** Render this SemVer as: `Major.Minor.Patch`, and append a suffix, + * `-SNAPSHOT` if a snapshot rendition is requested. + * + * @param isSnapshot + * Requests a snapshot rendition if true. Uses the `-SNAPSHOT` pre-release + * suffix. + * @return + * The string rendition of this version. + */ + def render(isSnapshot: Boolean): String = { + val version = toString() + if (isSnapshot) version + "-SNAPSHOT" else version + } + + /** @return + * Copy of this SemVer with the major version incremented by 1. + */ + def incrementMajor(): SemVer = copy(major = this.major + 1) + + /** @return + * Copy of this SemVer with the minor version incremented by 1. + */ + def incrementMinor(): SemVer = copy(minor = this.minor + 1) + + /** @return + * Copy of this SemVer with the patch version incremented by 1. + */ def incrementPatch(): SemVer = copy(patch = this.patch + 1) } object SemVer { - val SemVerPattern: Regex = + /** Version 0.1.0 + */ + val DefaultVersion: SemVer = SemVer(0, 1, 0) + + 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]]. + * + * @param candidate + * The candidate string to parse. + * @return + * The parsed [[SemVer]], or `None` if parsing failed. + */ def parse(candidate: String): Option[SemVer] = candidate match { case SemVerPattern(major, minor, patch) => diff --git a/src/main/scala/gs/SemVerKeys.scala b/src/main/scala/gs/SemVerKeys.scala index 8e02c0f..41cc77d 100644 --- a/src/main/scala/gs/SemVerKeys.scala +++ b/src/main/scala/gs/SemVerKeys.scala @@ -2,26 +2,62 @@ package gs import sbt._ +/** Defines all setting and task keys for the GS SemVer SBT Plugin. + */ object SemVerKeys { - /** SBT Setting Key for the latest known project version. + /** SBT Setting for the latest known project version (before this build). + * + * For setting your project version, please use the `semVerSelected` setting. * * This value is automatically derived from Git by looking at the most recent * tag. If the project does not yet have any tags, the value 0.1.0 is used. */ - lazy val latestVersion = settingKey[String]( + lazy val semVerCurrent = settingKey[String]( "Latest Git-tagged project version, or 0.1.0 if no tags exist." ) - /** SBT Setting Key for the working project version - used if there are any - * non-committed changes. + /** SBT Setting for the **selected** project version. * - * This value is automatically derived by taking the latest version, and - * either incrementing the revision by 1 or just using 0.1.0 if the project - * does not have any Git tags. + * 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.2.3` + * - `-Drelease=major`, `-Dsnapshot=true`: `semVerSelected = + * 2.2.3-SNAPSHOT` + * - `-Drelease=minor`: `semVerSelected = 1.3.3` + * - `-Drelease=minor`, `-Dsnapshot=true`: `semVerSelected = + * 1.3.3-SNAPSHOT` + * - `-Drelease=patch`: `semVerSelected = 1.2.4` + * - `-Drelease=patch`, `-Dsnapshot=true`: `semVerSelected = + * 1.2.4-SNAPSHOT` + * - `release` not set: `semVerSelected = 1.2.4-SNAPSHOT` */ - lazy val workingVersion = settingKey[String]( - "Revision level increment of the latest version, or 0.1.0 if no tags exist." + lazy val semVerSelected = settingKey[String]( + "Version selected for the current build. Depends on the release type and current version." + ) + + /** SBT Setting for the selected major version. This value is always the major + * version part of the `semVerSelected` value. + */ + lazy val semVerMajor = settingKey[Int]( + "Major version selected for the current build. Depends on the release type and current version." + ) + + /** Task which emits the current and selected versions at the informational + * log level. + */ + lazy val semVerInfo = taskKey[Unit]( + "Dump the calculated version information." ) } diff --git a/src/main/scala/gs/SemVerPlugin.scala b/src/main/scala/gs/SemVerPlugin.scala index 259fa22..40ca08c 100644 --- a/src/main/scala/gs/SemVerPlugin.scala +++ b/src/main/scala/gs/SemVerPlugin.scala @@ -1,6 +1,5 @@ package gs -import Keys._ import sbt._ object SemVerPlugin extends AutoPlugin { @@ -10,9 +9,56 @@ object SemVerPlugin extends AutoPlugin { import autoImport._ - /* override lazy val globalSettings: Seq[Setting[_]] = Seq( helloGreeting := - * "hi", ) - * - * override lazy val projectSettings: Seq[Setting[_]] = Seq( hello := { val s - * = streams.value val g = helloGreeting.value s.log.info(g) } ) */ + // 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() + + // The selected release type (if any), determined by looking at the SBT + // command line and checking if `-Drelease=` 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( + semVerCurrent := latestSemVer.toString(), + semVerSelected := selectedSemVer.render(isSnapshot), + semVerMajor := selectedSemVer.major + ) + } + + // Automatically exposed globally. + 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 + log.info(s"[SemVer] Current: ${semVerCurrent.value}") + log.info(s"[SemVer] Selected: ${semVerSelected.value}") + } + ) + }