sbt-github-acitons を使った CI の構築とプロジェクトの publish について
この記事は Scala Advent Calendar 2023 15日目 の記事です。
導入
Scala プロジェクトを GitHub で開発する際には GitHub Actions を使用して CI を構築することが多いと思います。
また、ライブラリの開発の場合は Maven Central に publish することも考えたいです。
しかし、プロジェクトそれぞれに対応した GitHub Actions を構築するのは専門知識も必要で手間のかかる作業です。
今回は sbt-github-actions という sbt プラグインを使用して、Scala プロジェクトの CI と Maven Central に publish する GitHub Actions のワークフローを作成する方法について説明していきます。
sbt-github-actions とは?
sbt-github-actions は、CI とライブラリリリースのための GitHub Actions ワークフローを生成する sbt プラグインです。
このプラグインを使用することで sbt プロジェクトでの一般的なワークフローを簡単に構築することができます。
使い方
sbt-github-actions プラグインのセットアップ
今回 sbt-github-actions を導入するプロジェクトの例として、拙作の scalatest-otel-reporter を使用します。[1]
まずは、 project/plugins.sbt
に sbt-github-actions プラグインを追加します。
addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.16.0")
build.sbt
にプロジェクトの設定を追加します。
lazy val scala2_13 = "2.13.12"
lazy val scala3 = "3.3.1"
lazy val supportedScalaVersions = List(scala2_13, scala3)
ThisBuild / scalaVersion := scala3
ThisBuild / crossScalaVersions := supportedScalaVersions
// sbt-github-actions
ThisBuild / githubWorkflowJavaVersions := Seq(JavaSpec.temurin("17"), JavaSpec.temurin("11"))
lazy val root = (project in file("."))
.aggregate(`scalatest-otel-reporter`)
.settings(
publish / skip := true,
)
lazy val `scalatest-otel-reporter` = (project in file("scalatest-otel-reporter"))
.settings(
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % "3.2.16" % Provided,
"io.opentelemetry" % "opentelemetry-sdk" % "1.30.0" % Provided,
),
)
Scala のクロスビルドの設定と、ThisBuild / githubWorkflowJavaVersions
に Java のバージョンを設定しています。
今回は Scala バージョンに、2.13.12 と 3.3.1、Java バージョンに Eclipse Temurin の 11 と 17 を指定しました。
GitHub Actions ワークフローを生成する
sbt githubWorkflowGenerate
コマンドを実行すると、GitHub Actions のワークフローを生成することができます。
.github/workflows/
以下に ci.yml
が生成されるはずです。
# 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: Continuous Integration
on:
pull_request:
branches: [ '**' ]
push:
branches: [ '**' ]
tags: [ v* ]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
build:
name: Build and Test
strategy:
matrix:
os: [ ubuntu-latest ]
scala: [ 2.13.12, 3.3.1 ]
java: [ temurin@17, temurin@11 ]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout current branch (full)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Java (temurin@17)
if: matrix.java == 'temurin@17'
uses: actions/setup-java@v3
with:
distribution: temurin
java-version: 17
cache: sbt
- name: Setup Java (temurin@11)
if: matrix.java == 'temurin@11'
uses: actions/setup-java@v3
with:
distribution: temurin
java-version: 11
cache: sbt
- name: Check that workflows are up to date
run: sbt '++ ${{ matrix.scala }}' githubWorkflowCheck
- name: Build project
run: sbt '++ ${{ matrix.scala }}' test
- name: Compress target directories
run: tar cf targets.tar target scalatest-otel-reporter/target examples/manual-configuration/target project/target
- name: Upload target directories
uses: actions/upload-artifact@v3
with:
name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }}
path: targets.tar
publish:
name: Publish Artifacts
needs: [ build ]
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
strategy:
matrix:
os: [ ubuntu-latest ]
scala: [ 3.3.1 ]
java: [ temurin@17 ]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout current branch (full)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Java (temurin@17)
if: matrix.java == 'temurin@17'
uses: actions/setup-java@v3
with:
distribution: temurin
java-version: 17
cache: sbt
- name: Setup Java (temurin@11)
if: matrix.java == 'temurin@11'
uses: actions/setup-java@v3
with:
distribution: temurin
java-version: 11
cache: sbt
- name: Download target directories (2.13.12)
uses: actions/download-artifact@v3
with:
name: target-${{ matrix.os }}-2.13.12-${{ matrix.java }}
- name: Inflate target directories (2.13.12)
run: |
tar xf targets.tar
rm targets.tar
- name: Download target directories (3.3.1)
uses: actions/download-artifact@v3
with:
name: target-${{ matrix.os }}-3.3.1-${{ matrix.java }}
- name: Inflate target directories (3.3.1)
run: |
tar xf targets.tar
rm targets.tar
- name: Publish project
env:
PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
PGP_SECRET: ${{ secrets.PGP_SECRET }}
SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
run: sbt ci-release
build
と publish
の 2 つのジョブが定義されています。
build
ジョブは CI にあたる部分で、設定された Java のバージョンを用いてプロジェクトをビルドし、テストを実行します。
publish
ジョブは build
ジョブで生成されたアーティファクトを Sonatype に publish します。
sbt プロジェクトの設定と githubWorkflowGenerate
コマンドで生成したワークフローをコミットして push すると、GitHub Actions が実行されます。
sbt プロジェクトで設定された Java x Scala バージョンのマトリクスでテストが実行されていることがわかります。
CI 部分ができたので、次は publish の設定を行っていきます。
Sonatype リポジトリのセットアップ
Maven Central に publish するには Sonatype アカウント及びリポジトリのセットアップが必要になります。
この手順について言及されている記事は多いのでここでは割愛します。(一番面倒なとこではあるのですが…🙇♂)
windymelt さん記事が参考になりますので参照ください。
後述の手順で以下の情報が必要になります。
- PGP 秘密鍵
- PGP パスフレーズ
- Sonatype のユーザ名
- Sonatype のパスワード
Sonatype に publish する設定
Sonatype にプロジェクトを publish するために、sbt-github-actions は sbt-ci-release という sbt プラグインのインテグレーションを提供しています。
sbt-ci-release は Sonatype への publish を簡単にするための設定とタスクを提供しています。
project/plugins.sbt
を編集して sbt-ci-release プラグインを追加します。
addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.12")
さらに、build.sbt
に以下の設定を追加します。
// プロジェクトに関わる情報
ThisBuild / organization := "dev.nomadblacky"
ThisBuild / homepage := Some(url("https://github.com/NomadBlacky/scalatest-otel-reporter"))
ThisBuild / licenses := List(License.MIT)
ThisBuild / developers := List(
Developer("nomadblacky", "Takumi Kadowaki", "nomadblacky@gmail.com", url("https://github.com/NomadBlacky")),
)
// 接頭辞に v がついたタグが push された際に publish ジョブを実行する
ThisBuild / githubWorkflowTargetTags ++= Seq("v*")
ThisBuild / githubWorkflowPublishTargetBranches := Seq(
// 接頭辞に v がついたブランチが push された際に publish ジョブを実行する
// (タグ側で publish の設定しているので不要かも…)
RefPredicate.StartsWith(Ref.Tag("v")),
// main ブランチで publish する
// sbt-ci-release では main ブランチで publish すると SNAPSHOT として publish される
RefPredicate.Equals(Ref.Branch("main")),
)
// GitHub Actions の publish ジョブで実行するコマンドを設定する
ThisBuild / githubWorkflowPublish := Seq(
WorkflowStep.Sbt(
commands = List("ci-release"),
name = Some("Publish project"),
// PGP キーと Sonatype の認証情報を GitHub の Secrets から取得する
env = Map(
// 先述の `Sonatype リポジトリのセットアップ` で取得した情報を設定する
// PGP 秘密鍵
"PGP_SECRET" -> "${{ secrets.PGP_SECRET }}",
// PGP パスフレーズ
"PGP_PASSPHRASE" -> "${{ secrets.PGP_PASSPHRASE }}",
// Sonatype のユーザ名
"SONATYPE_USERNAME" -> "${{ secrets.SONATYPE_USERNAME }}",
// Sonatype のパスワード
"SONATYPE_PASSWORD" -> "${{ secrets.SONATYPE_PASSWORD }}",
),
),
)
PGP キーと Sonatype の認証情報は GitHub の Secrets に設定しておく必要があります。
GitHub リポジトリの Settings > Secrets and Variables > Actions から設定します。
Sonatype に publish する
これで準備が整ったので、main
ブランチを GitHub に push します。
GitHub Actions の publish ジョブが実行されるはずです。
sbt-ci-release はブランチで publish ジョブを実行した場合、SNAPSHOT リポジトリに publish するようになっています。
Sonatype を確認すると SNAPSHOT バージョンがリリースされていることがわかります。
今度はタグを切って push します。
今回のリリースバージョンは v0.1.0-alpha としました。
$ git tag v0.1.0-alpha
$ git push origin v0.1.0-alpha
sbt-ci-release では接頭辞に v
がつくタグが push された際に Release リポジトリに publish されるようになっています。
これで無事にライブラリを Maven Central に publish することができました!
結論
sbt-github-actions の登場で、Scala プロジェクトで GitHub Actions のワークフローの構築が簡単になりました。
sbt + Scala プロジェクトを GitHub で開発する際は、ぜひ導入してみてください。
おまけ
ci.yml
と一緒に clean.yml
というファイルが出力されるけどこれはなに?
ci.yml
で build
ジョブで生成されたアーティファクト GitHub にアップロードし publish
ジョブで使用していますが、このアーティファクトは個人もしくは組織のストレージ制限に含まれてしまいます。
このアーティファクトはジョブ間で渡される一時的なものなので、clean.yml
では push
する度にアーティファクトを削除してストレージ制限に引っかからないように制御しているようです。
clean.yml is generated based on a static description because it should just be the default in all GitHub Actions
projects.
This is basically a hack to work around the fact that artifacts produced by GitHub Actions workflows count against
personal and organization storage limits, but those artifacts also are retained indefinitely up until 2 GB.
This is entirely unnecessary and egregious, since artifacts are transient and only useful for passing state between
jobs within the same workflow.
To make matters more complicated, artifacts from a given workflow are invisible to the GitHub API until that workflow
is finished, which is why clean.yml has to be a separate workflow rather than part of ci.yml.
It runs on every push to the repository.
githubWorkflowGenerate
を実行せずにコミットを push したらどうなるの?
sbt-github-actions は githubWorkflowCheck
というタスクを提供しており、現在の ci.yml
に設定が反映されていない場合はエラーになります。
また、生成された ci.yml
の build
ジョブの中で githubWorkflowCheck
を実行しているので、build
ジョブが失敗します。
これにより CI が落ちることで、設定の変更を忘れることを防ぐことができます。
CI で実行されるコマンドをカスタマイズするには?
sbt-github-actions ではジョブの内容をカスタマイズすることが可能です。
Scala プロジェクトでよくある例として、Scalafmt を CI で実行することが挙げられます。
ci.yml
の build
ジョブで Scalafmt を実行するように設定してみましょう。
まず、project/plugins.sbt
に Scalafmt プラグインを追加します。
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")
.scalafmt.conf
に Scalafmt の設定を追加します。
ここは各自のお好みで設定してください。
version = 3.7.17
runner.dialect = scala3
maxColumn = 120
align.preset = more
rewrite.trailingCommas.style = always
今回は build
ジョブ内で定義されている以下の部分に追加で scalafmtSbtCheck
, scalafmtCheckAll
を実行するように設定します。
jobs:
build:
# (省略)
steps:
- name: Build project
run: sbt '++ ${{ matrix.scala }}' test
build.sbt
に以下の設定を追加します。
ThisBuild / githubWorkflowBuild := Seq(
WorkflowStep.Sbt(
List("scalafmtSbtCheck", "scalafmtCheckAll", "test"),
name = Some("Build project"),
),
)
githubWorkflowBuild
キーは上記の test を実行している部分にあたるもので、デフォルトの設定から Scalafmt を実行する step に置き換えました。
この状態で githubWorkflowGenerate
を実行します。
jobs:
build:
# (省略)
steps:
- name: Build project
run: sbt '++ ${{ matrix.scala }}' scalafmtSbtCheck scalafmtCheckAll test
Scalafmt を CI に組み込むことができました!
同じ要領で scalafix などの Linter を組み込むことも可能なはずです。
その他、ジョブのカスタマイズについては sbt-github-actions の README に詳しく記載されていますので参照してください。
-
ScalaTest の実行を OpenTelemetry で計装するためのエクステンションです。(WIP)
元々はこのライブラリの記事を書く予定でしたがライブラリの実装そのものが間に合わず…
いずれ形になったときにはまた記事を書きたいです… ↩︎
Discussion