Skip to main content

Porting sbt plugins

Many sbt plugins are genuinely useful and you don't want to lose them when you move to Bleep. The good news is that most of them can be ported straightforwardly, and the result is usually simpler and easier to follow than the original.

This page is for the case where you need functionality that already exists as an sbt plugin and you want to use it from a Bleep script or sourcegen script. If you're writing new functionality from scratch, just write a regular script. No plugin mechanism needed.

The translation

sbt plugins hide most of their structure behind keys, scopes, and auto-imports. When ported to plain Scala, the structure becomes visible:

sbtBleep
class FooPlugin extends AutoPluginclass FooPlugin(...) (regular class)
lazy val fooSetting = ...Constructor parameter
lazy val fooTask = taskKey[...]def foo(...): ... (method)
override def requires = ...Pass dependencies into the constructor
sbt.Loggerryddig.Logger
Keys.baseDirectory.valueParameter or started.buildPaths.buildDir

No AutoPlugin, no autoImport, no Keys, no implicit task graph. Just a class you instantiate and methods you call.

Real ported plugins

Bleep ships with several sbt plugins already ported. They live in the liberated/ directory as git submodules. Each tracks the upstream project so we can merge fixes over time.

Currently ported:

  • sbt-ci-release: CI-driven Sonatype releases
  • sbt-sonatype: Maven Central publishing (including the new Central Portal)
  • sbt-dynver: Git-derived version strings
  • sbt-pgp: Artifact signing
  • sbt-native-image: GraalVM native image generation
  • mdoc: Documentation generation
  • sbt-scalafix: Code linting and rewriting
  • sbt-jni: JNI compilation

Example: DynVerPlugin

Here is the ported sbt-dynver plugin in full. Look how easy it is to follow end-to-end:

package bleep.plugin.dynver

import java.io.File
import java.util.Date

class DynVerPlugin(
val baseDirectory: File,
/* The prefix to use when matching the version tag */
val dynverTagPrefix: Option[String] = None,
/* The separator to use between tag and distance, and the hash and dirty timestamp */
val dynverSeparator: String = DynVer.separator,
/* The current date, for dynver purposes */
val dynverCurrentDate: Date = new Date,
/* Whether to append -SNAPSHOT to snapshot versions */
val dynverSonatypeSnapshots: Boolean = false
) {

lazy val tagPrefix = {
val vTagPrefix = dynverVTagPrefix
val tagPrefix = dynverTagPrefix.getOrElse(if (vTagPrefix) "v" else "")
assert(vTagPrefix ^ tagPrefix != "v", s"Incoherence: dynverTagPrefix=$tagPrefix vs dynverVTagPrefix=$vTagPrefix")
tagPrefix
}

/* The dynver instance for this build */
lazy val dynverInstance: DynVer =
DynVer(Some(baseDirectory), dynverSeparator, tagPrefix)

/* Whether or not tags have a 'v' prefix */
lazy val dynverVTagPrefix: Boolean =
dynverTagPrefix.getOrElse(DynVer.tagPrefix) == "v"

/* The output from git describe */
lazy val dynverGitDescribeOutput: Option[GitDescribeOutput] =
dynverInstance.getGitDescribeOutput(dynverCurrentDate)

/* The last stable tag */
lazy val dynverGitPreviousStableVersion: Option[GitDescribeOutput] =
dynverInstance.getGitPreviousStableTag

lazy val isSnapshot: Boolean =
dynverGitDescribeOutput.isSnapshot

/* The version string identifies a specific point in version control, so artifacts built from this version can be safely cached */
lazy val isVersionStable: Boolean =
dynverGitDescribeOutput.isVersionStable
/* The last stable version as seen from the current commit (does not include the current commit's version/tag) */
lazy val previousStableVersion: Option[String] =
dynverGitPreviousStableVersion.previousVersion

def getVersion(date: Date, out: Option[GitDescribeOutput]): String =
out.getVersion(date, dynverSeparator, dynverSonatypeSnapshots)

// The version of your project, from git
lazy val dynver: String =
getVersion(new Date, dynverInstance.getGitDescribeOutput(new Date))

lazy val version: String =
getVersion(dynverCurrentDate, dynverGitDescribeOutput)

// Asserts if the version derives from git tags
def dynverAssertTagVersion(): Unit =
dynverGitDescribeOutput.assertTagVersion(version)

// Checks if version and dynver match
def dynverCheckVersion: Boolean =
dynver == version

// Asserts if version and dynver match
def dynverAssertVersion(): Unit = {
val v = version
val dv = dynver
if (!dynverCheckVersion)
sys.error(s"Version and dynver mismatch - version: $v, dynver: $dv")
}
}

Porting workflow

  1. Copy the source into liberated/your-plugin/ as a git submodule, with both origin (your fork) and origin-original (upstream) remotes.
  2. Strip sbt imports: remove import sbt._, import Keys._, import sbt.plugins._.
  3. Unwrap AutoPlugin: replace with a regular class taking constructor parameters.
  4. Convert keys to parameters: each SettingKey becomes a constructor parameter, each TaskKey becomes a def.
  5. Replace sbt.Logger with ryddig.Logger (bleep's structured logger).
  6. Add it to bleep.yaml as a project with sources: pointing at the liberated directory, then use it from a script.
  7. Maintain: when upstream releases fixes, merge them with git merge origin-original/main and resolve conflicts in favour of the bleep adaptations.

The CLAUDE.md at the root of the bleep repo has the full maintenance process documented.

Limitations

Scopes

Bleep does not have scopes. Compile and Test don't exist as scopes; the test scope is a separate project. If a plugin leans heavily on scopes (e.g. Test / fullClasspath), you'll need to express that by passing the relevant project explicitly.

Global mutable state

Some sbt plugins communicate via shared global mutable state (sbt.Keys.state, task attribute maps). In Bleep these values must be passed as explicit parameters. This is usually an improvement (the data flow becomes visible), but it can require more invasive porting for plugins that were designed around ambient state.

Multi-project semantics

sbt plugins sometimes react to project structure via thisProject.value. Bleep scripts instead iterate over started.build.explodedProjects and handle each project explicitly.

Distribution

After porting, the plugin is just a normal Scala library. You can cross-publish it to Maven Central and have other Bleep builds depend on it by coordinate, exactly like any other dependency. Bleep itself does this with its ported plugins; they're published under build.bleep::bleep-plugin-*.