1. Overview

A project’s release process is always crucial and may require much manual work. In this tutorial, we’ll explore how to automate and customize the release process to match our needs using the sbt plugin sbt-release.

2. The Plugin’s Versioning Strategy

The plugin uses the semantic versioning on semver.org with the addition that we can add an appendix to every version. That means
that the version scheme consists of a major version, a minor version, a bugfix version, and an appendix. Only the major version is required, and everything else is optional. These are some examples of valid versions:

  • 1 (major version)
  • 1.0 (major and minor versions)
  • 1.0.0 (major, minor, and bugfix versions)
  • 1-SNAPSHOT (major version with appendix)
  • 1BETA (major version with appendix without -)
  • 1BETA.0.0 (major with appendix, minor, bugfix versions)
  • 1.0-SNAPSHOT (major and minor with appendix versions)
  • 1.0BETA.0-SNAPSHOT(major, minor with appendix and bugfix with appendix versions)

3. The sbt-release Plugin

3.1. Initial Setup of the Plugin

The first thing we need to do is to add the plugin to our plugins.sbt file located inside the project folder:

addSbtPlugin("com.github.sbt" % "sbt-release" % "1.4.0")

Now, our project contains the plugin. Most projects have the version definition inside the build.sbt file. This file contains scala code instead of an XML, YAML, or TOML definition that other programming languages use. This makes it hard to make a process that will modify it. For this reason, sbt-release requires another file that will hold the project’s current version. By default, this file is located in the version.sbt file in the root folder, but we can point to another file easily. For our example, let’s use a version.sbt file located inside the version folder. We need to add the following line in our build to do this*.sbt* file:

releaseVersionFile := file("version/version.sbt")

Then, let’s assume that our initial version is 1.0.0-SNAPSHOT. We’ll define this by adding it to our version.sbt file the following:

ThisBuild / version := "1.0.0-SNAPSHOT"

After this initial setup, we can trigger our plugin and our release process by running the sbt release command.

3.2. The Default Release Process

The plugin already has a defined release process that we can use out of the box. First, it’ll check if the directory is a git repository. If there are any snapshot dependencies, it lets the user decide if they want to proceed or not. Then, it’ll ask the user to input the release version and the next development version to bump the project to. When we’ve defined all the data regarding the release, it will clean the project and run all the tests. If any test fails, the whole release process will also fail. If not, we’re ready for release. The next step is to bump the version to the next release version and write it to our version.sbt file and apply it to the current build state. All these changes will add a commit, and the previous commit will be tagged with the version. Then, our project will be published by running the publish command. After that, our project has been released so we can move on with our new development version. The plugin will modify the version.sbt file again and apply the settings to the current build state. We can check the file to see that it now holds the next version number, for example, 1.0.1-SNAPSHOT, if this is our versioning strategy. After that, the changes will be committed again and pushed to our remote repository. This step requires us to set up an upstream remote repository. When we first issue our sbt release command, we must not have any uncommitted changes, otherwise, it’ll fail. If we try to run the command to our example, we’ll get the following useful error:

java.lang.RuntimeException: Aborting release: unstaged modified files

Modified files:

 - build.sbt
version/version.sbt

4. Customizing the Release Process

The default process may not match our needs for many reasons, and we may need to modify it. The sbt-release plugin provides a way to do this. The process is defined under the releaseProcess setting, a sequence of ReleaseStep elements, each defining a release step. We can add the following lines and start defining the steps in our build.sbt file:

releaseProcess := Seq[ReleaseStep](
  checkSnapshotDependencies,              // : ReleaseStep
  inquireVersions,                        // : ReleaseStep
  runClean,                               // : ReleaseStep
  setReleaseVersion,                      // : ReleaseStep
  commitReleaseVersion,                   // : ReleaseStep, performs the initial git checks
  tagRelease,                             // : ReleaseStep
  setNextVersion,                         // : ReleaseStep
  commitNextVersion                       // : ReleaseStep
)

In our example, we have omitted some steps from the default process, such as running the tests, publishing the artifacts, and pushing the changes. If we run the release command and use the 1.0.1 version as the release version, we’ll notice two changes once it’s completed: our version.sbt file now holds the development version value 1.0.2-SNAPSHOT, and we have two commits now, with messages Setting the version to 1.0.1 and Setting the version to 1.0.2-SNAPSHOT. These are all changes made by the sbt-release plugin during the release lifecycle.

4.1. Adding a Custom Step

Now that we have our release steps, we can define our own simply by defining a variable of type ReleaseStep. This consists of State transformation functions that describe the step, a check if the release process should run or abort, and a Boolean value that defines if this step is used in cross-builds defined with crossScalaVersions setting. Let’s define a custom step that’ll only print the new version of our project:

val customReleaseStep = ReleaseStep(action = step => {
  val extracted = Project.extract(step)
  val v = extracted.get(Keys.version)
  println(s"Custom release step that prints the new version: $v")
  step
})

We’re extracting our current version with the extract function from our project and then printing the version in the console. We only need to add it to the releaseProcess setting:

releaseProcess := Seq[ReleaseStep](
  checkSnapshotDependencies,              // : ReleaseStep
  inquireVersions,                        // : ReleaseStep
  runClean,                               // : ReleaseStep
  setReleaseVersion,                      // : ReleaseStep
  commitReleaseVersion,                   // : ReleaseStep, performs the initial git checks
  tagRelease,                             // : ReleaseStep
  setNextVersion,                         // : ReleaseStep
  commitNextVersion,                      // : ReleaseStep
  customReleaseStep
)

We can see the log line in our console during our next release.

4.2. Versioning Strategies

The plugin’s default versioning strategy increases the last version part, including the qualifier. This means the 1.0.0 version will bump to 1.0.1, and 1.0.0beta1 will bump to 1.0.0beta2. We can choose a versioning strategy for our next development version by setting the releaseVersionBump setting in our build.sbt file. We can choose from various strategies, such as Major, Minor, Bugfix, Nano, Next**,** and NextStable. The last one will remove any pre-release qualifier. For our example, we’ll use the NextStable strategy:

releaseVersionBump := sbtrelease.Version.Bump.NextStable

Next, let’s also customize our next development versioning strategy. For this, we’ll use the releaseNextVersion setting:

releaseNextVersion := (releaseVersion => releaseVersion.split("\\.") match {
  case Array(major, minor, bugfix) =>
    s"$major.$minor.${bugfix.toInt + 1}"
})

This setting consists of a function accepting the release version and returning the next one. In our example, we split our version into major, minor, and bugfix versions and increment the bugfix version.

4.3. Custom Source Control Messages

If we don’t like the default commit messages, the sbt-release plugin allows us to modify them. If we also need to change the tag comment, we only need to set the releaseCommitMessage, releaseNextCommitMessage, and releaseTagComment settings.

releaseTagComment        := s"Releasing ${(ThisBuild / version).value} using sbt-release"
releaseCommitMessage     := s"Setting version to ${(ThisBuild / version).value} using sbt-release"
releaseNextCommitMessage := s"Setting development version to ${(ThisBuild / version).value} using sbt-release"

After running the sbt release command, we can see in our source control log that the commit messages have changed: Setting the version to 1.0.0 using sbt-release and Setting the version to 1.0.1 using sbt-release.

4.4. Cross Building the Project

Many Scala projects have more than one Scala version, especially if it’s a library that needs to have support for most versions. We can easily enable cross-build of our project during the release process by enabling the releaseCrossBuild setting in our build.sbt file:

releaseCrossBuild := true

5. Running the Plugin with Command Arguments

Up to now, we have configured our release process, so it matches our needs, but it still requires a lot of user input to run. Fortunately, the plugin can accept command arguments that can fully automate the process and we’re able to integrate it in our CI/CD pipeline.

5.1. Release Version and Next Development Version

If for some reason, our already defined release strategy doesn’t fit our needs, for example, if we want to change the major version, the plugin allows us to define both the release version and the next development version as command arguments. Let’s say that in our example, we are transitioning from version 1.0.0 to 2.0.0. Then we want our next development version to be 2.1.0-SNAPSHOT, as we’re starting to work on a new minor release. We only have to run the sbt release release-version 2.0.0 next-version 2.1.0-SNAPSHOT command. The first argument, release-version, will set the desired release version, and the second one, next-version, will set our development version.

5.2. Skip Tests

In case of an emergency, that we need to deploy a hotfix immediately, we can skip tests execution and decrease release time by using the skip-tests argument. So, our command will be sbt release skip-tests.

5.3. Force a Cross Build

During a release, we may want to force a cross-build, so this version specifically supports another Scala version as well, but no other. As stated above, we can configure this behavior from our settings, but committing the enabled cross-build option and then committing again the disabled, isn’t very efficient. Fortunately, we can force a cross-build running the sbt release cross command.

5.4. Handle Existing Git Tags

Our release process contains the step where we also add a git tag in our release commit. There is a case where the tag may already exist. We can use the default-tag-exists command to choose our response to that. Our options are o for override, k for not overriding the tag, a for abort, or use our own tag name. So, if, in our example, we choose to use our own tag name, we’ll run the command sbt release default-tag-exists 1.1.0-new that will tag our commit with the 1.1.0-new name.

5.5. Fully Automated Release with Defaults

Finally, if we’re happy with our process and we just want it to run, we can always use the with-defaults argument and fully automate the process. This argument runs a non-interactive release that uses default values for each user input. More specifically, if the plugin finds any snapshot dependency, it’ll abort the process. Then, it’ll bump into the current version without the appendix as a release version, as described above. The next development version will follow our versioning strategy or it’ll increment the minor version and add the SNAPSHOT appendix if we haven’t defined one. If we haven’t chosen a way to handle an already existing tag, the default behavior is to abort the whole process. Finally, if the plugin can’t find a remote branch, it can’t be tracked or there are unmerged commits, the process will abort.

6. Conclusion

In this article, we’ve learned how to use the sbt-release plugin to customize and automate our project’s release process. As usual, the code is available over on GitHub.