🛠️

Scala クロスビルド用バージョンを一元管理する GitHub Action を作りました

2021/12/07に公開

この記事は Scala Advent Calendar 2021 7日目の記事です。

sbt で定義した crossScalaVersions と、GitHub Actions のビルドマトリクス matrix.scala を一元管理したくて GitHub Action を作成したので、紹介します。
https://github.com/exoego/cross-scala-versions

sbt の crossScalaVersions を使ったクロスビルド

ライブラリやツールなどを実装していると、異なる Scala バージョンをサポートしたくなることがあります。
コードベースを異なるバージョンに対してビルドすることをクロスビルドなどと言います。

sbt でのクロスビルドは、crossScalaVersions を用いて次のように複数の Scala バージョンを宣言します。

crossScalaVersions := Seq("3.1.0", "2.13.7", "2.12.14", "2.11.11")
scalaVersion := crossScalaVersions.value.head

このように宣言しておけば、sbt ++ test などとコマンドを実行すると、各バージョンに対して同じコマンドを実行できます。
++ は使用する Scala バージョンを一時的に切り替える命令で、次のように使えます。

  • ++ compile: 宣言されたすべてのバージョンで compile などのコマンドを実行
  • ++ 2.13.5: 2.13.5 など特定のバージョンに切替
  • ++ 2.13.5 compile: 2.13.5 に切り替えて、compile などのコマンドを実行

GitHub Actions でのクロスビルドと CI

コードに何らかの変更を加えたときは、サポートしたいすべての Scala バージョンでちゃんとビルドできることを CI で確認したいですね。
GitHub Actions で単純にやるとこんな CI ジョブになるでしょう。
さきほど説明した ++ により、全部のバージョンでテストが実行されます。

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - run: sbt ++ test # 全部のバージョンをテスト

ただ、この方法には次のような手間があります。

  • 全バージョンが1つのジョブで実行されるので、どのバージョンで失敗したかが分かりにくいい。ログを見る必要がある。
  • どれかひとつが失敗するとそこで打ち切り(フェイルファスト)なので、まだ実行してないバージョンが成功するかどうかが分からない。失敗したバージョンに対して修正したあと、また全体を再実行する必要がある。

ビルドを制御する変数が Scala バージョンひとつだけなら、これでもいいかもしれません。
ですが、Java のバージョン、Node.js のバージョン、Akka のバージョンなど、ビルド変数の組み合わせが増えると大変なことになります。

こうしたビルド変数の組み合わせを扱いやすくするために、メジャーな CI ツールはビルドマトリクスをサポートしています。
GitHub Actions もビルドマトリクスをサポートしています。

ビルドマトリクスを使うことで、Scala のクロスビルドは次のように書けます。

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false # どれかが失敗しても打ち切らずに全体を実行し
      matrix:
        # この変数名はユーザーが自由に定義可能
         scala: [3.1.0, 2.13.7, 2.12.14, 2.11.11]
    steps:
      - uses: actions/checkout@v2
      - run: sbt ++${{ matrix.scala }} test # マトリクスの変数を利用

これでメデタシメデタシ……としたいのですが、これには少し面倒があります。

散らばったバージョン指定をなんとかしたい

ビルドマトリクスの利用により、Scala のバージョン宣言が sbt と CI の2箇所に重複してしまいました。

crossScalaVersions := Seq("3.1.0", "2.13.7", "2.12.14", "2.11.11")
      matrix:
         scala: [3.1.0, 2.13.7, 2.12.14, 2.11.11]

サポートするバージョンをめったに更新しないのならこれでもいいのですが、Scala はたいてい、年に数回はマイナーバージョンがリリースされ、更新したくなります。
リリースのたびに手でバージョンを修正していると、「sbt の crossScalaVersions は修正したが、GitHub Actions の matrix を修正し忘れてしまった」ということが置きるものです。
ぼくも何度かやりました(まあ、大事にはならないのですが)。

こうした細かいトイルを放置していると 「バージョンアップするぞ!」 という気持ちが萎えてしまいませんか?
なんとかして、バージョン指定を一元管理したいですね。

TravisCI 時代の Scala クロスビルド

2020年まで、Scala OSS コミュニティでは CI の老舗 TravisCI がデファクトスタンダードでした。
TravisCI を使った Scala プロジェクトのために、バージョン指定を一元管理できるツールが作られたほどです。
https://github.com/dwijnand/sbt-travisci

このツールは、TravisCI の .travis.yml に書かれた Scala バージョン番号をパースし、sbt から使えるようにする(つまり crossScalaVersions のバージョン指定を省略できる)、というものです。
ぼくも自分が管理する OSS でも TravisCI と sbt-travisci には長年お世話になりました。

ですが、2020年に TravisCI の無料使用枠は制限がきつくなり、まったく足りなくなってしまいました。
また GitHub Actions や CircleCI など比較的最近の CI ではよくあるジョブを再利用できますが、黎明期からあって枯れていた TravisCI ではそれができず、今後の利便性への不安を感じたのもあります。
そんなわけで、ぼくは TravisCI から GitHub Actions に移行しました。
こうした動きは Scala コミュニティに広く見られました

GitHub Actions 時代になって困ったこと

ところが GitHub Actions には sbt-travisci 相当のものはありません。
ぼく自身 sbt-travisci 相当のツールを GitHub Actions 用に作ろうとして、「これは作るの面倒だぞ」と気づきました。

TravisCI での CI ジョブ定義は、機能も少なく非常に簡素でした。
.travis.yml に定義された scala というリストを調べれば、カンタンに Scala のバージョンを特定できました。

一方、GitHub Actions では非常に柔軟に CI ジョブを定義できます。
ファイル名も自由(ci.yamlbuild.yaml……)ですし、ひとつのファイルの中に複数のジョブを宣言できます。
また matrix 名もユーザーが定義できるので、scala でも scala212 でも scala_ver でもなんでもよいのです。

つまり、モダンな CI 定義の柔軟さゆえに、どこに宣言された Scala バージョンを「正」とするか非常に厄介なのです。

そこで cross-scala-versions を作った

sbt-travisci とはアプローチを逆転させることにしました。

従来のアプローチ
sbt-travisci
今回のアプローチ
cross-scala-versions
一元管理先 CI sbt crossScalaVersions
参照側 sbt crossScalaVersions CI

そのアプローチで作ったのが cross-scala-versions という GitHub Action です。
https://github.com/exoego/cross-scala-versions

使い方はこんな感じです。

jobs:
  get_scala_versions:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: exoego/cross-scala-versions@master
        id: run
    outputs:
      scala_versions: ${{ steps.run.outputs.scala_versions }}

  build:
    needs: get_scala_versions
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        scala: ${{ fromJson(needs.get_scala_versions.outputs.scala_versions) }}
    steps:
      - uses: actions/checkout@v2
      - run: sbt ++${{ matrix.scala }} test

流れとしては、

  1. get_scala_versions というジョブで拙作の cross-scala-versions を呼び出し、sbt の crossScalaVersions に宣言されたバージョンを JSON 配列形式で取得します。
  2. それを、後続のメインジョブ build から参照し、matrix.scala にセットしています。

GitHub Actions のさまざまな機能を組み合わせる都合で少し記述は多くなってしまいました。
ただ一度宣言してしまえばあとは crossScalaVersions をメンテナンスするだけ!!
バージョンの一元管理という目標は達成できました!!

cross-scala-versions のまだイケていないところ

  • 昨日 2021/12/06 に作ったばかりなので、本当に単機能で、まだおかしいところもあるかもしれません。
  • crossScalaVersions 解析のために Docker で JVM を立ち上げて sbt を実行するので、20秒前後かかります。
    • クロスビルドするときは遅いジョブだと数百秒かかるので、まあ許容範囲なのではないかと思います。
  • GitHub Actions の記述ももっと簡潔にできないか…?

もっといいアイデアがありましたら、ぜひ issues をオープンしたり、プルリクエストを送るなどお待ちしております!!

関連するツールの紹介

sbt から GitHub Actions の YAMLファイルを生成する、というアプローチもあります。
https://github.com/djspiewak/sbt-github-actions

Scala の Typelevel コミュニティや Disney Streaming 社でご活躍されている Daniel Spiewak さん中心に開発されています。
ぼくにとってはちょっと機能が過剰で、移行作業も大変なので、まだ使ってはいません。
これから新規開発するなど、ユースケースによっては役立つ場面が多いことでしょう。

Discussion