Streamline your CI using the power of sbt.
With sbt-steps you will:
- ⚡ Supercharge your CI with minimal setup and configuration.
- 📊 Generate summaries in HTML or ASCII to see what happened in your builds at a glance.
- 🛠️ Streamline your build by declaring and reusing steps or by creating your own steps plugin.
- 🤝 Make your project integrate well with any CI system, including GitHub Actions.
Here are two report examples after running ci using CIStepsPlugin:
| HTML report | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| ASCII report |
|
|
Note
See the demo scripted test for the build.sbt of these examples. Run sbt scripted sbt-steps/demo to see the full demo.
There are two ways to use this plugin: enable CIStepsPlugin or create your own
StepsPlugin. For first use it's recommended to start with CIStepsPlugin.
To quickly get started, first add the plugin to your project/plugins.sbt:
addSbtPlugin("io.github.agboom" % "sbt-steps" % "<version>")Then enable the CIStepsPlugin in your build.sbt:
lazy val myProject = (project in file("."))
.enablePlugins(CIStepsPlugin)
.settings(
name := "my-project",
)Head over to Usage.
See the plugin development docs.
The ci task is central to CIStepsPlugin. It runs the configured ci/steps for all
subprojects in the build definition. All settings and tasks from StepsPlugin, like
stepsTree and stepsStatusReport, are scoped in this task.
Run ci from an sbt shell (or sbt ci for batch mode). By default +test and +publish
are run. For the example project above, this results in the following steps sequence:
sbt:my-project> ci/stepsTree
[info] task: +Test / test
[info] +-cross build: true
[info] +-project steps:
[info] +-task: +myProject / Test / test
[info]
[info] task: +publish
[info] +-cross build: true
[info] +-project steps:
[info] +-task: +myProject / publish
After ci has completed, run ci/stepsTree --status to print the steps tree with the
completed status. A status optionally has a message, such as:
[info] +- status: succeeded
[info] +- Successfully published my-project 0.1.0-SNAPSHOT.
A detailed HTML report is created during ci that you can use as a job summary in GitHub
Actions or any CI that accepts Markdown or HTML. By default the
report is written to target/ci-status.html. At any time you can also write a report to
file or stdout with ci/stepsStatusReport.
To include skipped steps and more information in the report, pass the --verbose or -v
flag, e.g. ci -v or ci/stepsTree -v.
For a complete list of available tasks and settings, execute help ^steps.* from your sbt
shell.
Important
Currently, a steps task like ci cannot be run for a specific subproject (e.g. don't run
sbt core/ci). It uses all projects in the build regardless.
Note
In the commands above we have used ci as task scope, which is part of CIStepsPlugin.
If you create your own StepsPlugin ci is replaced by a different task created for
that steps plugin (e.g. deploy or release). All configurations, runs and reports are
scoped to this task. This enables having multiple steps configurations for different use
cases simultaneously. Read the plugin developer documentation for
more information.
Below is an example workflow for GitHub Actions:
name: CI
on:
pull_request:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: sbt
- uses: sbt/setup-sbt@v1
- name: Build
shell: bash
run: sbt ci
- name: Submit summary
# try to create a summary on success or failure, but not on cancelled
if: ${{ !cancelled() }}
shell: bash
run: |
status_file=./target/ci-status.html
if [ -f $status_file ]; then
echo "# CI summary" >> $GITHUB_STEP_SUMMARY
cat $status_file >> $GITHUB_STEP_SUMMARY
echo >> $GITHUB_STEP_SUMMARY
echo >> $GITHUB_STEP_SUMMARY
else
echo "Cannot create CI summary, because $status_file does not exist."
fiThe workflow looks like any other sbt workflow, except instead of invoking sbt commands
directly, sbt ci is used. At runtime the steps tree is printed. In addition the CI steps
report is added to the job summary. If you have additional StepsPlugins, you can
append their reports the same way.
The configuration examples below use the ci task scope, but please note that ci can be
replaced by any other StepsPlugin task.
By default, CIStepsPlugin sets ci/steps to run +test and +publish. This can be
customized as follows:
lazy val foo = (project in file("foo"))
.enablePlugins(ScalaUnidocPlugin)
.settings(
ci / steps := Seq(
Test / test,
publish,
Compile / unidoc,
)
)
lazy val bar = (project in file("."))
.settings(
ci / steps := Seq(
Test / test,
publish,
)
)This configuration will result in the following steps sequence:
sbt:bar > ci/stepsTree
[info] task: Test / test
[info] +-project steps:
[info] +-task: foo / Test / test
[info] +-task: bar / Test / test
[info]
[info] task: publish
[info] +-project steps:
[info] +-task: foo / publish
[info] +-task: bar / publish
[info]
[info] task: Compile / unidoc
[info] +-project steps:
[info] +-task: foo / Compile / unidoc
The tree shows the order in which the steps will actually be run. Project steps with the same task are grouped together, while the configured order of tasks is kept in tact, mimicking sbt aggregation and ordering. This also increases performance by enabling parallel execution. For example, in this build all tests are run in parallel before moving on to publishing and only if all test tasks have succeeded.
Important
Task steps do not run for project aggregates configured by aggregate(). Instead, they
are run for the subproject they are configured on. To share steps between subprojects,
see the next section.
Tip
If prefer to run your steps grouped by project instead of by step, use the
stepsGrouping setting.
Tip
Because the steps setting is a list, they can also be appended (ci/steps += Compile / unidoc) or removed (ci/steps -= publish). Do always check the resulting
ci/stepsTree after customizing.
Like any sbt setting, use the ThisBuild scope (or use a shared setting) to share steps
between subprojects. For example:
ThisBuild / ci / steps = Seq(
Test / test,
publish,
)
lazy val foo = (project in file("foo"))
lazy val root = (project in file("."))This configuration will result in the following steps sequence:
sbt:root> ci/stepsTree
[info] task: Test / test
[info] +-project steps:
[info] +-task: foo / Test / test
[info] +-task: root / Test / test
[info]
[info] task: publish
[info] +-project steps:
[info] +-task: foo / publish
[info] +-task: root / publish
Tip
To skip a step for a particular project, use skip := true or
project filters
Tip
Sharing steps across builds is possible by creating a plugin.
Important
If you use an external plugin that declares shared steps on the project level, please
note that the ThisBuild scope in your build.sbt will not work, because once enabled
it's overwritten by the plugin setting. In that case a shared setting is needed:
lazy val sharedSettings = Def.settings(
ci / steps := Seq(
Test / test,
publish,
),
)
lazy val foo = (project in file("foo"))
.settings(sharedSettings)Any (input) task step can be run for the configured crossScalaVersions using the +
prefix. For example:
inThisBuild(Seq(
ci / steps := Seq(
+(Test / test),
+publish,
),
crossScalaVersions := Seq("3.3.4", "2.13.15"),
))
lazy val foo = (project in file("foo"))
lazy val root = (project in file("."))This configuration will result in the following steps sequence:
sbt:root> ci/stepsTree
[info] task: Test / +test
[info] +-cross build: true
[info] +-project steps:
[info] +-task: +foo / Test / test
[info] +-task: +root / Test / test
[info]
[info] task: +publish
[info] +-cross build: true
[info] +-project steps:
[info] +-task: +foo / publish
[info] +-task: +root / publish
Like regular steps, cross build steps mimic sbt cross build aggregation and are run in
parallel like other tasks. This means that for each cross Scala version, all project tasks
are run before going to the next cross Scala version. In the example above, this results
the following order: ++ 3.3.4; foo/test; root/test; ++ 2.13.15; foo/test; root/test.
Note
Cross build steps can be safely declared without setting crossScalaVersions, because
scalaVersion is used by default.
The examples above only use task steps that run a Task. An input task step
is similar, except that it parses an input string before running the
InputTask. For example:
lazy val foo = (project in file("."))
.settings(
ci / steps := Seq(
(Test / testOnly) withInput "*MySpec",
)
)If the input fails to parse, an error is shown in the step status when running ci.
Note
Without invoking withInput the input is left empty. This will succeed only if the
task's parser supports empty input. A leading space is not needed for inputs, because
it's automatically added.
A command step runs an sbt command. Command steps are different from task
steps, because they behave exactly like commands executed from the sbt console. This means
that, unlike task steps, commands that run a task are also executed in project aggregates.
For this reason it's recommended to use command step only if there's no alternative task
step. To declare steps for multiple subprojects, use ThisBuild or a shared
setting.
However, command steps can be useful for running command aliases or actual commands. For example:
lazy val foo = (project in file("."))
.enablePlugins(ScoverageSbtPlugin)
.settings(
ci / steps := Seq(
"coverageOn",
(Test / test),
"coverageOff",
)
)Tip
Commands work well together with project filters.
To skip a task step in a particular project, set skip := true like you would for any sbt
task. For example:
inThisBuild(Seq(
ci / steps := Seq(
+(Test / test),
+publish,
),
crossScalaVersions := Seq("3.3.4", "2.13.15"),
))
lazy val foo = (project in file("foo"))
lazy val root = (project in file("."))
.settings(
publish / skip := true,
)This configuration will result in the following steps sequence:
sbt:root> ci/stepsTree --verbose
[info] task: +Test / test
[info] +-cross build: true
[info] +-project steps:
[info] +-task: +foo / Test / test
[info] +-task: +root / Test / test
[info]
[info] task: +publish
[info] +-cross build: true
[info] +-project steps:
[info] +-task: +foo / publish
[info] +-task: +root / publish
[info] +-skipped: root / publish / skip is set to true
Warning
To skip a command step it's better to use project
filters. The skip := true setting may work if the
command refers to a task, but this behavior is untested in sbt-steps.
Project filters allow you to declare a (shared) step, but only run it for a specific
subproject. Use the forProject combinator to achieve this. The default is ThisProject.
Project filters are especially useful for command steps, as they complement the skip := true feature. For example, the following steps will run the coverageOn and
coverageOff command for the root project only.
ThisBuild / ci / steps := Seq(
"coverageOn" forProject LocalRootProject,
+(Test / test),
"coverageOff" forProject LocalRootProject,
)
lazy val foo = (project in file("foo"))
.enablePlugins(ScoverageSbtPlugin)
lazy val root = (project in file("."))
.enablePlugins(ScoverageSbtPlugin)This configuration will result in the following steps sequence:
sbt:root> ci/stepsTree
[info] command: coverageOn
[info] +-project filter: LocalRootProject
[info] +-project steps:
[info] +-command: project root; coverageOn
[info]
[info] task: +Test / test
[info] +-cross build: true
[info] +-project steps:
[info] +-task: +foo / Test / test
[info] +-task: +root / Test / test
[info]
[info] command: coverageOff
[info] +-project filter: LocalRootProject
[info] +-project steps:
[info] +-command: project root; coverageOff
Note the absence of coverageOn and coverageOff for project foo, because of the project filter.
In some cases you want a step to be run only once in the entire build. To achieve this,
use the .once combinator. Its dual is .whenever. This is slightly different from a
project filter, because the task will be run at the
first opportunity, instead of a specific project. For example, the following steps will
run the authenticate task only once:
ThisBuild / ci / steps := Seq(
authenticate.once,
+publish,
)
lazy val foo = (project in file("foo"))
.settings(
name := "foo",
)
lazy val root = (project in file("."))
.settings(
name := "root",
)This configuration will result in the following steps sequence:
sbt:root> ci/stepsTree
[info] task: authenticate
[info] +-run once: true
[info] +-project steps:
[info] +-task: foo / authenticate
[info]
[info] task: +publish
[info] +-cross build: true
[info] +-project steps:
[info] +-task: +foo / publish
[info] +-task: +root / publish
Note the added +-run once: true line in the steps tree.
If you have a step that is not critical to the complete success or want to continue in any
case, use the .continueOnError combinator on a step. Its dual is .abortOnError, which
is the default. For example:
lazy val myLibrary = (project in file("."))
.settings(
ci / steps := Seq(
+(Test / test).continueOnError,
+publish,
)
)The settings above will run +test for and proceed to +publish regardless of its
outcome.
If a task step is declared for more than one project, they will be joined to run in
parallel as explained above. A task like +test will always finish
for all projects and Scala versions, whether .continueOnError is enabled or not. This
mimics sbt aggregation and strikes the right balance between functionality and
performance.
Important
A failed step will always result in a failure status of the step and the entire run,
whether .continueOnError is enabled or not.
By default, steps are grouped by step to mimic sbt aggregation as explained in this
section. While this is a sensible default, there are use cases to keep
the by-project grouping. The grouping can be changed with the stepsGrouping setting:
ThisBuild / ci / steps := Seq(
+(Test / test),
+publish,
)
Global / ci / stepsGrouping := StepsGrouping.ByProject
lazy val foo = (project in file("foo"))
.settings(
name := "foo",
)
lazy val root = (project in file("."))
.settings(
name := "root",
)This configuration will result in the following steps sequence:
> ci/stepsTree
[info] project: root
[info] +-task: +Test / test
[info] +-cross build: true
[info] +-task: +publish
[info] +-cross build: true
[info]
[info] project: foo
[info] +-task: +foo / Test / test
[info] +-cross build: true
[info] +-task: +foo / publish
[info] +-cross build: true
Warning
If +foo / Test / test fails the foo project is not published, but the root project is.
Only use this setting if you accept this behavior.
Warning
With StepsGrouping.ByProject, parallel execution of tasks is not possible. Only use
this setting for sequential tasks.
Caution
Global / stepsGrouping := StepsGrouping.ByProject will set the grouping for all
enabled steps plugins. It's recommended to set it per steps task scope, e.g. Global / ci / stepsGrouping := StepsGrouping.ByProject.
Result messages are shown in the report with a completed step or when you pass the -s
flag to stepsTree. You can add custom messages both to existing tasks or commands and to
new tasks, for example in a custom steps plugin. Custom messages
are added with MessageBuilders:
Global / stepsMessagesForSuccess += TaskMessageBuilder.forSuccessSingle(Test / test) { _ =>
CustomSuccessMessage("All tests passed!")
}
lazy val myLibrary = (project in file("."))
.settings(
ci / steps := Seq(
Test / test,
)
)This will result in the following steps report:
sbt:myLibrary> ci
...
sbt:myLibrary> ci/stepsTree -s
[info] task: Test / test
[info] +-project steps:
[info] +-task: myLibrary / Test / test
[info] +-status: succeeded
[info] +-All tests passed!
Note
Always set these settings in the Global scope, e.g. Global / ci / stepsMessagesForSuccess. Otherwise it is unused and has no effect. Fortunately, sbt
will warn you about this.
Important
Never reset stepsMessagesFor... with the := operator. Always append with += or
++=, unless you want to lose the defaults.
Tip
Custom message can be created in several ways. The above example shows the shorthand method. You can also create a separate case class. See the plugin development documentation for an example.
- sbt-release for the inspiration for this plugin
- Simacan where this plugin's development started
- Pascal for providing valuable feedback before going public
