🦾

Clojure CLI を使ったライブラリのビルドやリリースを楽にする

2022/05/26に公開

TL;DR

Clojure CLI を使ったライブラリのビルドやリリースを楽にするライブラリ build.edn を作りました。

動機

Clojure を使った開発においては Leiningen を使っている方はまだまだ多いと思います。
Leiningen ではビルドやリリースの手順が lein jar, lein release, lein deploy といったサブコマンドで統一されており、使う側としては特に悩むことはありません。
ではどうして Leiningen ではなく Clojure CLI を使うのか、なぜ Clojure CLI ではこれらの作業が楽ではないのかという点をそれぞれ問題と思っていること(主観)から説明したいと思います。

Leiningen の問題点

個人的には以下2つの問題を Leiningen には持っていました。

1. GPG が面倒くさい問題

Leiningen ではタグ付けやデプロイ時の署名にGPGを使うことができます
タグ付けにおいては --no-sign オプションをつけることで GPG を使った署名を省略することができますが、clojars.org などへのデプロイ時には署名が必要(?)になってしまいます。(自分が知らないだけで省略する方法があるかもしれないです。教えてチョットデキル方...)
個人的にはPCを買い替えた後に Leiningen からデプロイしようとすると必ず躓いていて、そういった事情からデプロイできる環境の用意とデプロイ作業自体に苦手意識を持っていました。

2. lein release が怖い問題

lein release はその名の通り新しいリリースを切るためのサブコマンドです。
実体は複数の操作をひとまとめにしたもので、デフォルトでは以下の操作が行われます。

とりあえず lein release しておけばリリースに必要な操作は全部やってくれるので便利といえば便利なのですが、デフォルトでデプロイや git push も含まれ外部サービスへの副作用があるので気軽に実行できません。
この挙動は project.clj 上で変更できるので安全な操作だけにすることもできますが、lein release を実行した時に何が起きるのかはちゃんと確認しないとわからない状況は変わりません。

リリース作業自体はそこまで頻繁に行う作業ではないので、個人的には処理内容が明示的であって欲しいのです。

Clojure CLI の問題点

上記の理由(と clojure.org 公式のツールということ)から、個人的に作っている OSS では Leiningen から Clojure CLI へ移行しているのですが、Clojure CLI にも勿論問題はあります。

ビルド/リリース手順が統一されていない問題

Clojure CLI は現状、依存関係を解決することが主な目的のツールになっていて、ビルドやリリースに関する機能は Clojure CLI 内では提供されていません。
つまり Clojure CLI 単体では統一的なビルド方法は提供されておらず、そこはユーザー任せになっている状況です。

ビルドを実現するためのライブラリはいくつか出ていますが、今使うとしたら公式から出ている tools.build になると思います。
しかしこのライブラリはビルドに特化した(Clojureらしい)ライブラリなのでどうやってビルドするかはコードで指定する必要があり(導入ガイド)、ビルドしたものをどうデプロイするかはユーザー任せのままです。

Clojure CLI に移行しようとして特に困る(と思っている)のはバージョン番号の管理で、これも特に決まった方法がなく Leiningen であったようなパッチバージョンをインクリメントする仕組みも当然ないので、何かしらの方法で自分で管理する必要があります。

そういった背景から各開発者はそれぞれ自分が良いと思う方法でビルド、デプロイ、そしてリリースをしている状況と認識しています。(e.g. weavejester/build, seancorfield/build-clj など)
それ自体は各開発者の自由なので特別問題と思ってはいないのですが、個人的に Clojure CLI を使った場合のビルド、デプロイ、リリースの手順で満足できる方法が確立していなかったがために、自分で管理しているOSSの中でも手順が異なったりする状況に陥り困ってしまっていました。

build.edn

Clojure CLI での問題点を解決するためにいろいろと手順を模索していたのですが、最近ようやく一旦は満足できるものが決まりました。
一部のライブラリで試験導入して問題なさそうだったので、他ライブラリでも手順を統一するためにライブラリ化したものが以下になります。

https://github.com/liquidz/build.edn

問題をどう解決しているか

Clojure CLI で問題と思っていた点については以下のようにして build.edn で解決を図っています。

バージョン番号の管理

バージョン番号については tools.build で提案されている MAJOR.MINOR.COMMIT をそのまま採用しています。(とはいえ必ずこの形式でないといけないわけではなく変数を使って例えば CalVerのような形式も可能です)
理由としては公式のライブラリも含めて Clojure界隈ではこの形式のバージョンが一般的になりつつあることと、また元々の SemVer で PATCH だった箇所が COMMIT (これまでのコミット数) になることでリリース時のインクリメントが不要になることから採用しています。

ビルド/デプロイ手順の統一

build.edn ではビルド/デプロイに必要な情報をまとめるための build.edn というその名の通りのファイルを用意してもらうことが基本になります。(例外は後述します)
このファイルではライブラリ名やバージョン番号(主に MAJOR.MINOR まで)を最低限管理して、このファイルさえ用意すれば jar, uberjar, install, deoploy などのコマンドが利用できるようになります。
これによりビルドやデプロイのためのコードを都度書く必要がなくなり、build.edn を使っている限り手順を統一化することができます。

明示的なリリース手順

build.edn では Leiningen における lein release にあたる機能はあえて提供していません。
リリースに必要な機能はコマンド(関数)として個別に提供してあるので、それらを GitHub Actions などで組み合わせて使ってもらうことを期待しています。
そうすることでリリース手順は GitHub Actions のワークフローとして明文化され、リリース時に何が起きるのかわからないという状況を回避することができます。

しかしそうなると今度はリリース手順が統一されなくなるように思えてしまいますが、その点は現時点では特に問題と感じていません。
ライブラリのリリース時にやることは主に以下3つくらいかと思います。(主観です)

  1. clojars.org へのデプロイ
  2. GitHub 上での Tag/Release の作成
  3. README などの更新 (e.g. 導入手順にかかれているバージョン番号の更新)

順番の前後はあるかもしれませんが大きく違ってくることはないはずです。
それよりもリリース手順が暗黙的であることの問題の方が大きい認識なので、十分許容範囲内だと思っています。

簡単な使い方

では簡単に build.edn の使い方を説明したいと思います。(とは言っても GitHub 上の README に書かれていることと同じです)
なお以下の手順は何かしらのライブラリのコードがある前提での説明になるのでご承知おきください。

build.edn ファイルの用意

何よりもまず最初に build.edn ファイルを用意しましょう。
必要最低限なのは :lib:version の指定のみです。今回バージョンは MAJOR.MINOR.COMMIT の形式にしたいので {{git/commit-count}} という変数を使います。

build.edn
{:lib com.github.YOUR-ACCOUNT/AWESOME-LIB
 :version "0.1.{{git/commit-count}}"}

deps.edn ファイルの編集

次に deps.edn で build.edn への依存関係を追加します。

deps.edn
{:aliases
 {:build
  {:deps {com.github.liquidz/build.edn {:git/tag "0.2.63" :git/sha "f4e571d"}}
   :ns-default build-edn.main}}}

build エイリアスを用意して、そこにこの記事を書いている時点で最新のバージョンを指定します。

ns-defaultbuild-edn.main を指定することで、ビルド/デプロイ時に必要なコードには build.edn ファイルをベースにした処理が使われ、自分でコードを用意する必要がなくなります。

あとは使うだけ

準備はもうおしまいです。
おもむろに clojure -T:build jar を実行してみましょう。 target ディレクトリ配下に「ライブラリ名.jar」が生成されているはずです。
他にも以下のようなコマンドが利用できます。

コマンド 説明
clojure -T:build uberjar スタンドアロンな JAR を生成
clojure -T:build install ライブラリをローカルの Maven リポジトリ(~/.m2 配下)にインストール
clojure -T:build deploy clojars.org にデプロイ(現状 CLOJARS_USERNAMECLOJARS_PASSWORD 環境変数が必要)
clojure -T:build lint build.edn ファイルのフォーマットチェック

ね、簡単でしょう?

build.edn ファイルを使わない利用方法

上記の例では build.edn ファイルを用意しましたが、build.edn が提供する関数を直接使う方法もあります。
deps.edn ファイルにて ns-default 指定した build-edn.mainbuild-edn.core の単なるラッパーです。そのため build-edn.core を直接使う場合だと build.edn ファイルを用意する必要はなくなります。

ではどういう時に build.edn ファイルを使わない方が良いのかというと、ビルド/デプロイ時に build-edn.main にて提供している処理とは別に独自の処理を加えたい、もしくは細かくパラメータを制御したいといった場合です。

具体例として liquidz/antq の build.clj を見てみましょう。

(ns build
  (:require
   [build-edn.core :as build-edn]))

(def ^:private config
  {:lib 'com.github.liquidz/antq
   :version "1.6.{{commit-count}}"
   :main 'antq.core})

まず build.edn ファイルにあたる情報は build.clj 内の config として定義しています。

(defn uberjar
  [m]
  (-> (merge config m)
      (assoc
       ;; NOTE: To include org.slf4j/slf4j-nop
       :aliases [:nop]
       ;; NOTE: does not contain src/leiningen
       :src-dirs ["src/antq"])
      (build-edn/uberjar)))

そして clojure -T:build uberjar で実行される uberjar 関数は自分で定義する必要があります。
build-edn.core で提供している uberjar 関数を使うと面倒なことなくスタンドアロンなJARを生成できますが、
ここではビルド時に nop エイリアスを含めることと、ビルド対象のソースパスを制限するというパラメータの微調整をしています。
これらは build.edn ファイルだけではできないものです。

(defn deploy
  [m]
  (let [config (merge config m)]
    (build-edn/deploy (assoc config :lib 'antq/antq))
    (build-edn/deploy config)))

同様に clojure -T:build deploy で実行される関数も定義が必要です。
deploy では元々 config で指定してあったライブラリ名とは別に antq/antq という名前でもデプロイするようにしています。

clojars.org では 2021 年からグループ名の検証が必要になりました。それに伴い liquidz/antq では元々 antq/antq という名前でデプロイしたものを com.github.liquidz/antq という名前でデプロイするよう変更しました。
しかし名前を変更せずに使ってしまっている人はどうしてもいて、その人たちをカバーするために現状では antq/antq にも最新バージョンをデプロイしています。 (グループ名の検証が必須になる前から使われていたグループ名は検証の対象外)

その別名でのデプロイを自動化しているのがこちらです。
(ちなみに clojure -T:build deploy :lib antq/antq のようなコマンドライン引数でのパラメータの上書きでも可能といえば可能です)

このように build.edn ファイルだけでは実現できない細かい調整に関しても、ちょっとしたコードを書くだけで実現できるのが build.edn の強みの1つでもあります。

リリースに併せたドキュメント更新

ここまではビルド/デプロイの注目してきましたが、ここで目線を変えてドキュメントについて考えてみましょう。

大抵のライブラリは README なりなんなりに導入手順をまとめているかと思います。
それらには最新のバージョン番号が記載されていることも少なくないはずです。
そしてそれらはドキュメントであるが故に更新が漏れてしまって、後から慌てて手動で更新したりすることはよくあるケースです。

build.edn ではそういったものをリリース時に一緒に更新できる機能も提供しています。

単なるドキュメント更新なので厳密にはビルド/リリースとは別作業です。
なので他ライブラリとして切り出す方が綺麗なやり方かもしれませんが、個人的にはリリースとドキュメント更新は不可分で、かつ MAJOR.MINOR.COMMIT のようにコミット数がバージョン番号に入る場合はリリースと同時に更新しないとコミット数がずれてしまい不都合しかありません。
そのため機能の一部として組み込んでいます。

例として build.edn 自身の CHANGELOG.adoc の更新設定を見てみましょう。

build.edn
{:documents [{:file "CHANGELOG.adoc"
              :match "Unreleased"
              :action :append-after
              :text "\n== {{version}} ({{now/yyyy}}-{{now/mm}}-{{now/dd}})"}]}

見た感じでわかる方もいるかもしれませんが(できればわかって欲しいなという気持ちで各キーは命名してます)、以下のような変更を定義しています。

  • :file - "CHANGELOG.adoc" というファイルにて
  • :match - "Unreleased" という文字列にマッチする行の
  • :action - 後ろに :text を追加する

定義が済んだら clojure -T:build update-documents コマンドでドキュメントが更新されます。
この関数は更新するのみで git commit ならびに git push といった処理は当然行いません。
なので定義が期待通りに動くかどうかは実際にコマンドを実行することで安全に確認できます。

なお :text で利用できる変数については build.edn の README を参照してください。

build.edn ファイルのフォーマットチェック

build.edn ファイルはこれまでに説明してきた通り単なる EDN 形式のファイルでありデータそのものです。
なので基本的にはエディタ上での補完などは効かずタイプミスなども当然しやすいです。

build.edn では build.edn ファイルで扱う設定ファイルの形式を metosin/malli のスキーマとして定義することで、ミスした場合でもどこがどのようにミスしているのかわかりやすく表示することを可能としています。

例えば以下のような build.edn ファイルがあるとしましょう。
先に説明しておくと :lib, :version は期待しない形式になっており、 :documents に関しては :action として :create を指定したいところを :replace にしてしまっています ( :replace だと :match が必須ですが、:create だと :match指定しなくて良いようになっています。 )

build.edn
{:lib foo
 :version 1.2
 :documents [{:file "bar.txt"
              :action :replace
              :text "baz"}]}

チェック方法は前段で軽く触れていましたが以下のコマンドになり、結果もなぜエラーなのかは英文になっているのでわかりやすくなっているかと思います。

$ clojure -T:build lint
* :documents
  * :match
    * missing required key
  * :action
    * should be :create
* :lib
  * should be a qualified symbol
* :version
  * should be a string

$ echo $?
0

これの利点としては例えばデプロイ処理の直前にチェック処理をかませておくことで、設定ファイルのミスよる意図しない状態でのデプロイを未然に防ぐことができることにあります。
https://github.com/liquidz/build.edn/blob/71c1add185f70ac1ef3c91929a5dec64d4c136ef/.github/workflows/release.yml#L31-L32

Clojure CLI を使ったビルドを楽にするライブラリは build.edn の他にもありますが、フォーマットチェックに対応しているものは現状見かけないので build.edn の大きな強みの1つであると思います。

GitHub Actions との連携

少し長くなってしまいましたが、ここまででビルド/デプロイまたドキュメント更新などを build.edn でどのように楽してできるかがわかっていただけたかと思います。
それらを GitHub Actions を使ってより楽に実行するための例を最後に紹介したいと思います。

大事なのは build.edn ファイルでの以下の指定です。

build.edn
{:github-actions? true}

この指定によりビルド/デプロイ時にビルドした/デプロイした際のバージョン番号などの情報が GitHub Actions の Output として出力されるようになり、ワークフロー内でそれらの出力を参照することができるようになります。
これにより GitHub 上で Tag/Release を作成するような他のアクションとの連携が容易にできるようになります。

具体例として liquidz/merr のリリースワークフローを見てみましょう。

release.yml
name: Tag and Release
on: workflow_dispatch

jobs:
  tag-and-release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          # バージョン番号にコミット数を含めるので全履歴を取得
          fetch-depth: 0

      # ...中略...

      # デプロイ前に build.edn ファイルのチェックを行っておくと安心
      - name: lint build.edn
        run: clojure -T:build lint

      # build.edn を使ってデプロイ
      # 次のステップで Output を参照するために ID を付けておく
      - name: deploy to clojars
        id: deploy
        run: clojure -T:build deploy
        env:
          CLOJARS_PASSWORD: ${{secrets.CLOJARS_PASSWORD}}
          CLOJARS_USERNAME: ${{secrets.CLOJARS_USERNAME}}

      # Tag と Release の作成
      - uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          # タグ名、リリース名はデプロイ時のバージョン番号を利用
          tag_name: ${{ steps.deploy.outputs.version }}
          release_name: ${{ steps.deploy.outputs.version }}
          body: released
          draft: false
          prerelease: false

IDからOutputに出力されたバージョン番号を参照して Tag/Release を作成しているだけなので難しいことはないかと思います。
(なお actions/create-release はアーカイブ済みのアクションではありますが、代わりのアクションが選びきれていないことと、とりあえず動いているのでまだ使っています)

ワークフローは on: workflow_dispatch の指定で GitHub Actions 上から実行できるようにしているので、手元にデプロイする環境がない場合でも例えば Pull Request をブラウザ上でマージして、ブラウザ上からリリースということが可能になり、リリース作業の属人化も解消できます。

build.edn での各関数でどのような Output が出力されるのかは build.edn の README を参照してください。

最後に

build.edn の README にも書いていますが、個人で開発しているいくつかのライブラリではすでに build.edn でビルド/リリースを自動化しています。
https://github.com/liquidz/build.edn#projects-using-buildedn
今まではリリース作業が若干手間で作業するのに気合が必要だったのですが、今では GitHub Actions からボタンを押すだけなのでだいぶ気楽にできるようになりました。

趣味のOSSですが面倒なことが増えて趣味として楽しめなくなってはつまらないので、そういった意味でもやりたいことに集中できるような環境整備は大切ではないかなと思います。

Discussion