Scalaライブラリのプロジェクト設定にベストプラクティスを適用してくれるsbt-typelevel

この記事はScala Advent Calendar 2023の13日目です.

Scalaのライブラリを作るとき, 複数のScalaバージョンに対応させてクロスプラットフォーム(JVM, Scala.js, Scala Native)に対応させて, CIも設定して...とやるのは割と面倒です. 実はsbt-typelevelというsbtプラグインを使うと, この辺の面倒な設定をほとんどいい感じにやってくれます.

この記事では, sbt-typelevelの基本的な導入方法と, 細かい設定ノウハウを紹介します.

sbt-typelevelとは

もともと, Typelevelにはプロジェクトがたくさんあるので, 各プロジェクトで似たような設定を何度もやらなくていいように, ベストプラクティスを共通化するために用意されたもののようです. 実体は他のプラグインの集合体 + 便利なデフォルト設定です.

一般的な設定部分とTypelevelのプロジェクトに固有の部分は分けられているので, Typelevel外のプロジェクトにも問題なく適用できます.

基本設定

プラグイン設定

project/plugins.sbtに必要なものを入れます. (バージョンは適宜最新化してください.)

基本:

addSbtPlugin("org.typelevel"      % "sbt-typelevel"          % "0.6.3")

Scalafixを使う場合は追加:

addSbtPlugin("org.typelevel"      % "sbt-typelevel-scalafix" % "0.6.3")

サイト生成をする場合:

addSbtPlugin("org.typelevel"      % "sbt-typelevel-site"     % "0.6.3")

Scala.jsをサポートする場合:

addSbtPlugin("org.scala-js"       % "sbt-scalajs"            % "1.14.0")

Scala Nativeをサポートする場合:

addSbtPlugin("org.scala-native"   % "sbt-scala-native"       % "0.4.16")
build.sbt設定

基本要素としては以下を設定します.

build.sbt:

ThisBuild / tlBaseVersion := "1.0"

ThisBuild / organization     := "com.github.tarao"
ThisBuild / organizationName := "my project authors"
ThisBuild / startYear        := Some(2023)
ThisBuild / licenses         := Seq(License.Apache2)
ThisBuild / developers := List(
  tlGitHubDev("tarao", "INA Lintaro"),
)

val Scala_3 = "3.3.1"
val Scala_2_13 = "2.13.12"

ThisBuild / scalaVersion       := Scala_3
ThisBuild / crossScalaVersions := Seq(Scala_2_13, Scala_3)

コンパイラ設定

sbt-typelevelはコンパイラオプションもいい感じに設定してくれるのですが, 一部うまくいかない部分もあるので多少は調整が必要です.

build.sbt:

lazy val compileSettings = Def.settings(
  // 警告をエラーにする
  tlFatalWarnings := true,

  // デフォルトで設定されるがうまくいかないものを外す
  scalacOptions --= Seq(
    // Scala 3.0.1以降だとうまく動かない
    // https://github.com/lampepfl/dotty/issues/14952
    "-Ykind-projector:underscores",
  ),
  Test / scalacOptions --= Seq(
    // テストだとちょっと厳しすぎる
    "-Wunused:locals",
  ),
  Compile / console / scalacOptions --= Seq(
    // コンソールで import した瞬間はまだ使ってないから当然許したい
    "-Wunused:imports",
  ),
)

このcompileSettingsは各サブプロジェクトで共通して使うようにします.

クロスプラットフォーム設定

ルートプロジェクトをtlCrossRootProjectで設定する以外はsbt-crossprojectの設定そのままです.

build.sbt:

lazy val root = tlCrossRootProject
  .aggregate(core, hoge)
  .settings(compileSettings)
  .settings(
    console        := (core.jvm / Compile / console).value,
    Test / console := (core.jvm / Test / console).value,
  )

lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform)
  .crossType(CrossType.Pure)
  .withoutSuffixFor(JVMPlatform)
  .in(file("modules/core"))
  .settings(compileSettings)
  .settings(
    description := "My awesome Scala library",
  )

lazy val hoge = crossProject(JVMPlatform, JSPlatform, NativePlatform)
  .crossType(CrossType.Pure)
  .withoutSuffixFor(JVMPlatform)
  .dependsOn(core % "compile->compile;test->test")
  .in(file("modules/hoge"))
  .settings(compileSettings)
  .settings(
    description := "hoge extension",
  )

ピュアなScalaのコードで書いている場合はこれだけでScala.jsとScala Nativeにも対応できると思います.

CI設定

CI設定は, sbt githubWorkflowGenerateを実行すると自動生成されます. .github/workflowsci.ymlclean.ymlが生成されるので, それらもいっしょにコミットするようにします. CI設定が変わるような変更をしたときは再度sbt githubWorkflowGenerateしてコミットしなおす必要があります. もし既存の設定とコミットされたYAMLファイルが乖離していたらCIが落ちて気づけるようになっているので, 一度CIが動くようになれば大丈夫でしょう.

クロスバージョン

crossScalaVersionsクロスプラットフォームの設定は自動的に認識してmatrixが組まれます. テストしたいJVMのバージョンだけ忘れずに追加しておきましょう.

build.sbt:

ThisBuild / githubWorkflowJavaVersions := Seq(
  JavaSpec.temurin("8"),
  JavaSpec.temurin("11"),
  JavaSpec.temurin("17"),
)
ターゲットブランチ

デフォルトだと, リポジトリ上でプルリクエストを作ったときにpull_requestpushの両方でCIがトリガされてしまうと思うので, プルリクエストする先のメインのブランチを指定しておく方がよいと思います.

build.sbt:

ThisBuild / githubWorkflowTargetBranches := Seq("master")
カバレッジ計測設定

残念ながらカバレッジ計測の設定は自動的にはやってくれず, 自分でやる必要があります. たとえばCodecovでカバレッジ計測する場合は次のようにします.

project/plugins.sbt:

addSbtPlugin("org.scoverage"      % "sbt-scoverage"          % "2.0.9")

build.sbt:

ThisBuild / githubWorkflowAddedJobs ++= Seq(
  WorkflowJob(
    id     = "coverage",
    name   = "Generate coverage report",
    javas  = List(githubWorkflowJavaVersions.value.last),
    scalas = githubWorkflowScalaVersions.value.toList,
    steps = List(WorkflowStep.Checkout) ++ WorkflowStep.SetupJava(
      List(githubWorkflowJavaVersions.value.last),
    ) ++ githubWorkflowGeneratedCacheSteps.value ++ List(
      WorkflowStep.Sbt(List("coverage", "rootJVM/test", "coverageAggregate")),
      WorkflowStep.Use(
        UseRef.Public(
          "codecov",
          "codecov-action",
          "v3",
        ),
        params = Map(
          "flags" -> List("${{matrix.scala}}").mkString(","),
        ),
        env = Map(
          "CODECOV_TOKEN" -> "${{secrets.CODECOV_TOKEN}}",
        ),
      ),
    ),
  ),
)

もちろん, Codecovの指示に従ってcodecov.ymlを置くのと, リポジトリsecretsにCODECOV_TOKENを追加するのを忘れずに.

リリース設定

Maven CentralへのパッケージのリリースもCIでやってくれます. organizationリポジトリ名, licenseなどを読み取って適切にアーティファクトを作ってくれます.

アカウントの準備

ここの指示に従いましょう:

やることをまとめると以下になります.

  1. アカウントを持ってなければSonatype/Maven Centralの登録をする
  2. User Tokenを取得する
  3. PGP鍵を生成
古いアカウントを持っている人

2021-02以前からSonatypeアカウントを持っている人は, リポジトリsecretsのSONATYPE_CREDENTIAL_HOSToss.sonatype.orgに設定しましょう.

リリースサイクルの指定

基本的にタグをつけたらリリースでいいと思うので, リリースブランチの指定は消しておきましょう.

ThisBuild / tlCiReleaseBranches          := Seq()

ドキュメントにはデフォルトは空と書いてありますが実際は違っていて, List("main")になっていると思います.

この設定で, vで始まるタグをつけたときだけリリースジョブが走るようになります. tlBaseVersionの部分が変わるときはbuild.sbtも変更しましょう.

アーティファクト名の調整

デフォルトの設定だと, 公開されるアーティファクトの名前はリポジトリ名-サブプロジェクト名になります. たとえば, myprojectというリポジトリcorehogeの2つのサブプロジェクトがあるならmyproject-coremyproject-hogeになります(その他Scalaバージョンやプラットフォームのサフィックスも付きます).

すべてのサブプロジェクトが同列で, 機能を分割したものになっているならこれでいいのですが, core以外のサブプロジェクトはオプショナルなプラグインのような場合には, coreはサブプロジェクトなしのmyprojectで公開したいんじゃないかと思います.

しかしsbt-crossprojectがこういう場合を想定していないため, ちょっとごにょごにょする必要があります. あと, lazy val core = ...in(file("modules/core"))...というようにサブプロジェクト名を繰り返し書いていてコピペしたとき変更し忘れそうなので, そこもついでに対処しておきます. プロジェクト名はprojectNameで明に与えることにして, asModuleWithoutSuffixasModuleでいい具合に設定するようにしてみます.

project/ProjectKeys.scala:

import sbt.settingKey

object ProjectKeys {
  lazy val projectName = settingKey[String]("project name")
}

project/Implicits.scala:

import sbt._
import sbt.Keys._
import sbtcrossproject.{CrossPlugin, CrossProject}
import scala.language.implicitConversions
import ProjectKeys.projectName

object Implicits {
  implicit class CrossProjectOps(private val p: CrossProject) extends AnyVal {
    def asModuleWithoutSuffix: CrossProject = asModule(true)

    def asModule: CrossProject = asModule(false)

    private def asModule(noSuffix: Boolean): CrossProject = {
      val project = p.componentProjects(0)
      p
        .settings(
          moduleName := {
            if (noSuffix)
              (ThisBuild / projectName).value
            else
              s"${(ThisBuild / projectName).value}-${(project / name).value}"
          },
          CrossPlugin.autoImport.crossProjectBaseDirectory := {
            val dir = file(s"modules/${(project / name).value}")
            IO.resolve((LocalRootProject / baseDirectory).value, dir)
          },
        )
        .configure(project =>
          project.in(file("modules") / project.base.getPath),
        )
    }
  }

  implicit def builderOps(b: CrossProject.Builder): CrossProjectOps =
    new CrossProjectOps(b.build())
}

build.sbt:

@@ -1,5 +1,9 @@
+import ProjectKeys._
+import Implicits._
+
 ThisBuild / tlBaseVersion := "1.0"

+ThisBuild / projectName      := "myproject"
 ThisBuild / organization     := "com.github.tarao"
 ThisBuild / organizationName := "my project authors"
 ThisBuild / startYear        := Some(2023)
@@ -25,7 +29,7 @@ lazy val root = tlCrossRootProject
 lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform)
   .crossType(CrossType.Pure)
   .withoutSuffixFor(JVMPlatform)
-  .in(file("modules/core"))
+  .asModuleWithoutSuffix
   .settings(compileSettings)
   .settings(
     description := "My awsome Scala library",
@@ -35,7 +39,7 @@ lazy val hoge = crossProject(JVMPlatform, JSPlatform, NativePlatform)
   .crossType(CrossType.Pure)
   .withoutSuffixFor(JVMPlatform)
   .dependsOn(core % "compile->compile;test->test")
-  .in(file("modules/hoge"))
+  .asModule
   .settings(compileSettings)
   .settings(
     description := "hoge extension",

サイト生成

ライブラリを作ったらWebサイトを作って公開したいですね. sbt-typelevelを使っていればサイトの公開も比較的簡単にできます.

基本設定

必要最小限のこととしてはTypelevelSitePluginを有効化するだけです.

lazy val docs = project
  .in(file("site"))
  .dependsOn(core.jvm)
  .enablePlugins(TypelevelSitePlugin)
  .settings(
    scalacOptions --= Seq(
      // ドキュメントの例ではローカル変数を使わないこともあるだろう
      "-Wunused:locals",
    ),
  )

docsディレクトリ以下に置いた.mdファイルがコンパイルされてsiteディレクトリにHTMLファイルなどが展開されますが, コンパイルからデプロイまではCIが自動でやってくれるので, ソースファイルをdocs下に置くこととsbt docs/tlSitePreviewでプレビューできることだけ知っていればOKです.

GitHub pagesの設定

ここの指示に従って設定します. 具体的には以下を設定します.

  1. https://github.com/{user}/{repo}/settings/actionsで"Workflow permissions"を"Read and write permissions"にする
    • GitHub Actionsが生成したWebページをコミットできるようにします
  2. https://github.com/{user}/{repo}/settings/pagesgh-pagesブランチを公開するようにする
仕組み

裏側の仕組みとしてはmdocプリプロセスしてLaikaレンダリングされます.

mdocは.mdファイル内のScalaのコードブロックをコンパイル・実行して, 結果をコメント行として挿入してくれます.

たとえば,

```scala mdoc
case class Person(name: String, age: Int)

val person = Person("tarao", 3)

person.name

person.age
```

というコードが書かれていたら,

```scala
case class Person(name: String, age: Int)

val person = Person("tarao", 3)
// person: Person = Person(name = "tarao", age = 3)

person.name
// res0: String = "tarao"

person.age
// res1: Int = 3
```

というふうに変換してくれます. これにより必ず動くコード例でドキュメントを書くことができます.

また, @VERSION@に現在のライブラリのバージョンが渡ってくるので

```scala
libraryDependencies ++= Seq(
  "com.github.tarao" %% "myproject" % "@VERSION@"
),
```

のようにしてインストール方法の説明を書いておくと, 常に最新のバージョン番号が埋まった形になります.


Laikaはほどよいテーマとナビゲーションを自動的に提供してくれます. HTML以外への変換もサポートされています.

APIドキュメントやソースコードへリンク

Laikaの@:apiディレクティブ@:sourceディレクティブが正しく動くようにするのは少し面倒ですが, 以下をコピペで大丈夫だと思います. この辺の設定も自動でやってくれると助かるんですけどね...

lazy val docs = project
  ...
  .settings(
    ...

    tlSiteApiModule := Some((core.jvm / projectID).value),
    laikaConfig := {
      import laika.config.{ApiLinks, LinkConfig, SourceLinks}
      val version = mdocVariables.value("VERSION")
      val apiUrl = {
        val group = groupId.value
        val project = projectName.value
        val scalaVer = scalaBinaryVersion.value
        val base = s"https://javadoc.io/doc/${group}/${project}_${scalaVer}"
        s"${base}/${version}/"
      }
      val sourceUrl = {
        val scmUrl =
          scmInfo.value.map(_.browseUrl.toString).orElse(homepage.value).get
        val moduleBase = {
          val rootDir = (root.all / baseDirectory).value.toString
          val crossDir = (core.jvm / crossProjectBaseDirectory).value.toString
          crossDir.stripPrefix(s"${rootDir}/")
        }
        val base = (core.jvm / baseDirectory).value
        val dir = (core.jvm / Compile / scalaSource)
          .value
          .toString
          .stripPrefix(s"${base}/")

        s"${scmUrl}/tree/v${version}/${moduleBase}/${dir}/"
      }

      laikaConfig
        .value
        .withRawContent
        .withConfigValue(
          LinkConfig
            .empty
            .addApiLinks(
              ApiLinks(apiUrl),
            )
            .addApiLinks(
              ApiLinks(s"https://scala-lang.org/api/${scalaVersion.value}/")
                .withPackagePrefix("scala"),
            )
            .addSourceLinks(
              SourceLinks(
                baseUri = sourceUrl,
                suffix  = "scala",
              ),
            ),
        )
    },
  )
サイトの更新サイクル

デフォルトではmainブランチにコミットしたら自動的にデプロイされるようになっています. しかしこれだと, 新機能を実装してそのドキュメントを書いたらリリース前に反映されてしまいます. ドキュメントのバージョニングをきちんとやっている場合は問題にならないかもしれませんが, 普通はリリースと同時にドキュメントも更新し, あとは必要に応じて手で反映できるくらいがいいんじゃないかと思います.

CIによる自動反映をリリース時のみにするには以下の設定をします.

build.sbt:

ThisBuild / tlSitePublishBranch := None

手動で反映可能にするには, workflow_dispatchのジョブを自分で定義する必要があります.

build.sbt:

githubWorkflowGenerate := {
  val sbt =
    if (githubWorkflowUseSbtThinClient.value) {
      githubWorkflowSbtCommand.value + " --client"
    } else {
      githubWorkflowSbtCommand.value
    }

  def indent(output: String, level: Int): String = {
    val space = (0 until level * 2).map(_ => ' ').mkString
    (space + output.replace("\n", s"\n$space"))
      .replaceAll("""\n[ ]+\n""", "\n\n")
  }

  val site = {
    val job = githubWorkflowGeneratedCI.value.find(_.id == "site").get
    job.copy(steps = job.steps.map { step =>
      if (step.name == Some("Publish site")) {
        step.withCond(step.cond.map { cond =>
          s"github.event_name == 'workflow_dispatch' || ( ${cond} )"
        })
      } else {
        step
      }
    })
  }
  val yml = baseDirectory.value / ".github" / "workflows" / "site.yml"
  val rendered = GenerativePlugin.compileJob(site, sbt)

  IO.write(
    yml,
    s"""
       |# This file was automatically generated by sbt-github-actions using the
       |# githubWorkflowGenerate task. You should add and commit this file to
       |# your git repository. It goes without saying that you shouldn't edit
       |# this file by hand! Instead, if you wish to make changes, you should
       |# change your sbt build configuration to revise the workflow description
       |# to meet your needs, then regenerate this file.
       |
       |name: Manual Site Generation
       |
       |on:
       |  workflow_dispatch:
       |
       |env:
       |  GITHUB_TOKEN: $${{ secrets.GITHUB_TOKEN }}
       |
       |jobs:
       |${indent(rendered, 1)}
       |""".stripMargin,
  )

  githubWorkflowGenerate.value
}

実際に使っている例

以上のことを実際にすべてやっているリポジトリの例はこちらになります:


設定に困ったら

sbt-typelevelのコードはそんなに難しくないので, 困ったらコードを読めばいいと思います. sbt-typelevelを使っていそうなリポジトリTypelevelのプロジェクト一覧から探してきて参考にするのもよいと思います.

あとは以下のScalaの日本語コミュニティで訊けば, 僕も含め誰かが日本語で助けてくれると思います.

Invite link for Scalaわいわいランド

まとめ

sbt-typelevelを使うと以下のことをいい感じに設定してくれます.

  • コンパイラオプション
  • クロスビルド
  • CI
  • リリース
  • サイト生成

Scalaのライブラリをしっかり作るときは基本的にこれに乗っかっておけばいいんじゃないかと思います.