Skip to content

An extensible sbt plugin to configure, run and share a list of tasks in your projects locally or in CI, while generating pretty reports in HTML or ASCII.

License

Notifications You must be signed in to change notification settings

agboom/sbt-steps

Repository files navigation

sbt steps

Configure, run and share a list of tasks and commands to run in your sbt projects. Run the steps from the sbt shell. Generate reports in HTML or in ASCII format. Use in GitHub Actions or any other CI environment.

Demo

Here are two report examples after running ci using CIStepsPlugin:

HTML report

sbt:root> ci/stepsStatusReport -

(+Test / test) Cross test
+bar / Test / test
+foo / Test / test
+root / Test / test
(+publishLocal) Cross publish
+bar / publishLocal
🟢Successfully published bar 0.1.0-SNAPSHOT
+foo / publishLocal
🟢Successfully published foo 0.1.0-SNAPSHOT
+root / publishLocal
publishLocal / skip is set to true
(Compile / unidoc) Generate unified Scaladoc
🟥root / Compile / unidoc
(Scalaunidoc / doc) Scaladoc generation failed
ASCII report

sbt:root> ci/stepsTree --status

steps tree

Note

See the demo scripted test for the build.sbt of these examples. Run sbt scripted sbt-steps/demo to see the full demo.

Getting started

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.

Enable 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",
  )

Create your own StepsPlugin

See the plugin development docs.

Usage

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.

From the sbt shell

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.

With GitHub Actions

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."
          fi

The 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.

Configuration

The configuration examples below use the ci task scope, but please note that ci can be replaced by any other StepsPlugin task.

Customize steps

By default, CIStepsPlugin sets ci/steps to run +test and +publish. This can be customized as follows:

lazy val foo = (project in file("foo"))
  .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

Important

Task steps do not run for project aggregates configured by aggregate(). Instead, they are run for the subproject they are configured on. However, it's possible to run a task only for a specific subproject with project filters. Sharing steps is done like any sbt setting, read the next section for more information.

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.

Note

Notice that the tree shows the steps in a different grouping than configured in the build definition. The tree shows the order in which the steps will actually be run. Project steps with the same step are grouped together, while the configured order is kept in tact. This mimics sbt aggregation and prevents steps to be run in an unexpected order. For example, in this build all test must succeed before continuing to publish. If you prefer per project grouping, use the stepsGrouping setting.

Share steps between subprojects

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

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)

Declare steps for cross Scala versions

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

Note

Cross build steps can be safely declared without setting crossScalaVersions, because scalaVersion is used by default.

Important

For performance reasons, cross build steps mimic sbt cross build aggregation. 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. This behavior may lead to incomplete steps when its subsequent step has failed, which can be confusing. Even though this is the intended behavior, we may need to look into making this more clear. This scripted test can be used to test the performance limits.

Use input task steps

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.

Use command steps

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.

Skip a project step

To skip a task step, 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

Note

Command steps cannot be skipped with skip := true, so use project filters instead. Read further for more information.

Set project filters on a step

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. 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.

Declare a step to run only once

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 filter: LocalRootProject
[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.

Continue to next step on error

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.

Important

A failed step will always result in a failure status of the step and the entire run, whether .continueOnError is enabled or not.

Advanced configuration

Change the steps grouping

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.

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.

Add custom result messages to the report

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.

Acknowledgements

  • sbt-release for the inspiration for this plugin
  • Simacan where this plugin's development started
  • Pascal for providing valuable feedback before going public

About

An extensible sbt plugin to configure, run and share a list of tasks in your projects locally or in CI, while generating pretty reports in HTML or ASCII.

Topics

Resources

License

Stars

Watchers

Forks