sbt 1.x からのマイグレーション

build.sbt DSL の Scala 3.x への移行

念の為書いておくと、sbt 1.x 系 2.x 系のどちらを使ってもScala 2.x および Scala 3.x 両方のプログラムをビルドすることが可能だ。ただし、build.sbt DSL を裏付ける Scala は sbt のバージョンによって決定される。sbt 2.0 では、Scala 3.8.x 系に移行する。

そのため、sbt 2.x 用のカスタムタスクや sbt プラグインを実装する場合、それは Scala 3.x を用いて行われることとなる。Scala 3.x に関する詳細はScala 3.x incompatibility tableScala 2 with -Xsource:3 なども参照。

// これは -Xsource:3 で Scala 2.12.20 上で動作する
import sbt.{ given, * }

given のインポート

Scala 2.x と 3.x の違いの 1つとして、型クラスのインスタンスの言語スコープへの取り込み方の違いが挙げられる。Scala 2.x では import FooCodec._ と書いたが、Scala 3 は、import FooCodec.given と書く。

// 以下は、sbt 1.x と 2.x の両方で動作する
import sbt.librarymanagement.LibraryManagementCodec.{ given, * }

後置記法の回避

特に ModuleID 関連で、 sbt 0.13 や 1.x のコード例で後置記法を見ることは珍しく無かった:

// BAD
libraryDependencies +=
  "com.github.sbt" % "junit-interface" % "0.13.2" withSources() withJavadoc()

これは、sbt 2.x では起動に失敗する:

-- Error: /private/tmp/foo/build.sbt:9:61 --------------------------------------
9 |  "com.github.sbt" % "junit-interface" % "0.13.2" withSources() withJavadoc()
  |                                                             ^^
  |can't supply unit value with infix notation because nullary method withSources
   in class ModuleIDExtra: (): sbt.librarymanagement.ModuleID takes no arguments;
   use dotted invocation instead: (...).withSources()

これを修正するには、普通の (ピリオドを使った) 関数呼び出しの記法で書く:

// GOOD
libraryDependencies +=
  ("com.github.sbt" % "junit-interface" % "0.13.2").withSources().withJavadoc()

ベア・セッティングの変更点

version := "0.1.0"
scalaVersion := "3.8.4"

settings(...) を介さず、上の例のように build.sbt に直書きされたセッティングをベア・セッティング (bare settings) と呼ぶ。

Warning

sbt 1.x において、ベア・セッティングはルートプロジェクトにのみ適用されるセッティングだった。sbt 2.x においては、build.sbt のベア・セッティングは全サブプロジェクトに注入されるコモン・セッティングとして解釈される。

name := "root"         // 全サブプロジェクトの名前が root になる!
publish / skip := true // 全サブプロジェクトがスキップされる!

ルートプロジェクトにのみセッティングを適用させるためには、マルチ・プロジェクトなビルドを定義するか、セッティングを LocalRootProject にスコープ付けさせる:

LocalRootProject / name := "root"
LocalRootProject / publish / skip := true

ThisBuild のマイグレーション

sbt 2.x においては、ベア・セッティングが ThisBuild にスコープ付けされる場面は無くなったはずだ。コモン・セッティングの ThisBuild に対する利点として委譲の振る舞いが、分かりやすくなるということがある。これらのセッティングはプラグインのセッティングと、settings(...) の間に入るため、 Compile / scalacOptions といったセッティングを定義するのに使える。 ThisBuild ではそれは不可能だった。

exportJars の変更点

exportJars のデフォルト値は false から true に変更された。これは getResource("/")resource.toURI を壊す可能性がある。ビルドでこのロジックが壊れ、NullPointerExceptionFileSystemNotFoundException が発生する場合は exportJars := false を設定する。古い振る舞いを維持したい場合は exportJars := false をビルドに設定する。この変更は sbt/sbt#7464 によって導入された。ブログ も参照。

キャッシュ・タスクへのマイグレーション

sbt 2.x において、全タスクはデフォルトでキャッシュされる。キャッシュに参加するためには、タスクの戻り型が sjsonnew.JsonFormat の given を提供する必要がある。戻り型に JsonFormat が無い (ParadoxProcessorClassLoaderSeq[PathMapping] 等の複合オブジェクトや関数型など) タスクは sbt 2 においてビルドのロード時に失敗する。

given を定義したくない場合、最も簡単なマイグレーション方法はタスクを Def.uncached(...) でラップして sbt 2 がキャッシュをスキップして常に再実行するようにすることだ:

myTask := Def.uncached {
  // シリアライズ不可能な型を返すタスク本体
}

タスクのキャッシュを検討するにあたって、副作用のあるタスクには注意が必要だ。sbt 2 がディスクキャッシュからタスクの結果を復元するとき、タスク本体を再実行せずにキャッシュされた値を返す。副作用 (ファイルの書き込みやマッピングの同期など) はサイレントにスキップされる。タスクが毎回副作用を生じることを意図している場合、Def.uncached(...) でラップして sbt 2 が常に再実行するようにする。

sbt2-compat プラグインは sbt 1.x 上で Def.uncached を互換性 shim (no-op) として提供する。ビルド全体及びタスク単位でのオプトアウトの方法などの詳細は キャッシュ・タスク リファレンスを参照。

IntegrationTest の廃止

IntegrationTest コンフィギュレーションの廃止に対応するためには、別のサブプロジェクトを定義して、普通のテストとして実装するのが推奨される。

スラッシュ構文へのマイグレーション

sbt 1.x は sbt 0.13 スタイル構文とスラッシュ構文の両方をサポートしたきた。2.x で sbt 0.13 構文のサポートが無くなるので、sbt シェルと build.sbt の両方で統一スラッシュ構文を使う:

<project-id> / Config / intask / key

具体的には、test:compile という表記はシェルからは使えなくなる。代わりに Test/compile と書く。build.sbt ファイルの半自動的なマイグレーションには統一スラッシュ構文のための syntactic Scalafix rule 参照。

scalafix --rules=https://gist.githubusercontent.com/eed3si9n/57e83f5330592d968ce49f0d5030d4d5/raw/7f576f16a90e432baa49911c9a66204c354947bb/Sbt0_13BuildSyntax.scala *.sbt project/*.scala

sbt プラグインのクロスビルド

sbt 2.x では、sbt プラグインを Scala 3.x と 2.12.x に対してクロスビルドすると自動的に sbt 1.x 向けと sbt 2.x 向けにクロスビルドするようになっている。

// sbt 2.x を使った場合
lazy val plugin = (projectMatrix in file("plugin"))
  .enablePlugins(SbtPlugin)
  .settings(
    name := "sbt-vimquit",
  )
  .jvmPlatform(scalaVersions = Seq("3.8.4", "2.12.20"))

projectMatrix を使った場合、プラグインを plugin/ のようなサブディレクトリに移動させる必要がある。そうしないと、デフォルトのルートプロジェクトも src/ を拾ってしまうからだ。

sbt 1.x を使った sbt プラグインのクロスビルド

sbt 1.x を使ってクロスビルドする場合 sbt 1.10.2 以降を使う必要がある。

// sbt 1.x を使った場合
lazy val scala212 = "2.12.20"
lazy val scala3 = "3.8.4"
ThisBuild / crossScalaVersions := Seq(scala212, scala3)

lazy val plugin = (project in file("plugin"))
  .enablePlugins(SbtPlugin)
  .settings(
    name := "sbt-vimquit",
    (pluginCrossBuild / sbtVersion) := {
      scalaBinaryVersion.value match {
        case "2.12" => "1.5.8"
        case _      => "2.0.1"
      }
    },
  )

%% の変更点

sbt 2.x において ModuleID%% 演算子は多プラットフォームに対応するようになった。JVM系のサブプロジェクトは、%% は以前同様に Maven リポジトリにおける (_3 のような) Scala バージョンの接尾辞をエンコードする。

%%% 演算子のマイグレーション

Scala.JS や Scala Native の sbt 2.x の対応ができるようになった場合、%% は (_3 のような) Scala バージョンと (_sjs1 その他の) プラットフォーム接尾辞をエンコードできるようになっている。そのため、%%%%% に置き換えれるようになるはずだ:

libraryDependencies += "org.scala-js" %% "scalajs-dom" % "2.8.0"

JVM ライブラリが必要な場合は .platform(Platform.jvm) を使う。

target に関する変更

sbt 2.x では、target ディレクトリが単一の target/ ディレクトリに統一され、それぞれのサブプロジェクトがプラットフォーム、Scala バージョン、id をエンコードしたサブディレクトリを作る。scripted test でこの変更点を吸収するために、existsabsentdelete のコマンドは glob式 ** および || をサポートする。

# 変更前
$ absent target/out/jvm/scala-3.3.1/clean-managed/src_managed/foo.txt
$ exists target/out/jvm/scala-3.3.1/clean-managed/src_managed/bar.txt

# 変更後
$ absent target/**/src_managed/foo.txt
$ exists target/**/src_managed/bar.txt

# どちらでも ok
$ exists target/**/proj/src_managed/bar.txt || proj/target/**/src_managed/bar.txt

sbt 1.x では、target.value はプロジェクトルートの target/ ディレクトリを指す。sbt 2.x では代わりに target/out/jvm/scala-<ver>/<project-name> を指す。プラグインのマイグレーション時にこの変更点に注意する必要がある。

CI パイプラインのマイグレーション

CI パイプラインに影響する可能性のある振る舞いの変更点がいくつかある。

コマンドを連続して実行する

コマンドの連続実行はセミコロンで区切られたクォートされた文字列として指定するようになった:

sbt "clean ; compile ; test"

以前は sbt clean compile test と書くことができたが、現在では "Expected whitespace character" というエラーが発生する。

複数のステップを持つ sbt サーバー

CI パイプラインに sbt を実行する複数のステップが含まれている場合、後続のステップは毎回新しい sbt プロセスを起動する代わりに、最初の呼び出しで起動されたサーバーを再利用する。その結果、後続のステップは最初のセッションに渡された環境変数を引き続き使用する。

パイプラインが各セッションに異なる環境変数 (JAVA_OPTS など) を渡すことに依存している場合、全ての sbt 呼び出しで同一になるようにジョブレベルで全ての変数を提供するか、各ステップの後またはステップ間で sbt をシャットダウンする必要がある。

sbt "clean ; compile ; test ; shutdown"

これは sbt を直接実行する場合でも、内部的に sbt を呼び出す sbt-dependency-submission のようなサードパーティーのアクションを通じて実行する場合でも適用される点に注意。

テスト成果物

テスト結果などの出力成果物は target/out 以下のサブディレクトリに保存されるようになったため、テストパブリッシュや成果物のアーカイブに使用されるパスを更新する必要があるかもしれない:

path: target/out/**/test-reports/*.xml

グローバルベースディレクトリの変更

sbt 2.x において、グローバルベースディレクトリはディレクトリ標準に従う。Windows でのデフォルト値は %LOCALAPPDATA%/sbt/2 だ。その他の場合は $XDG_CONFIG_HOME/sbt/2 もしくは $HOME/.config/sbt/2 だ。詳細は sbt リファレンス を参照。

PluginCompat 技法

同じ *.scala ソースを sbt 1.x と 2.x の両方をターゲットとして使うために、例えば src/main/scala-2.12/src/main/scala-3/ の両方に PluginCompat という名前のオブジェクトなどの shim を作成することができる。マイグレーション時によく遭遇する API は sbt2-compat プラグインに抽象化されており、手動で shim を作成することを避けるために使用できる。sbt プラグインで使用するには、sbt プラグインの build.sbt に追加する:

addSbtPlugin("com.github.sbt" % "sbt2-compat" % "<version>")

そして共有ソース内で変換メソッドをインポートして使用する:

import sbtcompat.PluginCompat._

// 変換メソッドをここで使用する

sbt2-compatPluginCompat パターン、及びそれらの使い方の詳細については以下のブログ記事を参照: Migrating sbt plugins to sbt 2 with sbt2-compat plugin

Classpath 型のマイグレーション

sbt 2.x では Classpath 型が Seq[Attributed[File]] の代わりに Seq[Attributed[xsbti.HashedVirtualFileRef]] のエイリアスに変更された。I/O (クラスパス URL、マッピング、同期、検証) に FilePath を必要とするプラグインはこれらの参照を変換する必要がある。上記のように sbt2-compat を追加してインポートした場合、toNioPathstoFiles を使う。例:

import sbtcompat.PluginCompat._

myTask := {
  implicit val conv: FileConverter = fileConverter.value
  val paths = toNioPaths((Compile / dependencyClasspath).value)
  val files = toFiles((Compile / dependencyClasspath).value)
  // ...
}

sbt2-compat は共有ソースで使用するための toNioPathtoFiletoFileRefsMappingDef.uncached その他の便利メソッドも提供する。プラグインが提供する API の最新ドキュメントは sbt2-compat README を参照。

独自の PluginCompat shim の定義

sbt2-compat がカバーしていない sbt 1.x と 2.x 間で壊れた API に対しては、src/main/scala-2.12/PluginCompat.scalasrc/main/scala-3/PluginCompat.scala に別々のソースファイルを作成することで独自の PluginCompat shim を定義することができる。例:

// src/main/scala-2.12/PluginCompat.scala

package sbtfoo

import sbt._
import Keys._

object PluginCompat {
  def someSharedMethod(): Unit = ...
}
// src/main/scala-3/PluginCompat.scala

package sbtfoo

import sbt._
import Keys._

object PluginCompat {
  def someSharedMethod(): Unit = ...
}

そして sbt プラグインで独自の PluginCompat shim を使用する:

import sbtfoo.PluginCompat._

myTask := {
  someSharedMethod()
}

このパターンは sbt2-compat と互換性があり、sbt 1.x と 2.x の違いを吸収するために併用することができる。