Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify integration with sbt-release #187

Merged
merged 2 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 30 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,24 +211,12 @@ In this mode, you can use sbt-version-policy to check that incoming pull request
- compute the next release version according to its compatibility guarantees
1. set the key `releaseVersion` as follows:
~~~ scala
releaseVersion := {
val maybeBump = versionPolicyIntention.value match {
case Compatibility.None => Some(Version.Bump.Major)
case Compatibility.BinaryCompatible => Some(Version.Bump.Minor)
case Compatibility.BinaryAndSourceCompatible => None // No need to bump the patch version, because it has already been bumped when sbt-release set the next release version
}
{ (currentVersion: String) =>
val versionWithoutQualifier =
Version(currentVersion)
.getOrElse(versionFormatError(currentVersion))
.withoutQualifier
(maybeBump match {
case Some(bump) => versionWithoutQualifier.bump(bump)
case None => versionWithoutQualifier
}).string
}
}
import sbtversionpolicy.withsbtrelease.ReleaseVersion
releaseVersion := ReleaseVersion.fromCompatibility(versionPolicyIntention.value)
~~~
The `releaseVersion` function bumps the release version according to the compatibility guarantees defined
by `versionPolicyIntention`. Optionally, you can also define a _qualifier_ to append to the release version
by setting the environment variable `VERSION_POLICY_RELEASE_QUALIFIER` (e.g., `VERSION_POLICY_RELEASE_QUALIFIER=RC1`).
2. Reset `versionPolicyIntention` to `Compatibility.BinaryAndSourceCompatible` after every release.
This can be achieved by managing the setting `versionPolicyIntention` in a separate file (like [sbt-release] manages the setting `version` in a separate file, by default), and by adding a step that overwrites the content of that file and commits it.

Expand All @@ -237,34 +225,40 @@ In this mode, you can use sbt-version-policy to check that incoming pull request
You can have a look at the test [example-sbt-release](./sbt-version-policy/src/sbt-test/sbt-version-policy/example-sbt-release)
for an example of sbt project using both sbt-version-policy and sbt-release.

In that example, we also automatically reset the intended compatibility level to `BinaryAndSourceCompatible` as the last step of the release process.

#### Unconstrained compatibility level

In this mode, you can use sbt-version-policy to assess the incompatibilities introduced in the project since the last release and compute the new release version accordingly (ie, to bump the major version number if you introduced binary incompatibilities):

1. make sure `versionPolicyIntention` is not set
2. define `releaseVersion` from the compatibility level returned by `versionPolicyAssessCompatibility`
2. define `releaseVersion` from the compatibility level returned by `versionPolicyAssessCompatibility`:
~~~ scala
import sbtversionpolicy.withsbtrelease.ReleaseVersion

releaseVersion := {
ReleaseVersion.fromAssessedCompatibilityWithLatestRelease().value
}
~~~
Alternatively, if your project contains multiple modules, you want to use the aggregated assessed compatibility level:
~~~ scala
import sbtversionpolicy.withsbtrelease.ReleaseVersion

releaseVersion := {
val compatibilityWithPreviousReleases = versionPolicyAssessCompatibility.value
val compatibilityWithLastRelease = compatibilityWithPreviousReleases.head
val (_, compatibility) = compatibilityWithLastRelease
val maybeBump = compatibility match {
case Compatibility.None => Some(Version.Bump.Major)
case Compatibility.BinaryCompatible => Some(Version.Bump.Minor)
case Compatibility.BinaryAndSourceCompatible => None // No need to bump the patch version, because it has already been bumped when sbt-release set the next release version
}
{ (currentVersion: String) =>
val versionWithoutQualifier =
Version(currentVersion)
.getOrElse(versionFormatError(currentVersion))
.withoutQualifier
(maybeBump match {
case Some(bump) => versionWithoutQualifier.bump(bump)
case None => versionWithoutQualifier
}).string
}
ReleaseVersion.fromAggregatedAssessedCompatibilityWithLatestRelease().value
}
~~~
In both cases, the `releaseVersion` function sets the release version according to the compatibility level
with the latest release. Optionally, you can also define a _qualifier_ to append to the release version
by setting the environment variable `VERSION_POLICY_RELEASE_QUALIFIER` (e.g., `VERSION_POLICY_RELEASE_QUALIFIER="-RC1"`).

Note that for the first release you have to set the release version yourself via the file `version.sbt` (e.g., set
`1.0.0-SNAPSHOT` or `0.1.0-SNAPSHOT`). This is because `sbt-version-policy` needs a previous release to exist to be
able to assess the compatibility level of the current state of the project with that release.

##### Example

We demonstrate the “unconstrained” mode in [this example](./sbt-version-policy/src/sbt-test/sbt-version-policy/example-sbt-release-unconstrained).

## How does `versionPolicyCheck` work?

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package sbtversionpolicy.withsbtrelease

import sbtversionpolicy.Compatibility
import sbtversionpolicy.SbtVersionPolicyPlugin.aggregatedAssessedCompatibilityWithLatestRelease
import sbtversionpolicy.SbtVersionPolicyPlugin.autoImport.versionPolicyAssessCompatibility
import sbt.*
import sbtversionpolicy.SbtVersionPolicyMima.autoImport.versionPolicyPreviousVersions

/** Convenient methods to integrate with the plugin sbt-release */
object ReleaseVersion {

private val qualifierVariableName = "VERSION_POLICY_RELEASE_QUALIFIER"

/**
* @return a [release version function](https://github.com/sbt/sbt-release?tab=readme-ov-file#custom-versioning)
* that bumps the patch, minor, or major version number depending on the provided
* compatibility level.
* @param qualifier Optional qualifier to append to the version (e.g. `"-RC1"`). By default, it tries to read
* it from the environment variable VERSION_POLICY_RELEASE_QUALIFIER.
*/
def fromCompatibility(compatibility: Compatibility, qualifier: String = sys.env.getOrElse(qualifierVariableName, "")): String => String = {
val maybeBump =
compatibility match {
case Compatibility.None => Some(Version.Bump.Major)
case Compatibility.BinaryCompatible => Some(Version.Bump.Minor)
case Compatibility.BinaryAndSourceCompatible => None // No need to bump the patch version, because it has already been bumped when sbt-release set the next release version
}
{ (currentVersion: String) =>
val versionWithoutQualifier =
Version(currentVersion)
.getOrElse(Version.formatError(currentVersion))
.withoutQualifier
val bumpedVersion =
(maybeBump match {
case Some(bump) => versionWithoutQualifier.bump(bump)
case None => versionWithoutQualifier
}).string
bumpedVersion + qualifier
}
}

private def fromAssessedCompatibility(qualifier: String)(assessCompatibility: Def.Initialize[Task[Compatibility]]): Def.Initialize[Task[String => String]] =
Def.ifS(Def.task {
versionPolicyPreviousVersions.value.isEmpty
})(Def.task {
// If there are no previous versions to assess the compatibility with,
// fallback to the default release version function, which drops the qualifier
// from the version set in the file `version.sbt`
// (e.g., "1.0.0-SNAPSHOT" => "1.0.0")
(version: String) =>
Version(version)
.map(_.withoutQualifier.string + qualifier)
.getOrElse(Version.formatError(version))
Comment on lines +46 to +53
Copy link
Contributor

@rtyley rtyley Dec 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah clever, I like it! In order to work out the new version number, if there's no previously released version to compare compatibility with, we just read the current snapshot version specified in version.sbt (eg "1.0.0-SNAPSHOT") and use it without the qualifier (eg "1.0.0") 👍

})(Def.task {
val log = Keys.streams.value.log
val compatibility = assessCompatibility.value
log.debug(s"Compatibility level is ${compatibility}")
fromCompatibility(compatibility, qualifier)
})

/**
* Task returning a [release version function](https://github.com/sbt/sbt-release?tab=readme-ov-file#custom-versioning)
* based on the assessed compatibility level of the project.
*
* Use it in your `.sbt` build definition as follows:
*
* {{{
* import sbtversionpolicy.withsbtrelease.ReleaseVersion
*
* releaseVersion := ReleaseVersion.fromAssessedCompatibilityWithLatestRelease().value
* }}}
*
* sbt-release uses the `releaseVersion` function to set the version before publishing a release (at step
* `setReleaseVersion`). It reads the current `version` (usually defined in a file `version.sbt`, and looking
* like `"1.2.3-SNAPSHOT"`), and applies the function to it.
*
* @param qualifier Optional qualifier to append to the version (e.g. `"-RC1"`). By default, it tries to read
* it from the environment variable VERSION_POLICY_RELEASE_QUALIFIER.
*/
def fromAssessedCompatibilityWithLatestRelease(
qualifier: String = sys.env.getOrElse(qualifierVariableName, "")
): Def.Initialize[Task[String => String]] =
fromAssessedCompatibility(qualifier)(Def.task {
val compatibilityResults = versionPolicyAssessCompatibility.value
val compatibilityWithLatestRelease =
compatibilityResults.headOption
.getOrElse(throw new MessageOnlyException("Unable to assess the compatibility level of this project."))
val (_, compatibility) = compatibilityWithLatestRelease
compatibility
})

/**
* Task returning a [release version function](https://github.com/sbt/sbt-release?tab=readme-ov-file#custom-versioning)
* based on the assessed compatibility level of the project (ie, the highest level of compatibility
* satisfied by all the sub-projects aggregated by this project).
*
* Use it in the root project of your `.sbt` build definition as follows:
*
* {{{
* import sbtversionpolicy.withsbtrelease.ReleaseVersion
*
* val `my-project` =
* project
* .in(file("."))
* .aggregate(mySubproject1, mySubproject2)
* .settings(
* releaseVersion := ReleaseVersion.fromAggregatedAssessedCompatibilityWithLatestRelease().value
* )
* }}}
*
* sbt-release uses the `releaseVersion` function to set the version before publishing a release (at step
* `setReleaseVersion`). It reads the current `version` (usually defined in a file `version.sbt`, and looking
* like `"1.2.3-SNAPSHOT"`), and applies the function to it.
*
* @param qualifier Optional qualifier to append to the version (e.g. `"-RC1"`). By default, it tries to read
* it from the environment variable VERSION_POLICY_RELEASE_QUALIFIER.
*/
def fromAggregatedAssessedCompatibilityWithLatestRelease(
qualifier: String = sys.env.getOrElse(qualifierVariableName, "")
): Def.Initialize[Task[String => String]] =
fromAssessedCompatibility(qualifier)(aggregatedAssessedCompatibilityWithLatestRelease)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package sbtversionpolicy.withsbtrelease

// All the code below has been copied from https://github.com/sbt/sbt-release/blob/master/src/main/scala/Version.scala

import util.control.Exception._

private[withsbtrelease] object Version {
sealed trait Bump {
def bump: Version => Version
}

object Bump {
case object Major extends Bump { def bump = _.bumpMajor }
case object Minor extends Bump { def bump = _.bumpMinor }
case object Bugfix extends Bump { def bump = _.bumpBugfix }
case object Nano extends Bump { def bump = _.bumpNano }
case object Next extends Bump { def bump = _.bump }

val default = Next
}

val VersionR = """([0-9]+)((?:\.[0-9]+)+)?([\.\-0-9a-zA-Z]*)?""".r
val PreReleaseQualifierR = """[\.-](?i:rc|m|alpha|beta)[\.-]?[0-9]*""".r

def apply(s: String): Option[Version] = {
allCatch opt {
val VersionR(maj, subs, qual) = s
// parse the subversions (if any) to a Seq[Int]
val subSeq: Seq[Int] = Option(subs) map { str =>
// split on . and remove empty strings
str.split('.').filterNot(_.trim.isEmpty).map(_.toInt).toSeq
} getOrElse Nil
Version(maj.toInt, subSeq, Option(qual).filterNot(_.isEmpty))
}
}

def formatError(version: String) = sys.error(s"Version [$version] format is not compatible with " + Version.VersionR.pattern.toString)
}

private[withsbtrelease] case class Version(major: Int, subversions: Seq[Int], qualifier: Option[String]) {
def bump = {
val maybeBumpedPrerelease = qualifier.collect {
case Version.PreReleaseQualifierR() => withoutQualifier
}
def maybeBumpedLastSubversion = bumpSubversionOpt(subversions.length-1)
def bumpedMajor = copy(major = major + 1)

maybeBumpedPrerelease
.orElse(maybeBumpedLastSubversion)
.getOrElse(bumpedMajor)
}

def bumpMajor = copy(major = major + 1, subversions = Seq.fill(subversions.length)(0))
def bumpMinor = maybeBumpSubversion(0)
def bumpBugfix = maybeBumpSubversion(1)
def bumpNano = maybeBumpSubversion(2)

def maybeBumpSubversion(idx: Int) = bumpSubversionOpt(idx) getOrElse this

private def bumpSubversionOpt(idx: Int) = {
val bumped = subversions.drop(idx)
val reset = bumped.drop(1).length
bumped.headOption map { head =>
val patch = (head+1) +: Seq.fill(reset)(0)
copy(subversions = subversions.patch(idx, patch, patch.length))
}
}

def bump(bumpType: Version.Bump): Version = bumpType.bump(this)

def withoutQualifier = copy(qualifier = None)
def asSnapshot = copy(qualifier = Some("-SNAPSHOT"))

def string = "" + major + mkString(subversions) + qualifier.getOrElse("")

private def mkString(parts: Seq[Int]) = parts.map("."+_).mkString
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
target/
.bsp/
global/
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import sbtversionpolicy.withsbtrelease.ReleaseVersion
import sbtrelease._
import ReleaseTransformations._

inThisBuild(List(
organization := "org.organization",
homepage := Some(url("https://github.com/organization/project")),
licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")),
developers := List(
Developer(
"julienrf",
"Julien Richard-Foy",
"julien@organization.org",
url("https://github.com/julienrf")
)
),
scalaVersion := "3.0.1"
))

val module =
project
.settings(
name := "sbt-release-unconstrained-test"
)

val root = project.in(file("."))
.aggregate(module)
.settings(
// Tell sbt-release to set the release version based on the level of compatibility with the previous release
releaseVersion := ReleaseVersion.fromAggregatedAssessedCompatibilityWithLatestRelease().value,
// Custom release process for testing purpose only: the artifacts are locally published,
// and we don’t push to the remote repository.
releaseProcess := Seq[ReleaseStep](
checkSnapshotDependencies,
inquireVersions,
runClean,
runTest,
setReleaseVersion,
commitReleaseVersion,
tagRelease,
releaseStepTask(module / publishLocal), // Publish locally for our tests only, in practice you will publish to Sonatype
setNextVersion,
commitNextVersion,
// pushChanges // Disable pushing the changes to the remote repository for our tests only
)
)

TaskKey[Unit]("checkTag_1_0_0") := {
import scala.sys.process._
assert("git describe --tags".lineStream.exists(_.contains("v1.0.0")))
}

TaskKey[Unit]("checkTag_1_1_0") := {
import scala.sys.process._
assert("git describe --tags".lineStream.exists(_.contains("v1.1.0")))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.organization.module

class NewAPI
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.organization.module

class Dummy
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
addSbtPlugin("ch.epfl.scala" % "sbt-version-policy" % sys.props("plugin.version"))
addSbtPlugin("com.github.sbt" % "sbt-release" % "1.1.0")
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Setup repository
$ exec git init
$ exec git config user.email "email"
$ exec git config user.name "name"

$ exec git add .
$ exec git commit -m "Initial commit"
> reload

# First release
> release with-defaults
> checkTag_1_0_0
> reload

# New contributions
$ copy-file file-to-add.template module/src/main/scala/org/organization/module/NewAPI.scala
$ exec git add module/src/main/scala/org/organization/module/NewAPI.scala
$ exec git commit -m "Some hard work"

# Second release
> release with-defaults
# Check that sbt-version-policy bumped the minor version
> checkTag_1_1_0
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ThisBuild / version := "1.0.0-SNAPSHOT"
Loading
Loading