Skip to main content

Comparison with Mill

Mill is the closest tool in spirit to bleep: same dissatisfaction with sbt, same instinct that the build should be a structured object you can introspect rather than a soup of effects. The two diverge on what "structured object" means.

Build is data vs. build is targets

In Mill, the build is a build.mill file. Scala code. Modules are classes, settings are methods, and targets are macros that produce a typed task graph evaluated lazily on demand.

In bleep, the build is YAML. The structure is closed (every field is in the schema). Custom logic doesn't live in the build file at all; it lives in scripts, which are regular Java, Kotlin, or Scala programs.

Same Scala module:

build.mill (Mill)bleep.yaml
import mill._
import mill.scalalib._
import mill.scalalib.publish._

object mylib extends ScalaModule with PublishModule {
def scalaVersion = "3.8.3"
def ivyDeps = Agg(
ivy"com.lihaoyi::fansi:0.5.0"
)
def publishVersion = "0.1.0"
def pomSettings = PomSettings(
description = "A useful library",
organization = "com.example",
url = "https://github.com/me/mylib",
licenses = Seq(License.`Apache-2.0`),
versionControl = VersionControl.github("me", "mylib"),
developers = Seq(Developer("me", "Me", "https://me.com"))
)

object test extends ScalaTests with TestModule.Munit {
def ivyDeps = Agg(ivy"org.scalameta::munit:1.0.0")
}
}
$schema: https://raw.githubusercontent.com/oyvindberg/bleep/master/schema.json
$version: 1.0.0-M9

projects:
mylib:
scala:
version: 3.8.3
platform:
name: jvm
dependencies:
- com.lihaoyi::fansi:0.5.0
publish:
groupId: com.example
url: https://github.com/me/mylib
description: A useful library
developers:
- id: me
name: Me
url: https://me.com
licenses:
- name: Apache-2.0
url: https://www.apache.org/licenses/LICENSE-2.0.txt

mylib-test:
extends: template-jvm
isTestProject: true
dependsOn: mylib
dependencies:
- org.scalameta::munit:1.0.0

Mill's build.mill is more expressive. You can write a method that computes a setting from another setting, override a target, mix in a trait that adds behavior. Bleep's YAML is more boring. That's the trade.

For a build script that's stable enough to live in git for years, the boring side wins on diff legibility and tooling. For a build that's itself an evolving program, bleep is the wrong shape.

Faster everything (still)

Mill 1.x and bleep have the same shape: a native (Graal) launcher in front of a JVM daemon that does the actual compile work. The remaining differences:

Mill 1.xBleep
LauncherNative (Graal)Native (Graal)
DaemonPersistent Mill server, per build rootbleep-bsp, shared across every workspace on the machine
Daemon spin-upMill server boot + build.mill compile (several seconds)JVM warmup + Zinc analysis load (several seconds)
Build re-eval after editingrecompile build.millre-parse YAML
Inspection commands (list projects, show config, etc.)go through Mill (server roundtrip)run in the native CLI without touching the daemon
Incremental compileZincZinc (same engine)

The first compile in a workspace pays daemon spin-up either way. After that, Mill reuses the same server per build root; bleep reuses the same bleep-bsp across every bleep workspace on the machine. Read-only commands (bleep projects, bleep build show, bleep build invalidated) skip the daemon entirely on bleep, since they run in the native CLI. See Compile servers for the daemon details.

Targets vs scripts

Mill's typed task graph is genuinely elegant. def myArtifact = T { ... } defines a target, Mill caches its result, and any input change invalidates the cache automatically. Compose enough of these and you've got a multi-step pipeline with cross-step caching, expressed in idiomatic Scala.

The question bleep keeps coming back to is: does that have to live in the build? A task graph is a piece of incremental-compute machinery. The build tool consumes one (for compile, test, sourcegen, link). It doesn't have to be one, and most builds never define a custom target.

Bleep's answer is to push that machinery one layer out, into a script. A script is a regular Java, Kotlin, or Scala program; it reads the resolved build model and can pull in any incremental-build library on the JVM. Mill's T works as a library: depend on it from the script and you've reproduced the typed task graph for your pipeline, decoupled from the build tool itself. (And nothing stops you from triggering a bleep compile from inside that pipeline if the graph needs it.)

In practice, most builds don't reach for any of that:

  • Compile / test / run / publish / link: built-in commands with their own caches. You don't define them.
  • Sourcegen: generators that auto-run before the projects that consume them, with timestamp-based invalidation. See Source generation.
  • Custom pipelines: write a script. Use Mill's T, use a plain Map[Hash, Path], use whatever incremental engine fits the task.

The task graph is a great pattern. We just don't think the build tool is the right place to keep it.

What Mill does that bleep doesn't

  • Typed task graph in the build file: user-defined T targets with automatic caching, expressed alongside the rest of the build. (In bleep that machinery moves into a script; see Targets vs scripts.)
  • build.mill as code: full Scala expressivity for non-trivial build logic.
  • Module hierarchies via Scala traits: extends ScalaModule with PublishModule with ScalafmtModule composes capabilities. Bleep templates are flatter (key-value merge, not behavior composition).
  • BSP integration via mill mill.bsp.BSP/install; bleep's BSP is always on with no install step.

What bleep does that Mill doesn't

  • Native CLI for inspection commands. Mill goes through its server even for read-only queries; bleep handles them in the native binary without waking the daemon.
  • Cross-workspace daemon sharing. One bleep-bsp serves every bleep build on your machine; Mill servers are per build root.
  • YAML build that any tool, LLM, or refactor can edit safely.
  • Scripts as plain programs rather than def myTask = T { ... } Scala targets. Equivalent power for the common case, no DSL to learn.
  • Built-in bleep import from sbt and Maven: Mill has its own importer for some, but bleep's is broader.

When Mill is still the right answer

  • You're already on Mill and the cold-start cost doesn't bother you.
  • Your build is a task graph, the pipeline of intermediate artifacts is the central thing, and you want the build tool itself to own it rather than a script alongside it.
  • You want full Scala expressivity in the build file itself, not pulled out into a scripts project.

See also