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/workflows
にci.yml
とclean.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_request
とpush
の両方で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
などを読み取って適切にアーティファクトを作ってくれます.
アカウントの準備
ここの指示に従いましょう:
やることをまとめると以下になります.
- アカウントを持ってなければSonatype/Maven Centralの登録をする
- User Tokenを取得する
- リポジトリsecretsの
SONATYPE_USERNAME
とSONATYPE_PASSWORD
に設定
- リポジトリsecretsの
- PGP鍵を生成
古いアカウントを持っている人
2021-02以前からSonatypeアカウントを持っている人は, リポジトリsecretsのSONATYPE_CREDENTIAL_HOST
をoss.sonatype.org
に設定しましょう.
リリースサイクルの指定
基本的にタグをつけたらリリースでいいと思うので, リリースブランチの指定は消しておきましょう.
ThisBuild / tlCiReleaseBranches := Seq()
ドキュメントにはデフォルトは空と書いてありますが実際は違っていて, List("main")
になっていると思います.
この設定で, v
で始まるタグをつけたときだけリリースジョブが走るようになります. tlBaseVersion
の部分が変わるときはbuild.sbt
も変更しましょう.
アーティファクト名の調整
デフォルトの設定だと, 公開されるアーティファクトの名前はリポジトリ名-サブプロジェクト名
になります. たとえば, myproject
というリポジトリでcore
とhoge
の2つのサブプロジェクトがあるならmyproject-core
とmyproject-hoge
になります(その他Scalaバージョンやプラットフォームのサフィックスも付きます).
すべてのサブプロジェクトが同列で, 機能を分割したものになっているならこれでいいのですが, core
以外のサブプロジェクトはオプショナルなプラグインのような場合には, core
はサブプロジェクトなしのmyproject
で公開したいんじゃないかと思います.
しかしsbt-crossprojectがこういう場合を想定していないため, ちょっとごにょごにょする必要があります. あと, lazy val core = ...in(file("modules/core"))...
というようにサブプロジェクト名を繰り返し書いていてコピペしたとき変更し忘れそうなので, そこもついでに対処しておきます. プロジェクト名はprojectName
で明に与えることにして, asModuleWithoutSuffix
とasModule
でいい具合に設定するようにしてみます.
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です.
仕組み
裏側の仕組みとしては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の日本語コミュニティで訊けば, 僕も含め誰かが日本語で助けてくれると思います.