blog.syfm

徒然なるままに考えていることなどを書いていくブログ

自作 CLI ツールのワークフローとそれを支える技術

去年の秋頃から作っていた gRPC クライアントツールである Evans が今年に入り、徐々にいろんな方々に使われ始めました。

github.com

Issue や Pull Request を頂けてとてもありがたい反面、それらを反映したバグフィックスや機能追加がツールに取り込まれても、ユーザは能動的に GitHub Releases をチェックしなければそのアップデートを知ることができません。
これは開発者的にもユーザ的にも損失です。そもそもユーザがアップデートを利用できるかどうか確認する作業や、アップデート方法に関する知識 (どういった方法でインストールしたかどうかなど) を知る必要も本来は必要ないはずです。 この問題をどうにかしたいと思い、最近は CLI ツールの配布、自動アップデート機構に関するワークフローを整備していました。
そのために使用したツールや開発したライブラリ、その経緯を書いていきたいと思います。

5月29日 追記

この記事の内容で LT を行いました。以下はそのスライドです。

speakerdeck.com

バージョニング

Go は、以下のように標準でリモートリポジトリからバイナリを取得 / 更新することができます。

$ go get -u github.com/ktr0731/evans

しかし、go get ではデフォルトブランチの HEAD、もしくは go1.x.x ブランチ (ローカルの Go のバージョンに依存) のソースが利用されるため、実質バージョンを元にした取得ができないといった問題があります。
ツールの継続的なリリース、自動アップデート機構のためには、これは大きな問題であるため、まずはセマンティックバージョニングによるリリースを前提としました。

最近 Russ Cox が提案した vgo ではライブラリのバージョニングとして、セマンティックバージョニング (Semantic Import Versioning)を前提としているため、今後はセマンティックバージョニングが Go プロジェクトにおけるバージョニングのデファクトスタンダードになるのではないかと思っています。

ktr0731/go-semver

ツールの配布を自動化する上で、バージョニングも自動化したいと思うのが普通です。
そのため、セマンティックバージョニング用のライブラリ・ツールをつくりました。

github.com

go-semver を使い、ツールのソース内に以下のような変数を用意します。

var currentVersion = semver.MustParse("0.1.0")

currentVersion は MajorMinorPatch といった各値を表すフィールドや、Equal といったバージョンの比較のためのメソッドなどが定義されています。

fmt.Printf("current: %s\n", currentVersion) // current: 0.1.0
fmt.Println(currentVersion.LessThan(semver.MustParse("0.1.1"))) // true

詳しくは GoDoc をご覧ください。

go-semver が用意しているツール、cmd/bump により、対象のソースを AST へ変換して解析することで安全にバージョニングを行うことができます。

# main.go から semver.Parse or semver.MustParse を探し、そのバージョンのパッチを上げる
$ bump -w patch main.go

AST を用いたバージョニングは motemen/gobump を参考にしています。

github.com

各プラットフォームへのビルド、GitHub Releases へのリリース

せっかく Go で書いているので、色々なプラットフォーム向けにバイナリを配布したいものです。
Go は簡単に元々、各プラットフォーム向けにビルドすることができますが、mitchellh/gox を使うとより簡単に、並列にビルドすることができます。

github.com

作成したバイナリ群は、GitHub Releases へ並行にアップロードしています。
これには tcnksm/ghr を利用しています。

github.com

Evans では、これら二つの作業は、CI 上で実行しています。
ブランチが master へマージされたときに、ツールのバージョンが更新されているかどうかチェックし、更新されていればそのバージョンで Git のタグを切り、ビルド・リリースを行います。

自動アップデート

自動アップデートは今回、最も重要な機構です。

github.com

ライブラリを設計するにあたって、以下のことを重視しました。

  • アップデートに関する最低限の手段のみを提供し、どう使うかはライブラリの使用者に任せる
  • 複数の手段でアップデートできるようにする

go-updater には Means という概念があります。
これは、例えば Homebrew でのインストール、GitHub Releases からバイナリを取得してインストールといったそれぞれのインストール手段のことです。

go-updater を利用する際に使用する Means を決定します。もっともシンプルな例は以下のようになります。
標準で Homebrew、GitHub Releases の Means パッケージを用意しています。

means, _ := updater.NewMeans(github.GitHubReleaseMeans("ktr0731", "evans", github.TarGZIPDecompresser))

これを利用し、アップデートを行うことができます。

u := updater.New(currentVersion, means)
updatable, newVersion, _ := u.Updatable(context.Background())
if updatable {
    _ = u.Update(context.Background())
    fmt.Printf("update completed: %s → %s\n", currentVersion, newVersion)
}

updater の UpdateIf の値を変更することでアップデートが利用可能かどうかのレベルを変更することができます。

// minor 以上のアップデートが見つかった場合、Updatable は true を返す
u.UpdateIf = updater.FoundMinor

updater.SelectAvailableMeansFrom を使うことで、複数の Means を元に、どの Means によりインストールされたかを判別することができます。
Evans であれば GitHub Releases、Homebrew でのインストールをサポートしているので以下のように記述します。

means, err = updater.SelectAvailableMeansFrom(
    context.Background(),
    brew.HomebrewMeans("ktr0731/evans", "evans"),
    github.GitHubReleaseMeans("ktr0731", "evans"),
)

対象の Means によってインストールされているかどうかのロジックは、各 Means の実装に依存します。
例えば、GitHub Releases であれば ldflags により isGitHubReleasedBinarytrue となっているかどうか、Homebrew であれば brew list <name> コマンドを実行してパスが見つかるかどうかでチェックしています。

注意すべきなのは、Means によっては、インストールされているかどうかのチェックに時間がかかる可能性があることです。
実際、 Homebrew コマンドの実行に 0.3 ~ 0.5 秒ほどかかるので、それが許容できない場合はツール側で工夫する必要があります。
Evans では起動時に非同期でアップデートがあるかをチェックし、見つかった場合はその情報をキャッシュし、次回起動時にプロンプトでアップデートをするかをチェックしています。

まとめ

ktr0731/evans では、ktr0731/go-semver によるセマンティックバージョニングの導入、michellh/gox と tcnksm/ghr によるツールの高速なビルド・リリース、ktr0731/go-updater によるツールの自動アップデート機構の導入を行いました。
これらのツールやライブラリを利用することで、配布から CLI ツールのアップデートまでをほぼ自動的に行えるようになりました。
特に、自動アップデートはユーザの手間がほとんどかからないため、非常に便利です。