Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

リリース自動化の嬉しみとその手法

DevOpsやCIOps、GitOpsなどを通じて生産性向上を突き詰めていくと、コンパイルやテストだけではなくリリースまで自動したくなってきます。リリースには必要な作業が多く、また頻度も高くないため毎回思い出したり間違えたりが発生するためです。

特に変更内容をまとめて文書化する作業は、利用者に対する影響度もその煩雑さも高いため、自動化できれば文書の品質向上やリリース頻度の向上に大きく貢献できます。本記事では、筆者がNode/Java界隈でよく見るリリース自動化手法について紹介することで、リリース自動化の敷居を下げたいと思います。

なお本記事で言う「リリース」は、jarファイルやコンテナイメージなどビルドの成果物をリポジトリGitHub Releasesにアップロードして他プロジェクトやデプロイ環境で利用できるようにすることを指しています。環境に対する「デプロイ」や、エンドユーザへの公開を意味する「リリース」とは区別します。

自動化の前に

1. Changelogの要件を検討する

ソフトウェアのリリース時にその変更内容について説明する文書のことを、ChangelogまたはRelease Noteと呼びます。組織によってはChangelogとRelease Noteに異なる意味を持たせることもありますが、複数コミュニティから自動化手法を紹介する関係上、この記事では区別しません。

ChangelogにはKeep a Changelogというひろく知られた書式があります。自動化手法にはこの書式を意識したものも多いので、特に困らなければこれを採用します。

OSSでありがちなChangelogの保管場所としては、プロジェクトルートに CHANGELOG.md などのテキストファイルとして配置するか、GitHub Releasesの本文に記載する方法があります。Dependabotなどの依存管理手法はGitHub Releasesを参照して変更内容をユーザーに伝えるので、選択できるのであればOSSでなくともGitHub Releasesを使用することが望ましいでしょう。もちろんテキストファイルと併用しても構いませんし、GitHub PagesやWiki、チャットツールといった他の手法での公開も検討できます。

なおJava界隈ではChangelogに成果物のチェックサムを併記することもあります。これは利用者が成果物の整合性を検証する際に役立ちます。

自動化手法を選択する

1. Release Drafter

以下の記事にて紹介されているため詳細は割愛します。特徴はPRを使って開発しているプロジェクトであれば言語に依存せずほとんどのプロジェクトで採用できることでしょう。Maven開発のような非常に歴史の長いプロジェクトでも採用が検討されているようです

zenn.dev

2. GitHub ReleasesのRelease Note自動生成機能

GitHub Releases自体にも、Pull RequestをもとにRelease Noteを生成する機能が備わっています。 特に設定せずに導入が可能なため、とりあえず使ってみるには便利です。

docs.github.com

ラベルを使ってPRを分類するため、ラベルをPRに抜け漏れなく貼る運用が欲しくなるでしょう。actions/labelerのようなラベル管理を自動化する仕組みをあわせて検討すると良いかもしれません。

想定される利用手法は主に2つです:

  1. GitHub ReleasesからGUIを使ってリリースする手法。Releases作成時にGUI上に生成されるRelease Noteを目視確認できるため、安心して導入できるでしょう。Releasesのドラフトを作成→GitHub Actionsを発火し成果物をビルド→成果物をReleasesにアップロード→Releasesを公開 という流れです。
  2. CLIを使って自動化する手法。リリースプロセスから人手を廃するのに適しています。GitHub Actionsを発火し成果物をビルド→Releasesのドラフトをタグに対して作成、成果物をアップロード→Releasesを公開 という流れです。GitHub Actionsの発火にはタグ、あるいはリリースブランチへのpushを使うことが多いのではないでしょうか。

懸念があるとすれば、GitHubに対するロックインでしょうか。他の自動化手法と比べてバージョンが自動で決定されないのも特徴ですが、これはSemVer以外のバージョニングポリシー(例えばCalVer)を採用しやすいというメリットだと取ることも可能です。

3. semantic-release

SemVer2Conventional Commitsの利用を前提として、コミットメッセージをもとにリリースを自動実行する仕組みです。デフォルトブランチやリリースブランチに対するすべてのpushを契機としてリリースを行います。

semantic-release.gitbook.io

運用に柔軟性を持たせつつも、極力自動化し人の手を入れさせないための工夫が要所に見受けられるのが特徴です。なんせ、使っているロゴがこれです:

f:id:eller:20220216093216p:plain
「人間に作業させるとロクなことにならん」とか言ってそう

例えば運用には各開発者がConventional Commitsに従う必要がありますが、commitizenを使うことで導入障壁を下げることもできますし、commitlintを使うことでコミット時にコミットメッセージ書式の検証を行うこともできますし、semantic-pull-requestsを使うことでコミットメッセージが書式に従っていない場合にGitHub Checksを失敗させることもできます。またPRをsquash mergeすることで、コミットメッセージの決定をマージ時まで遅延することもできます。

またプラグイン機構による拡張も可能ですし、Shareable configurationsを使えば複数リポジトリをまたぐプロジェクトにも一貫した設定を行えますので、ある程度大きな規模の組織でも運用しやすいかもしれません。

利用方法はビルドのワークフローに npx semantic-release を埋め込むだけです。ブランチ名などの情報からリリースを行うべき状況だとsemantic-releaseが判断したら、Changelog生成やリリースが自動的に実行されます。リリースには npm publish./gradlew publish などのすでにコミュニティで利用されている手法が利用されるため、既存のリリース手順を再利用できます。

導入における主な課題は2つ。monorepoがbuilt-inではサポートされていないことと、Node.JS最新のLTSを必要とすることです。semantic-release自体はCI環境で実行するものなので開発者の手元にNode.JSを入れる必要はないのですが、前述のcommitizenやcommitlint, huskyといった関連ツールもほぼNode.JSコミュニティによって管理保守されているため、Node.JSを入れる判断をすることもあるでしょう。そのためNode.JS以外の環境を対象に開発しているプロジェクトではプロジェクトセットアップが若干複雑化するかもしれません。

なお似たものにstandard-versionrelease-itがあります。私は中の人を尊敬しスポンサーしているので、semantic-release推しです。spotbugs-gradle-pluginなど複数のOSSプロジェクトで使っていて、貢献受け入れやリリースを含め問題なく便利に回せています。

4. changesets

Atlassian発のmonorepoに特化した仕組みです。Node.JSを使ったmonorepoを開発しているのであれば検討しても良いかと思いますが、私はまだ試せていません。既存ユースケースもstandard-versionやsemantic-releaseと比べると1桁少ないです。

github.com

5. JReleaser

Node.JSではなくJVMを用いて動く仕組みです。他の仕組みと比べてまだ若いですが、既にMavenやGradleのサポートも用意されています。

jreleaser.org

f:id:eller:20220216093124p:plain
https://jreleaser.org/guide/latest/index.html より引用

Gradle用のクイックスタートを見た感じでは、 maven-publish プラグインではなく自前でリリース用の設定を持つようです。ここが ./gradlew publishnpm publish用の設定が完成されたプロジェクトに外付けする semantic-release とは大きく思想が異なる点です。コミュニティの進歩にJReleaserが自前でついていく必要があるため、保守コストが高くなる選択だと言えます。個人的には期待しつつもちょっと様子見です。

リリース自動化の果てに

1. 手動作業が残る部分

Changelog以外の文書は引き続き手動で作成する必要があります。例えば以下のようなものです:

  • エンドユーザ向けに変更内容を説明する文書
  • メジャーリリース時のマイグレーションガイド
  • マニュアル、プレス、その他

TwitterやSlackなどでの更新通知は自動化が可能ですが、もしブログ記事やメール通知のような手の込んだ文章を作成しているのであれば、それも残るでしょう。

ただこうした文書や通知はパッチリリース時にはあまり作らないはずで、パッチリリースの高速化・安定化・高頻度化は自動化によって充分に実現できると期待できます。

2. リリース自動化に向いたプロジェクト構成

CIやリリース自動化を推し進めると、ビルドやリリースに手作業が必要なプロジェクト構成はやりにくくなります。たとえば依存ライブラリを手でダウンロードしないとビルドできないとか、バージョン番号を手で書き換える必要があるとかです。

依存ライブラリについては、幸いJava界隈ではMaven Centralからほとんどのライブラリをダウンロードできます。昔では考えられなかったライブラリ、例えばOracle JDBC Driverもありますので、一度探してみるといいでしょう。
Maven Centralやその他のパブリックリポジトリに置いてないライブラリを使う場合は、自前でMaven Private Repositoryを管理してそこに置くことになるでしょう。これはNode.JSにおいても同様です。

バージョン番号の更新は、自動的に更新できるようにする必要があります。今回紹介した仕組みでサポートされているケースもありますし、Maven Release Pluginなどの機能を使うこともできます。ビルドツール設定以外のファイル、例えば META-INF/MANIFEST.MFBundle-Versionなどは手動ではなくビルドツールが自動で生成するようにしましょう。

3. リリース自動化に向いたブランチ戦略

リリース自動化は多くの場合、デフォルトブランチやリリースブランチが「常時リリース可能」であることを前提としています。 これは極力従うことが好ましいでしょう。

もしブランチが常時リリース可能でなかったら、リリース作業前に「リリース可能かどうか」を人間が検証する必要性が出てしまうためです。 もともと「自動化により人間の関与を減らしリリースの安定性と頻度を増やす」ことを目的に自動化しているのですから、ここに人間による作業を入れてしまうのは本末転倒です。少なくともリリース可能性検証のプロセスを自動化して、マージ前ビルドないしリリースビルドで自動的に検証されるようにするべきでしょう。

注意点として「マージしたらリリースされてしまう」ことを「完成するまでマージするべきではない」と受け止めない事が必要です。これはトピックブランチの寿命は短いほうが開発効率に良い影響があるためです。リリースできないとわかっている変更を公開することは避けつつ高頻度に変更をマージするために、Feature Toggleを使うなどの工夫が必要になるかもしれません。

まとめ

本記事では、筆者がNode/Java界隈でよく見るリリース自動化手法について紹介しました。

自動化手法 特徴 注意点
Release Drafter PRを使っていれば言語やビルドツールに関係なく利用可能。 リリースビルド用のワークフローを別に用意する必要がある。
GitHub ReleasesのRelease Note自動生成機能 設定不要。ラベルをもとにPRを分類してRelease Noteに反映。 バージョン番号を自分で決める必要がある。Release Note生成だけで、リリース作業自体は別に実行が必要。
semantic-release 柔軟な拡張性と徹底した自動化を両立。 実行にNode.JS最新のLTSが必要。標準ではmonorepoに非対応。
changesets 標準でmonorepoに対応。 Node.JSプロジェクト用。
JReleaser MavenやGradleといったビルドツールと統合。 JVM言語プロジェクト用。

リリース自動化はリリースの安定性と頻度を増やせる強力な仕組みです。ものによってはChangelogやコミットコメント、ブランチ戦略にリリース検証可能性検討の自動化といったものまで見直しをかける必要がありますが、それらも開発効率や生産性に寄与すると考えられているものがほとんどなので、開発体験向上のため検討してみてはいかがでしょうか。

Gradle/Kotlinで開発する私的ベストプラクティス2022

こちらのエントリーが素敵だなと思ったので、最近書いてるKotlinプロジェクトのベストプラクティスをまとめてみます。一部はJavaプロジェクトにおいても利用できるはずです。

zenn.dev

基本方針

  • 参加障壁を下げる。OSSプロジェクトでもプロプライエタリ・ソフトウェアプロジェクトでも、新しい開発者が参加するコストを下げることには大きな意義がある。
  • 環境差異を吸収する。javaにPATHが通ってさえいればOSに関係なくビルドが通るようにする。
  • プロジェクト固有ルールを作らない。Conventional CommitsやKeep a changelogなど、ひろく世に使われているルールを採用する。

Gradleを設定する

Spotlessを使う

コードのフォーマットはformatterに任せて人間は細かいことを考えない、というのが不特定多数が参加するソフトウェアプロジェクトのあるべき姿だと考えています。ここを妙にこだわるとエディタ縛りだとかタブ幅だとかのいわゆる”地雷”の多い話題を避けて通れませんし、プロジェクト固有のルールができて敷居が高くなり保守コストが高くなるという課題もあります。

逆に言えば、フォーマットをツールに任せれば、人間はエディタやOSの選択の自由を享受できます。

Gradleの場合、最もシームレスに使えるツールはSpotlessでしょう。MarkdownとKotlin、Kotlin Buildscriptすべてのフォーマットをこのツールで管理できます。

github.com

pre-merge buildでは spotlessCheck タスクを使ってフォーマットを確認します。このタスクは通常 check タスクから依存されていますので、深いことを考えずに ./gradlew build すれば充分です。

Git hookを導入する

commit時にSpotlessを自動実行するには、git hookを使います。git hookの設定にはghooksを使っていたのですが、脆弱性対応含む更新が止まっており*1使いにくい状態です。後述するsemantic-releaseを使うのであれば、NodeJSにPATHが通っている前提でhuskyを使っても良いでしょう。

JVM toolchainを使う

現時点でJavaには8,11,17という3つのLTSリリースがあります。誰でも簡単にビルドできるプロジェクトを作るためには、どのバージョンにJAVA_HOME環境変数が通っていても問題なくビルドできるべきです。このためのアプローチには「Java 8で動くようにする」と「JVM toolchainを使う」の2択があります。

既にJava 8をサポートしていないGoogle Errorproneやサポート終了を予告しているApache Camelのようなツールもありますので、JVM toolchainを使って「JAVA_HOMEがJava 8を指していても常にJava 11あるいは17を使ってビルドする」方が現時点でしょう。最新のKotlinプラグインならtoolchainの利用は容易です:

kotlin {
    jvmToolchain {
        (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(17))
    }
}

.gitattributes を書く

GitHubを使っている場合、人間に差分をレビューさせたくないファイルがあれば linguist-generated=true を指定しておきます。

./gradlew init で生成されるファイルを参考に、Windows向けの改行コード設定 *.bat text eol=crlf を加えても良いでしょう。

*.bat text eol=crlf
gradlew linguist-generated=true
gradlew.bat linguist-generated=true

settings.gradle.kts を書く

公式ドキュメントで言及されているとおり、設定ファイルをプロジェクトルートに置くことが強く推奨されています。 ./gradlew initでプロジェクトを作っていれば自動的に作成されているはずです。

最低限rootProject.nameを設定しておきます。これでどのようなフォルダ名が使われていても同じプロジェクト名になります。 またビルドスキャンをする予定があるなら、このタイミングで設定をしておくと良いでしょう。

plugins { id("com.gradle.enterprise") version "3.8.1" }

rootProject.name = "foo-bar"

gradleEnterprise {
  buildScan {
    termsOfServiceUrl = "https://gradle.com/terms-of-service"
    termsOfServiceAgree = "yes"
  }
}

gradle.properties を書く

公式ドキュメントをもとに設定しておきます。Java併用時に google-java-format を使う場合、少なくとも Java 17では--add-exportsの指定が必要です。

org.gradle.caching=true
org.gradle.configureondemand=true
org.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError -Xmx1G --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
org.gradle.parallel=true

ドキュメンテーションコメントにはDokkaを使う

私はあまり語れるほど使っていないのですが、KotlinプロジェクトではKDocをドキュメンテーションコメントに使い、KDocからの文書生成にDokkaを使います。設定も単純なので特に迷うことなく使えるはずです。

github.com

build タスクに必要なタスクすべてを実行させる

Gradleプロジェクトでは基本的に、 ./gradlew build によってプロジェクトのビルドと検証に必要なタスクがすべて実行されるべきです。assemble タスクによって成果物が作られ、 check タスクによってすべてのテストや検証が実行される状態を保つことで、これを実現できます。これらのタスクの役割は ライフサイクルタスクとしてドキュメントに説明されています。

ただSpotlessを使っている場合、手元では spotlessApply も実行したいがCI環境ではspotlessApply を実行させたくない、というケースがあるかもしれません。

これにはDSLによりCI環境の場合に分岐する、CI環境では -x オプションにより特定タスクをスキップする、手元で ./gradlew spotlessApply を明示的に実行させる、などの手法があります。プロジェクト固有ルールを作らないという方針と、標準では build タスクが spotlessApply タスクに依存していない現状を踏まえると、手元で ./gradlew spotlessApply を明示的に実行するのが良いと考えています。

とはいえ人間に「push前に./gradlew spotlessApplyを実行してね」というプロジェクト固有ルールを作りたくはないので、前述のGit hookに実行させる方針を採ることになるでしょう。

GitHub Actionsを設定する

ごく一般的なワークフロー設定は以下のようになります。 なおWindowsでビルドする場合は、--no-daemonオプションでGradle daemonを無効化する必要があります

jobs:
  build:
    strategy:
      matrix:
        os: ['windows-latest', 'ubuntu-latest']
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v2
      - uses: gradle/wrapper-validation-action@v1
      - run: ./gradlew build --no-daemon
      - uses: actions/upload-artifact@v2
        if: always()
        with:
          name: reports (${{ matrix.os }})
          path: build/reports

Dependabotを設定する

現時点では buildSrc プロジェクトの依存は更新対象に入っていません。以下のように明示的に指定する必要があります。

version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "daily"
    commit-message:
      prefix: "build"
  - package-ecosystem: "gradle"
    directory: "/"
    schedule:
      interval: "daily"
    commit-message:
      prefix: "fix"
  - package-ecosystem: "gradle"
    directory: "/buildSrc"
    schedule:
      interval: "daily"
    commit-message:
      prefix: "build"

逆に言えば、ビルドに用いるツールの依存を buildSrc プロジェクトに集めることで、ビルドに用いるツールとプロジェクトそのものの依存とを分けて扱うことができます。上記設定ではプロジェクトそのものの依存にのみ fix 接頭辞を用いることで、semantic-releaseによるリリースを発火させています。

semantic-release を使う

以下の記事で以前紹介したので詳細は省きます。なお今なら拙作GradleプラグインがあるのでGradleでもsemantic-releaseのフル機能を用いて開発できます。

blog.kengo-toda.jp

PATHにNode最新のLTSが通っている状態にする必要がありますが、これはnvmやasdfを使うことになるでしょう。最新のactions/setup-node@v2はnode-version-fileでファイルからNodeのバージョンを読み込めます:

- uses: actions/setup-node@v2
  with:
    node-version-file: '.nvmrc'
    cache: 'npm'
- run: |
     npm ci
     npx semantic-release

以上です。なおJava用ではありますが、ビルドの細かい話やGitHub Actionsの使い方については以下の本にも書いてますのでご参考まで。

zenn.dev

*1:PRは作ったんですが見てもらえてなさそう

--add-exportsをMaven/Gradleで使う

--add-exports なんてオプションは使わないに越したことはないのですが、依存先ライブラリの都合でどうしても必要という私のような人のためのメモ。

ポイントは javac だけでなく javadoc ないし java (テスト実行)コマンドに対するオプション提供も必要という点です。Gradle用のサンプルプロジェクトMaven用のサンプルプロジェクトもあります。

コンパイラ用の設定

注意点として、--add-exports オプションは --release オプションと同時に使えません

Maven3

  <build>
    <plugins>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.9.0</version>
        <configuration>
          <source>11</source>
          <target>11</target>
          <encoding>${project.build.sourceEncoding}</encoding>
          <forceJavacCompilerUse>true</forceJavacCompilerUse>
          <showWarnings>true</showWarnings>
          <compilerArgs>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
            <arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
          </compilerArgs>

Gradle

// build.gradle.kts
val exportsArgs = listOf(
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
    "--add-exports",
    "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
)

tasks.withType<JavaCompile>() {
    sourceCompatibility = "11"
    targetCompatibility = "11"
    options.compilerArgs.addAll(exportsArgs)
}

javadoc用の設定

いろいろとオプションを指定する手法が提供されていますが、以下のように<additionalOptions>を使う方法での動作を確認しました。内部的にはOptionファイルを経由してjavadocコマンドのオプションを設定しています。

Maven

  <build>
    <plugins>
      <plugin>
      <artifactId>maven-javadoc-plugin</artifactId>
      <version>3.3.1</version>
      <configuration>
        <additionalOptions>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
          <arg>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
        </additionalOptions>
      </configuration>
    </plugin>

Gradle

一気にややこしくなります。 options.addStringOption()では動作しないので、Maven同様にOptionファイルを経由しての指定が望ましいです。

// build.gradle.kts
val addExportsFile = file("$buildDir/tmp/javadoc/add-exports.txt")
val createJavadocOptionFile by tasks.registering {
    outputs.file(addExportsFile)
    doLast {
        addExportsFile.printWriter().use { writer ->
            exportsArgs.chunked(2).forEach {
                writer.println("${it[0]}=${it[1]}")
            }
        }
    }
}
tasks {
    withType<Javadoc> {
        dependsOn(createJavadocOptionFile)
        options.optionFiles(addExportsFile)
    }
}

テスト実行

Maven

surefireプラグインJVMをforkするなら、forkするJVM側にオプションを設定します。そうではない場合、以下のような .mvn/jvm.config ファイルでMavenを実行するJVM自体に設定を行います。

--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED

Gradle

jvmArgs プロパティを経由して設定します。このプロパティは標準でnullなので、ビルドスクリプトの他の箇所で設定していないなら .addAll() ではなく = を使って設定します。

// build.gradle.kts
tasks {
    test {
        useJUnit()
        jvmArgs = exportsArgs
    }

JavaScript Actionsをnode16で動かすようにする

この記事はCI/CD Advent Calendar 2021に参加しています。


先日GitHub ActionsがNodeJS v12のみならずv16でも動くようになりました。

github.com

今まではNodeJS v12しかサポートされていませんでしたが、このv12は来年の4月でサポートが切れます。速やかにv16に移行したほうが良さそうですね。必要な作業は actions.ymlruns.using を書き換えるだけではありますが、他に必要になるであろう作業もいくつか紹介します。

package.jsonengines.node を更新

package.jsonで実行に利用するNodeJSのバージョンを指定していた場合、それを更新する必要があります。16.13.1 が現在の最新バージョンなので、望ましい設定は以下のようになるでしょう:

  "engines": {
    "node": "^16.13.1"
  }

tsconfig.json を更新

NodeJSを12から16に上げると、ES2019ではなくES2021を使えるようになります。TypeScript公式の推奨設定を参考に、compilerOptionsを更新します:

{
  "compilerOptions": {
    "lib": ["ES2021"],
    "module": "commonjs",
    "target": "ES2021"
  }
}

TypeScriptを更新

ES2021をサポートしていないTypeScriptを使っている場合、TypeScript自体の更新が必要になります。 npm add -D typescript@^4.5.4 などとして更新しましょう。

なおTypeScript 4.4で、catchされたオブジェクトの型をanyからunknownに変更されています。トップレベルでエラーをキャッチして core.setFailed() を呼んでいる場合は修正が必要です:

-     core.setFailed(error.message)
+     if (error instanceof Error) {
+       core.setFailed(error)
+     } else {
+       core.setFailed(JSON.stringify(error))
+     }

ESLintを更新

ESLintの現時点での最新は8.5.0です。私が更新した多くのリポジトリではいくつかのプラグインも導入していましたが、そのような場合は npm add -D eslint@^8.5.0 @typescript-eslint/parser@^5.8.0 eslint-plugin-github@^4.3.5 eslint-plugin-jest@^25.3.0 などとして同時にアップデートすることが良いと思います。

.eslintrc.json の更新はESLintのリリースノートに載っているマイグレーションガイド(例えば3.0.0用マイグレーション)に従っても良いですが、特に設定にこだわりがないならば actions/typescript-actionのデフォルトブランチ から最新の推奨設定を引っ張ってくる方が時間は短縮できます。

GitHub Actions WorkflowでNodeJS v16を使う

GitHub hosted runnersでworkflowを実行し、かつactions/setup-node を使っていない場合、今月10日ごろから既にv16を使ったビルドが走っています。特に対応不要です。

actions/setup-node を使っている場合は、node-versionを変更する必要があります。ただ個人的には .nvmrc.node-version に使うバージョンを書いておいて、GitHub Actionsではその値を読むのがメンテ箇所が減るためおすすめです。setup-nodev2.5.0からnode-version-file設定がサポートされています

  - uses: actions/setup-node@v2
    with:
      node-version-file: '.nvmrc'

まとめ

以上です。GitHub JavaScript ActionはいつNodeJSのバージョンを上げるんだろう……と不安になっていた方々、やっと来ましたのでバンバン上げていきましょう。私が実際に作成した変更をいくつか並べておきますので、ご参考まで。

2021年のOSS活動状況まとめ

昨年のに引き続きOSS活動状況をまとめます。2021年12月20日時点の情報です。

概要:昨年比30%増

GitHubのプロファイルページによると今年のpublic contributionsは1,865で、昨年が1,440だったので約30%増です。commit 63%のpull requests 12%なので、引き続き手を動かしてコードを書けたと思います。

f:id:eller:20211220102614p:plain
my GitHub profile (2021/Dec/20)

主なリリースはspotbugs-gradle-plugin 4.6.1~5.0.3SpotBugs 4.2.1~4.5.2gradle-semantic-release-plugin 1.4.14~1.6.0actions-setup-docker-compose 1.0.0~1.0.4でした。

SpotBugs周辺の開発

一番大きい貢献はsonar-findbugsのメンテナを引き渡したことだと思います。このプロダクトを開発する過程においてSonarQubeと距離を置きたいと思うようになったため、先日のv4.0.5リリースから新しいメンテナ2名にPRのレビューやマージ、リリースプロセスの扱いなど全般を引き渡しました。活発なプロダクトの引き渡しはこれが初めてだったのですが、いまのところうまく回っているようで安心しています。

またSpotBugsコアにおいてSARIFレポートのGitHub Code Scanning API対応(v4.4.1)と複数レポートの出力(v4.5)とを完了したため、人間向けのHTMLレポートと機械向けのXMLやSARIFレポートとを同時生成するという普遍的なニーズに応えられるようにできました。これユーザには地味に便利な更新なんじゃないかと思います。

なお偽陽性偽陰性は3つ修正しました。これらも歴史が長くユーザ基盤のあるプロダクトとしては望まれるものでしょう。

発展的内容としてはGraalVMのnative-imageでパフォーマンスが向上するか試しています。残念ながら優位な差は出ませんでしたが、picocliで作ったCLIツールのネイティブイメージを作るための必要な知見が溜まったのは良かったです。

GitHub Actions周りの開発

actions/setup-java v2.3.0dependency cacheを実装しました。JavaプロジェクトをGitHub Actionsでビルドしている人なら、この機能による高速化とWorkflow定義の短縮化の恩恵を受けているのではないでしょうか。名前こそ出ませんでしたがGitHub公式ブログで紹介されたのが嬉しかったです。

また5月にはwrapper-validation-actionの安定性を向上させています。このActionは通信断でわりとすぐ落ちていたので、この変更の恩恵を受けている開発チームはけっこうあるんじゃないでしょうか。

GitHub Actionsについてはブログ記事も多く投稿しました。Dependabot周りで変更が多かったため、Dependabotが作るPRでいかに(SonarQubeのために)Secretsを使うか、を探求していた気がします。

なおreadthedocs-action公式のPRレビュー機能が充分に実装されたため、そろそろ不要かなという気がしています。

プログラミング言語

2021年はGroovyとJavaをKotlinで置き換えた年になりました。Javaはもちろん良い言語で、自分の強みの大きな部分を構成する言語です。と同時に変化の速い言語環境に身を置くことはアンテナを高く保つために有用ですし、Gradleビルドスクリプトに静的型付けを持ち込めるという利点は大きいと判断しています。並列実行性能を上げる技術基盤としての価値も期待していますが、そちらはまだ検証段階に至れていません。

似た理由でPowerful Command-Line Applications in GoをベースにGolangのキャッチアップもはじめましたが、こちらは2022年の挑戦になりそうです。

また自分がとても尊敬しているstatic program analysisの講義をしているAarhus Universityが新しくFlix言語を開発中、ということを知ってキャッチアップしています。PureとImpureを明示的に扱うというアプローチが面白いです。なおPRはひとつ送りましたが、残念ながらinvalidでした。最新のリリースではなく、デフォルトブランチでの挙動を確認すべきでした。
Flixはまだツールチェインに乏しいのでflix-gradle-pluginを実装してみましたが、言語標準のPackagerに手を入れないと依存管理周りは良いものにならなさそうなため、最近はScalaを読んでいます。

その他

Vert.xでReactiveなドキュメントビルダーのPoCも書いたのですが、こちらは技術的に無理は無さそうだけど言うほどパフォーマンスに寄与しなさそうという結論を出しました。外部サービスにビルドを委ねる大型プロジェクトでは意義があるかも?ですが、新ツールを書き直す程ではないかなと。
ReactorやVert.xのようなReactiveにプログラムを動作させるための取り組みには、引き続き関心を払っていこうと思います。

Sponsorを募集しています

昨年2月からGitHub Sponsorsを始めています。特にSpotBugsないしそのGradle プラグインをご利用の皆さま、ご支援のほどよろしくご検討ください。 SpotBugsのような古く自分ではあまり使わないプロダクトの保守を続けるにあたって、ユーザーや開発者からのわかりやすく明示的な支持があるとありがたいです。

github.com

継続的に支援するほどでは……という場合、ぜひこちらのGitHub Discussionsにて「誰が、どこで、どのように使っているか」を共有していただけるだけでも助かります。よろしくお願いします。

github.com

Gradleプラグインのメジャーアップデートにおいて、古いGradleへのサポートをいかにして切るか

spotbugs-gradle-plugin v5をリリースしました。beta1のリリースから約3ヶ月間かかっています。

github.com

Gradleプラグイン開発はややマイナーな取り組みだと思うので、Gradleプラグイン開発のメジャーアップデートがどういうものだったのかをちょっと紹介します。

なぜ後方互換を壊す必要があったのか

spotbugsプラグイン固有の理由はもちろんあります。デフォルトの設定がうまくなかった、リソースリークの解消にはデフォルトの動作モードを変える必要があった、などです。これに加えてGradleプラグインならではの理由があります。

古いGradleへのサポートを切るため

Gradle 7をサポートするには、Gradle 7で削除されたGradle APIへの依存を切る必要があります。この場合、移行先Gradle APIが存在するGradleのバージョンが最低限サポートできるバージョンになります。例えば今回削除された compile configurationの代替となるimplementation, api configurationsはGradle 3.4で導入されているので、プラグインがこれらのconfigurationに依存する場合は3.4が最低限サポートできるバージョンになります。

新しいGradleで導入された機能を使うため

Gradleは新しい機能を継続的に取り入れており、中にはJavaプロジェクト開発の体験を大きく変えるものも存在します。依存管理やキャッシュの改善、差分コンパイルの実現によるビルド時間の短縮が良い例ですね。Gradle社としてはこうした改善に命運を賭けているようで、Developer Productivity Engineering (DPE)という造語を作って強く押し出しています。

gradle.com

そしてこうした機能を前提にプラグイン開発を行う場合、自然と古いGradleに対するサポートを切る必要が出てきます。 例えば今回入れたJava toolchain対応はGradle 6.7で導入され、7.0以降で安定しています。これがv5とv6のサポートを切る主な動機となっています。

コードを簡潔に保つため

とはいえ動的にGradleVersionを調べるなどしてプラグインの動作を切り替えることで、古いGradleをサポートしつつ新しい機能を使うことも可能です。例えば今回のリリースでもGradle 7.1で取り入れられた JavaPluginExtension を使いつつGradle 7.0もサポートしています。

github.com

しかしこの方法はコードが複雑化する上、概念レベルでの変更への対応が難しいため、そこまでコストを掛けて互換を保つ判断はあまりしないのではと思います。 むしろこうした変更をパッチアップデートで行うspotlessのようなプラグインも存在します。

ユーザのスムーズな移行のため、Gradleのメジャーバージョンを複数サポートするバージョンを提供することは重要ですが、同様に新しいGradleメジャーバージョンに集中して管理しやすいリポジトリを保つこともまた必要です。Gradleの場合コミュニティの進歩が速く、ユーザもそれを前提に頻繁なアップデートをある程度覚悟していますので、プラグイン側が過度に古いGradleのサポートに固執する必要はないとも思われます。

まとめ

Gradleプラグインのメジャーアップデートにおいて、古いGradleへのサポートはコードを簡潔に保つためにも早めに切ってしまうべきです。マイナーアップデートやパッチアップデートで古いGradleへのサポートを切るプラグインもあり、それがコミュニティに受け入れられているくらいですので、古いGradleをサポートすることよりも新しいGradleをサポートするための素早い継続的デプロイを実現することにコストを割いたほうが良いでしょう。

なおGradleプロジェクトにおける素早い継続的デプロイには、semantic-releaseの活用がおすすめです。私が実装しメンテナンスしているgradle-semantic-releaseについては以下の記事で紹介しています。

blog.kengo-toda.jp

WIP: Gradleの機能でどこまでビルド性能が改善するのか

Gradleの --parallelJUnit並列実行、Configuration Cacheなどがどの程度ビルド性能を改善するのか、いくつかのOSSで実測してみた。利用した機能の概要は以下オフィシャルサイトを参照のこと。

docs.gradle.org

SonarQubeの事例

バージョン 9.0.1.46107 リリース後の master ブランチで実践。進捗などは以下のIssueで管理している:

github.com

もともとGradle 6.8.2を使っていたが、1,092ものbuild deprecationsが報告されておりGradle 7に直接アップグレードすることができない状態。Gradle 7ではいくつかのTaskのaccessorがLazy ConfigurationのためのAPIで置き換えられており、移行が必要。また依存管理に非推奨となっているcompiledefaultを使っており、implementationruntimeClasspathJava Library Pluginが提供するapiなどへの移行が必要。

最大の難関は com.github.hierynomus.license プラグインcom.github.johnrengelman.shadow プラグインが古く、数多くの警告が出ているというもの。タスクの入出力が不明瞭なためキャッシュも効いていない。またプラグインの最新版がGradle 6をサポートしていないため、これらのアップグレードはGradleの7へのアップグレードと同時に実行する必要があった。

f:id:eller:20211020102235p:plain
手を加える前のBuild scan結果。プラグイン由来の警告が多い。

改善結果

21分半かかっていたフルビルドが16分弱(73.3%)で終わるようになった。

f:id:eller:20211204093213p:plain
中央値で比較して73.3%に短縮

Parallel buildはビルド時間を1分程度短縮する効果があったが、GitHub ActionsのHosted Runnerは2コアしかないためデフォルトでは2並列にしかならない。サブプロジェクトが多いなら --max-workers などの設定でワーカー数を増やす方が良い。

f:id:eller:20211020104306p:plain
4並列でビルドしたときのタイムライン

テストの並列実行は残念ながらビルド性能の改善に繋がらなかった。Parallel buildでCPUを使い切っており性能が出なかったためと思われる。実際サブプロジェクトひとつのテストを実施してみると、テストの並列実行を有効化したほうが性能が出る。サーバが必要なBuild Cacheは試していないが、これを有効化することでフルビルドの必要性が下がるため、テスト並列実行の重要性も上がると期待される。

SpotBugs

実験中。Antから無理やり移したタスクがEclipse plugin周辺で残っているので、まず buildSrc にビルドロジックを移動させるところからやる必要がある。 Eclipse関連の依存をMaven Centralからダウンロードするようにしたいが、以下の課題が未解決。

www.eclipse.org