How to run sbt tasks with custom settings
When preparing build configuration in sbt, you might want to write custom sbt task. Task in sbt is like a function that can depend on other tasks and project settings.
Custom sbt tasks
To illustrate it, let’s write a simple task that validates environment, runs tests and builds an app
import sbt.*
lazy val validateAndBuild = taskKey[Unit]("Validates env, runs tests and builds the app")
val root = project.in(file(".")).settings(
validateAndBuild := {
validateEnvVars
test
assembly
}
)
Tasks vs immutable settings
This can be handy when the project grows in complexity. One problem with this approach is that we don’t control the inputs to the sub-tasks that we run. In sbt, settings are initialized when the project is loaded and are not mutable.
import sbt.*
// Tasks that attempt to modify the settings
lazy val updateVersion = taskKey[Unit]("blah")
lazy val addDependency = taskKey[Unit]("blah")
val root = project.in(file(".")).settings(
name := "My project",
version := "0.0.0",
updateVersion := {
version := "0.1.0"
println(s"Current version is ${version.value}")
},
addDependency := {
libraryDependencies += "org.polyvariant" %% "example" % "2.1.37"
}
)
As shown below, running the tasks doesn’t affect the settings values.
sbt:My project> show version
[info] 0.0.0
sbt:My project> show libraryDependencies
[info] * org.scala-lang:scala-library:2.12.20
sbt:My project> updateVersion
Current version is 0.0.0
[success] Total time: 0 s, completed Oct 5, 2025, 6:52:17 PM
sbt:My project> addDependency
[success] Total time: 0 s, completed Oct 5, 2025, 6:52:21 PM
sbt:My project> show version
[info] 0.0.0
sbt:My project> show libraryDependencies
[info] * org.scala-lang:scala-library:2.12.20
Now let’s say you want to add a task to your build that assembles the app using sbt-assembly but using a custom Main
class that enables extra debug symbols. Our task would be called buildDebugApp
and would run assembly
but with a custom assembly / mainClass
setting.
The most intuitive approach would be to temporarily override the main class, execute assembly
, and then restore the old mainClass
value.
lazy val buildDebugApp = taskKey[Unit]("Assemble a debug version of this app with custom main class")
val root = project.in(file(".")).settings(
buildDebugApp := {
val oldMain = (assembly / mainClass).value
assembly / mainClass := Some("org.example.DebugMain")
assembly
assembly / mainClass := oldMain
}
)
Unfortunately, as we learned above, this doesn’t work.
Project.extract
and running tasks with new state
To overcome this issue we’ll have to use Project.extract
. Because tasks read the current project state to obtain their dependent values, the only way to override those values is to run the tasks in a new environment.
Even though settings are immutable, we can extract the current project state with Project.extract(state.value)
, modify that state, and run a task in the modified environment like this:
lazy val buildDebugApp = taskKey[Unit]("Assemble a debug version of this app with custom main class")
val root = project.in(file(".")).settings(
buildDebugApp := {
// Capture current project state and reference
val initialState = state.value
val tpr = thisProjectRef.value
// Prepare the value overrides
val customMain = Some("org.example.DebugMain")
val overrideSettings = Seq(
tpr / assembly / mainClass := customMain,
)
// Prepare a copy of current state with value overrides
val currentState = Project.extract(initialState)
val newState = currentState.appendWithSession(overrideSettings, initialState)
// Run assembly in the modified environment
Project
.extract(newState)
.runTask(tpr / assembly, newState)
}
)
You can also use runInputTask
if you’re executing an InputTask
.