The Mill Meta-Build

The meta-build manages the compilation of the build.mill. Customizing the meta-build gives you greater control over how exactly your build.mill evaluates.

To customize it, you need to explicitly enable it with import $meta._. Once enabled, the meta-build lives in the mill-build/ directory. It needs to contain a top-level module of type MillBuildRootModule.

If you don’t configure it explicitly, a built-in synthetic meta-build is used. Meta-builds are recursive, which means, it can itself have a nested meta-builds, and so on.

To run a task on a meta-build, you specify the --meta-level option to select the meta-build level.

Autoformatting the build.mill

As an example of running a task on the meta-build, you can format the build.mill with Scalafmt. Everything is already provided by Mill. You only need a .scalafmt.conf config file which at least needs configure the Scalafmt version.

Run Scalafmt on the build.mill (and potentially included files)
$ mill --meta-level 1 mill.scalalib.scalafmt.ScalafmtModule/
  • --meta-level 1 selects the first meta-build. Without any customization, this is the only built-in meta-build.

  • mill.scalalib.scalafmt.ScalafmtModule/reformatAll is a generic task to format scala source files with Scalafmt. It requires the tasks that refer to the source files as argument

  • sources this selects the sources tasks of the meta-build, which at least contains the build.mill.

Finding plugin updates

Mill plugins are defined as ivyDeps in the meta-build. Hence, you can easily search for updates with the external mill.scalalib.Dependency module.

Check for Mill Plugin updates
$ mill --meta-level 1 mill.scalalib.Dependency/showUpdates
Found 1 dependency update for
de.tototec:de.tobiasroeser.mill.vcs.version_mill0.11_2.13 : 0.3.1-> 0.4.0

Sharing Libraries between build.mill and Application Code

build.mill (download, browse)
package build
import $meta._
import mill._, scalalib._
import scalatags.Text.all._

object `package` extends RootModule with ScalaModule {
  def scalaVersion = "2.13.4"
  def ivyDeps = Agg(
    ivy"com.lihaoyi::scalatags:${millbuild.DepVersions.scalatagsVersion}",
    ivy"com.lihaoyi::os-lib:0.10.7"
  )

  def htmlSnippet = Task { h1("hello").toString }
  def resources = Task.Sources {
    os.write(Task.dest / "snippet.txt", htmlSnippet())
    super.resources() ++ Seq(PathRef(Task.dest))
  }
}

This example illustrates usage of the mill-build/ folder. Mill’s build.mill file and it’s import $file and $ivy are a shorthand syntax for defining a Mill ScalaModule, with sources and ivyDeps and so on, which is compiled and executed to perform your build. This "meta-build" module lives in mill-build/, and can be enabled via the import $meta._ statement above.

mill-build/build.mill (browse)
package build
import mill._, scalalib._

object `package` extends MillBuildRootModule {
  val scalatagsVersion = "0.12.0"
  def ivyDeps = Agg(ivy"com.lihaoyi::scalatags:$scalatagsVersion")

  def generatedSources = Task {
    os.write(
      Task.dest / "DepVersions.scala",
      s"""
package millbuild
object DepVersions{
  def scalatagsVersion = "$scalatagsVersion"
}
      """.stripMargin
    )
    super.generatedSources() ++ Seq(PathRef(Task.dest))
  }
}
src/Foo.scala (browse)
package foo
import scalatags.Text.all._
object Foo {
  def main(args: Array[String]): Unit = {
    println("Build-time HTML snippet: " + os.read(os.resource / "snippet.txt"))
    println("Run-time HTML snippet: " + p("world"))
  }
}

Typically, if you wanted to use a library such as Scalatags in both your build.mill as well as your application code, you would need to duplicate the version of Scalatags used in both your import $ivy statement as well as your def ivyDeps statement. The meta-build lets us avoid this duplication: we use generatedSources in mill-build/build.mill to create a DepVersions object that the build.mill can use to pass the scalatagsVersion to the application without having to copy-paste the version and keep the two copies in sync.

When we run the application, we can see both the Build-time HTML snippet and the Run-time HTML snippet being printed out, both using the same version of Scalatags but performing the rendering of the HTML in two different places:

> mill compile
...

> mill run
Build-time HTML snippet: <h1>hello</h1>
Run-time HTML snippet: <p>world</p>

> mill show assembly
".../out/assembly.dest/out.jar"

> ./out/assembly.dest/out.jar # mac/linux
Build-time HTML snippet: <h1>hello</h1>
Run-time HTML snippet: <p>world</p>

This example is trivial, but in larger builds there may be much more scenarios where you may want to keep the libraries used in your build.mill and the libraries used in your application code consistent. With the mill-build/build.mill configuration enabled by import $meta._, this becomes possible.

You can customize the mill-build/ module with more flexibility than is provided by import $ivy or import $file, overriding any tasks that are present on a typical ScalaModule: scalacOptions, generatedSources, etc. This is useful for large projects where the build itself is a non-trivial module which requires its own non-trivial customization.

You can also run tasks on the meta-build by using the --meta-level cli option.

> mill --meta-level 1 show sources
[
.../build.mill",
.../mill-build/src"
]

> mill --meta-level 2 show sources
.../mill-build/build.mill"

Sharing Source Code between build.mill and Application Code

build.mill (download, browse)
package build
import $meta._
import mill._, scalalib._

object `package` extends RootModule with ScalaModule {
  def scalaVersion = millbuild.ScalaVersion.myScalaVersion
  def sources = Task.Sources {
    super.sources() ++ Seq(PathRef(millSourcePath / "mill-build/src"))
  }
}
mill-build/build.mill (browse)
package build
import mill._

object `package` extends MillBuildRootModule
mill-build/src/ScalaVersion.scala (browse)
package millbuild
object ScalaVersion {
  def myScalaVersion = "2.13.10"
}
src/Foo.scala (browse)
package foo

object Foo {
  def main(args: Array[String]): Unit = {
    println("scalaVersion: " + millbuild.ScalaVersion.myScalaVersion)
  }
}

This example shows another use of the Mill meta-build: because the meta-build is just a normal Scala module (with some special handling for the .sc file extension), your build.mill file can take normal Scala source files that are placed in mill-build/src/ This allows us to share those sources with your application code by appropriately configuring your def sources in build.mill above.

> mill run
scalaVersion: 2.13.10

Here we only share a trivial def myScalaVersion = "2.13.10" definition between the build.mill and application code. But this ability to share arbitrary code between your application and your build opens up a lot of possibilities:

  • Run-time initialization logic can be placed in mill-build/src/, shared with the build.mill and used to perform build-time preprocessing, reducing the work needing to be done at application start and reducing startup latencies.

  • Build-time build.mill logic can be placed in mill-build/src/ and shared with your run-time application code, allowing greater flexibility in e.g. exercising the shared logic in response to user input that’s not available at build time.

In general, the Mill meta-build with its mill-build folder is meant to blur the line between build-time logic and run-time logic. Often you want to do the same thing in both places: at build-time where possible to save time at runtime, and at runtime where necessary to make use of user input. With the Mill meta-build, you can write logic comprising the same source code and using the same libraries in both environments, giving you flexibility in where your logic ends up running