Goのコマンドラインツールをセルフアップデートするためのライブラリつくった

突然ですが,Goでコマンドラインツールを書く時,ツールの配布はどうしているでしょうか?

  1. go get でインストールできるようにする
  2. GitHub 上にリリースして,ダウンロードして使ってもらう
  3. システムのパッケージマネージャ(Homebrew など)を使う

などがメジャーかと思います.

ただ,これらの選択肢はどれも問題があります.

go get -u は常にリポジトリの HEAD をインストールしてしまうため,ユーザがインストールしたタイミングに依存したバイナリができてしまいます.これを避けるには dev ブランチを切ってそっちで開発する必要がありますが,Go のツールはそうなってないものが多く,どのタイミングで go get -u したら良いかユーザには容易に判断できません.また,仮に dev ブランチ運用したとしても依存ライブラリの更新のタイミングは制御できず,vendoring などを使う必要があります.

GitHub 上でのリリースは各バージョンごとにテストが通ったものをリリースノート付きでリリースできますが,ユーザが自発的にアップデートを確認し,手でバイナリをダウンロードしてくる必要があります.

システムのパッケージマネージャは上記の問題を解決してくれますが,プラットフォームに依存し,アップデート時に余計な手間も出ます(例えば Homebrew なら homebrew-core へのプルリクなど).

Go は全てを静的リンクした1つのバイナリを簡単にクロスコンパイルできる利点を持っているため,これをうまく使えないかと思い,go-github-selfupdateをつくりました.この記事ではその紹介をします.

GoDoc Badge TravisCI Status AppVeyor Status Codecov Status

github.com

go-github-selfupdate とは

go-github-selfupdate とは,GitHub 上にある最新のリリースを検知して,自分自身のバージョンよりも高ければ自動で自分自身のバイナリを更新する(セルフアップデート)機能を提供するためのライブラリです.

以下のような流れでセルフアップデートを行います.

  1. GitHubReleases API を使い,プレリリースでない最新のリリースを Git のタグ名から判定してリリース物の URL を取得
  2. リリース物をダウンロードし,zip や gzip,tar.gz などの圧縮形式・アーカイブ形式をファイル名から判定し,自動で展開・解凍して目的のバイナリを引っ張ってくる
  3. inconshreveable/go-update を使って自身の実行ファイルをダウンロードした新しいものに差し替える

これによって,GitHub で公開している最新の安定版バイナリにそのコマンドから自動検知・更新することができます.

Travis CI や AppVeyor を使って Linux, Windows, macOS でテストし,正常系だけでなく異常系もテストしています(カバレッジ90%以上). また,inconshreveable/go-update を使っているので,実行ファイルの差し替えに失敗した場合には自動で元の実行ファイルにロールバックしてくれます. zip,gzip,tar.gz の解凍・展開には Go の標準ライブラリを使っています.

使い方

お試し CLI

テストにも使っているお試しの CLI があるので,下記のように試せます.これはリリースページのバイナリにセルフアップデートする(v1.2.3 → v1.2.4)例です.

$ go get -u github.com/rhysd/go-github-selfupdate/tree/master/cmd/selfupdate-example

$ # バージョンを確認
$ selfupdate-example -version
v1.2.3

$ # セルフアップデートを実行
$ selfupdate-example -selfupdate

$ # アップデートされている
$ selfupdate-example -version
v1.2.4

$ # もちろん再度実行してもすでに最新なので何も起きない
$ selfupdate-example -selfupdate
$ selfupdate-example -version
v1.2.4

コード例

セルフアップデートの各段階の処理を関数として公開していますが,一番簡単なのは selfupdate.UpdateSelf() を使うことです.

import (
    "log"
    "github.com/blang/semver"
    "github.com/rhysd/go-github-selfupdate/selfupdate"
)

const version = "1.2.3"

func doSelfUpdate() {
    v := semver.MustParse(version)
    latest, err := selfupdate.UpdateSelf(v, "owner/repo")
    if err != nil {
        log.Println("Binary update failed:", err)
        return
    }
    if latest.Version.Equals(v) {
        // latest version is the same as current version. It means current binary is up to date.
        log.Println("Current binary is the latest version", version)
    } else {
        log.Println("Successfully updated to version", latest.Version)
        log.Println("Release note:\n", latest.ReleaseNotes)
    }
}

(後ほど説明しますが)このライブラリではバージョンの大小比較のためにセマンティックバージョニングを前提としています. UpdateSelf() はツールの現在のバージョンと,ツールがホストされているリポジトリの情報(owner/repo)を受け取り,更新した結果のリリースを表す構造体(selfupdate.Release)とエラーを返します.

エラーはダウンロードしてきたファイルが破損していた場合など,セルフアップデートが実行できなかった場合に返されます.

現在のツールのバージョンが最新だった場合はエラーではないので,latestVersionフィールドに現在のバージョンと同じ値が返されます. なので,アップデート後のバージョンと今のバージョンを比較する(latest.Version.Equals(v))ことで実行ファイルの差し替えが行われたかを知ることができます.latest にはバージョンの他にリリースノートやリリースページの URL なども含まれるため,必要な場合は適宜利用できます.

この(ユーザへの出力を含めて)14行だけで実行ファイルをアップデートする仕組みを組み込むことができました.

次にもう少し複雑な例を出してみます. バージョンアップデート前にユーザに「このバージョンにアップデートするけど良い?」と確認を取りたいとします.

import (
    "bufio"
    "github.com/blang/semver"
    "github.com/rhysd/go-github-selfupdate/selfupdate"
    "log"
    "os"
)

const version = "1.2.3"

func confirmAndSelfUpdate() {
    latest, found, err := selfupdate.DetectLatest("owner/repo")
    if err != nil {
        log.Println("Error occurred while detecting version:", err)
        return
    }

    v := semver.MustParse(version)
    if !found || latest.Version.Equals(v) {
        log.Println("Current version is the latest")
        return
    }

    fmt.Print("Do you want to update to", latest.Version, "? (y/n): ")
    input, err := bufio.NewReader(os.Stdin).ReadString('\n')
    if err != nil || (input != "y\n" && input != "n\n") {
        log.Println("Invalid input")
        return
    }
    if input == "n\n" {
        return
    }

    if err := selfupdate.UpdateTo(latest.AssetURL, os.Args[0]); err != nil {
        log.Println("Error occurred while updating binary:", err)
        return
    }
    log.Println("Successfully updated to version", latest.Version)
}

少々長いですが,全体の流れとしては,UpdateSelf() の中でやっている処理をバラして,

  1. selfupdate.DetectLatest()GitHub 上での最新のリリースを検知してアップデートの必要があるかチェック
  2. ユーザに y/n で確認するプロンプトを出す
  3. selfupdate.UpdateTo() で実行ファイルをダウンロード,解凍・展開,置き換えする

というものです.特に難しい部分は無いはずです.リリースが無い場合は異常系では無いので,selfupdate.DetectLatest() はリリースがあったかどうかとエラーを別に返すようにしています.

リリース物の名前付けルール

どのバイナリがどの環境向けのものかを判別するために,下記のような名前付けでリリース物を配布しているという前提で実装しています.

{cmd}_{goos}_{goarch}{.ext}

{cmd}コマンドラインツールの名前です.この中に _ を含んでいても構いません. {goos} は OS の種類(runtime.GOOS),{goarch}アーキタイプruntime.GOARCH)です.{.ext} はファイルの拡張子で,何もなし(圧縮なし),.zip.gz.tar.gz のいずれかで,対応する圧縮・アーカイブが施されているものとします.

また,セパレータには _ の代わりに - が使え,Windows 限定で .exe.zip のように .exe が付いても大丈夫なようになっています(実行ファイルをそのまま圧縮しても良いように).

なので,foo-bar をコマンド名とすると下記のようなファイルはリリース物として認識されます.

  • foo-bar_linux_amd64
  • foo-bar_linux_amd64.zip
  • foo-bar_linux_amd64.tar.gz
  • foo-bar_linux_amd64.gz
  • foo-bar-linux-amd64.tar.gz

リリース名(Git のタグ名)の名前付けルール

go-github-selfupdateは Git のタグを見てそのリリースのバージョンを判定します(リリースタイトルではないので注意).

タグ名は正規表現で言うと \d+\.\d+\.\d+ を含んでいる必要があり,それより手前の文字列は削除されます.なので,v1.2.3ver1.2.3release-1.2.3などは全てバージョン 1.2.3 として認識されます.

この命名規則に合致しないもの(例えば nightly など)や,プレリリースに指定されているものなどは単に無視されます.

リリース物の構造

まとめると下記のような構造でリリースすれば正しくgo-github-selfupdateで認識できます.

コマンド名を foo-bar とすると,一例としてリリースは下記のようになります.

  • v1.2.4
    • foo-bar_linux_amd64.tar.gz
      • foo-bar
    • foo-bar_darwin_amd64.tar.gz
      • foo-bar
    • foo-bar_windows_amd64.exe.zip
      • foo-bar.exe
    • ...(他のプラットフォーム向けのバイナリ)
  • v1.2.3
    • foo-bar_linux_amd64.tar.gz
      • foo-bar
    • foo-bar_darwin_amd64.tar.gz
      • foo-bar
    • foo-bar_windows_amd64.exe.zip
      • foo-bar.exe
    • ...(他のプラットフォーム向けのバイナリ)

難しく考えなくても,gox でクロスビルドしてアーカイブし,ghrでリリースしたりしていれば大体この構造になっているのではないかと思います.

デバッグする

go-github-selfupdate内では(デフォルトで無効な)ログを仕込んでありますので,ツールのデバッグ時には下記のようにしてログを有効にすることで内部でどういう処理が行われているかを標準エラー出力に表示することができます.

selfupdate.EnableLog()

適用例

手前味噌ですが,いくつか自分のツールにはすでに組み込んでみています.

おまけ CLI ツール

go-github-selfupdateを使い,いくつか簡単なコマンドラインツールも作成しました.

  • detect-latest-release: 引数に与えられたリポジトリの最新のリリースを検知するコマンド
  • go-get-release: go get のような Go のコマンドをインストールするツール.go get と違い GitHub の最新のリリース物をダウンロードしてきて $GOPATH/bin に配置する

例えば下記のように ghr の安定版をインストールしたり,

$ go-get-release github.com/tcnksm/ghr
Command was updated to the latest version 0.5.4: /Users/rhysd/.go/bin/ghr

下記のように dot-github の最新バージョン(やリリースノートなど)を知ったりすることができます.

$ detect-latest-release rhysd/dot-github
1.3.0

go-github-selfupdateを下地として使っているので,飽くまで前章で解説した名前付けルールに従っているものでしか使えません(それ以外ではリリースが検知できないと思います).

tj/go-updateとの違い

実はすでに同じ目的のライブラリとして tj/go-update がありますが,下記の点が違います

tj/go-update は

  • Windows をサポートしていない
  • バージョンのプレフィックスは v のみ許可
  • プレリリースかどうかを見ない
  • テストが少ない(正常系の1ケースのみ)
  • GitHub だけでなく Apex というリリース置き場をサポートしている

まとめ

GitHub 上にある最新のリリースを検知して,自分自身のバージョンよりも高ければ自動で自分自身のバイナリを更新する(セルフアップデート)機能を提供するためのライブラリ,go-github-selfupdateを作成しました.これによって,配布者にとってはリリースの配布がより楽になり,ユーザにとってはリリースされた安定版に手軽にアップデートできるという利点があるのではないかと思います.