📦

sbt-github-acitons を使った CI の構築とプロジェクトの publish について

2023/12/15に公開

この記事は 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 とは?

https://github.com/sbt/sbt-github-actions

sbt-github-actions は、CI とライブラリリリースのための GitHub Actions ワークフローを生成する sbt プラグインです。
このプラグインを使用することで sbt プロジェクトでの一般的なワークフローを簡単に構築することができます。

使い方

sbt-github-actions プラグインのセットアップ

今回 sbt-github-actions を導入するプロジェクトの例として、拙作の scalatest-otel-reporter を使用します。[1]

https://github.com/NomadBlacky/scalatest-otel-reporter

まずは、 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

buildpublish の 2 つのジョブが定義されています。

build ジョブは CI にあたる部分で、設定された Java のバージョンを用いてプロジェクトをビルドし、テストを実行します。

publish ジョブは build ジョブで生成されたアーティファクトを Sonatype に publish します。

sbt プロジェクトの設定と githubWorkflowGenerate コマンドで生成したワークフローをコミットして push すると、GitHub Actions が実行されます。

img.png

sbt プロジェクトで設定された Java x Scala バージョンのマトリクスでテストが実行されていることがわかります。

CI 部分ができたので、次は publish の設定を行っていきます。

Sonatype リポジトリのセットアップ

Maven Central に publish するには Sonatype アカウント及びリポジトリのセットアップが必要になります。
この手順について言及されている記事は多いのでここでは割愛します。(一番面倒なとこではあるのですが…🙇‍♂)

windymelt さん記事が参考になりますので参照ください。

https://blog.3qe.us/entry/2023/09/09/233614

後述の手順で以下の情報が必要になります。

  • PGP 秘密鍵
  • PGP パスフレーズ
  • Sonatype のユーザ名
  • Sonatype のパスワード

Sonatype に publish する設定

Sonatype にプロジェクトを publish するために、sbt-github-actions は sbt-ci-release という sbt プラグインのインテグレーションを提供しています。

https://github.com/sbt/sbt-ci-release

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 から設定します。

img.png

Sonatype に publish する

これで準備が整ったので、main ブランチを GitHub に push します。
GitHub Actions の publish ジョブが実行されるはずです。
sbt-ci-release はブランチで publish ジョブを実行した場合、SNAPSHOT リポジトリに publish するようになっています。

img.png

Sonatype を確認すると SNAPSHOT バージョンがリリースされていることがわかります。

img.png

今度はタグを切って push します。
今回のリリースバージョンは v0.1.0-alpha としました。

$ git tag v0.1.0-alpha
$ git push origin v0.1.0-alpha

https://github.com/NomadBlacky/scalatest-otel-reporter/releases/tag/v0.1.0-alpha

sbt-ci-release では接頭辞に v がつくタグが push された際に Release リポジトリに publish されるようになっています。

img.png

img.png

これで無事にライブラリを Maven Central に publish することができました!

結論

sbt-github-actions の登場で、Scala プロジェクトで GitHub Actions のワークフローの構築が簡単になりました。
sbt + Scala プロジェクトを GitHub で開発する際は、ぜひ導入してみてください。

おまけ

ci.yml と一緒に clean.yml というファイルが出力されるけどこれはなに?

ci.ymlbuild ジョブで生成されたアーティファクト 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 に設定が反映されていない場合はエラーになります。

img.png

また、生成された ci.ymlbuild ジョブの中で githubWorkflowCheck を実行しているので、build ジョブが失敗します。
これにより CI が落ちることで、設定の変更を忘れることを防ぐことができます。

CI で実行されるコマンドをカスタマイズするには?

sbt-github-actions ではジョブの内容をカスタマイズすることが可能です。
Scala プロジェクトでよくある例として、Scalafmt を CI で実行することが挙げられます。
ci.ymlbuild ジョブで 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 に詳しく記載されていますので参照してください。

https://github.com/sbt/sbt-github-actions?tab=readme-ov-file#settings

脚注
  1. ScalaTest の実行を OpenTelemetry で計装するためのエクステンションです。(WIP)
    元々はこのライブラリの記事を書く予定でしたがライブラリの実装そのものが間に合わず…
    いずれ形になったときにはまた記事を書きたいです… ↩︎

GitHubで編集を提案

Discussion