Go で --version の出力を実装する

Go でコマンドラインツールを実装した時に

$ some-tool -version

のようにバージョンを出力するフラグを実装することが多いと思います.本記事はこれをどう実装するかのメモです.

手動でバージョン情報を管理

素朴にはバージョン情報を定数で持って手動で管理する方法があります.

package main

import (
    "flag"
    "fmt"
)

const version = "1.2.3"

func main() {
    var v bool
    flag.BoolVar(&v, "version", false, "Show version")
    flag.Parse()

    if v {
        fmt.Println(version)
    }
}

新しいリリースを行うときは version 定数の値を手で書き換えてコミットしてからリリース用のタグを打つという方法でやっていたのですが,間をおいてリリースする時に修正忘れが怖いのとリリースの手間を極力減らしたいという事情があります.

定数の書き換え処理を CI のリリースジョブで自動でやれば良いように見えますが,それはうまくいきません.リリースジョブはリポジトリにタグを打った時に動きますが,上記バージョン文字列定数はタグを打つ前に書き換える必要があるためです.でないと go install してビルドしたバイナリのバージョン情報が更新前のものになってしまいます.リリースジョブが走るトリガーを別のものにし,リリースジョブの中でタグを打つようなことはできますが,-version の実装のためだけにリリースフローを曲げるのは微妙です.

今までは『バージョン文字列定数を更新してコミットしてからタグを打ち git push する』という一連の流れをスクリプトにしてリリース時にはそれを使うようにし,リリースジョブ側ではそのスクリプトによって生成されたコミットにタグが打たれていることをコミットメッセージから validation するということをやっていました.それで問題なく動いてはいたのですが,やはり普通にタグを打つだけでリリースしたいのと,リリースジョブの validation 部分をメンテするのを避けたいと思うようになりました.

runtime/debug.BuildInfo からバージョンを取得する

ツイッター@shibu_jp さんに下記の記事を教えてもらいました

blog.lufia.org

Go ではビルド時にバイナリにモジュールのバージョン情報が埋め込まれており,runtime/debug.BuildInfo としてそれを実行時に取得できます.これを利用するとバージョン情報取得は下記のように実装できます.

package main

import (
    "flag"
    "fmt"
    "runtime/debug"
)

func getVersion() string {
    i, ok := debug.ReadBuildInfo()
    if !ok {
        return "unknown"
    }
    return i.Main.Version
}

func main() {
    var v bool
    flag.BoolVar(&v, "version", false, "Show version")
    flag.Parse()

    if v {
        fmt.Println(getVersion())
    }
}

これにより,go install 時にビルドされたバージョンを -version で表示でき,バージョン文字列を手動で管理する必要はなくなりました.モジュールが有効になっていないとバージョンは unknown になりますが,go install@latest@v1.0.0 のようなサフィックスをつけると自動でモジュールを有効にしてくれるので問題ないと思います.

ビルド済みバイナリにバージョン情報を埋める

上記の BuildInfo を使う方法では go build でビルドした時のバージョンは (devel) になるので,CI のリリースジョブでビルドしたバイナリを GitHub のリリースページにアップロードするとバージョンが取得できません.

なので,リリースジョブでビルドする時にバージョン情報を埋めて代わりにそちらを使うようにします.

package main

import (
    "flag"
    "fmt"
    "runtime/debug"
)

// バージョン情報を埋め込める用に変数を用意しておく
// const ではなく var なことに注意
var version = ""

func getVersion() string {
    if version != "" {
        // バージョン情報が埋め込まれている時
        return version
    }
    i, ok := debug.ReadBuildInfo()
    if !ok {
        return "unknown"
    }
    return i.Main.Version
}

func main() {
    var v bool
    flag.BoolVar(&v, "version", false, "Show version")
    flag.Parse()

    if v {
        fmt.Println(getVersion())
    }
}

これで

go build -ldflags '-s -w -X main.version=1.0.0'

とすると version 変数に "1.0.0" という文字列をビルド時にリンクします.

CI でのリリース作成には GoReleaser を使っているので,.goreleaser.yaml

builds:
  - main: ./cmd/some-tool
    ldflags: -s -w -X main.version={{.Version}}

のようにするとリリースタグの値をビルド済み実行バイナリに埋め込むことができます.これで go install とリリースページで公開するビルド済みバイナリの両方でバージョン情報が表示できるようになりました.

追加情報を出す

バージョンだけでなく『どうやってインストールしたか』も出力しておくと issue の切り分けの場合などに便利なことがあります.これも version と同じ要領で

package main

import (
    "flag"
    "fmt"
    "runtime/debug"
)

var (
    version = ""
    installFrom = "built from source"
)

func getVersion() string {
    if version != "" {
        return version
    }
    i, ok := debug.ReadBuildInfo()
    if !ok {
        return "unknown"
    }
    return i.Main.Version
}

func main() {
    var v bool
    flag.BoolVar(&v, "version", false, "Show version")
    flag.Parse()

    if v {
        fmt.Printf("%s\n%s\n", getVersion(), installFrom)
    }
}

のようにすると

$ go build ./cmd/some-tool
$ ./some-tool -version
(devel)
built from source

のように2行目にどうインストールされたかが表示されます.-ldflags を使って,.goreleaser.yaml に下記のように設定することで,そのバイナリがリリースページからダウンロードされたものか,自前でビルドしたものかを表示できます.

builds:
  - main: ./cmd/some-tool
    ldflags: -s -w -X main.version={{.Version}} -X "main.installFrom=downloaded from release page"

細かい注意点として,リンクする文字列に空白を含む場合は -X の引数全体をダブルクォートで囲む必要があります(シングルクォートではダメで,Bash のように main.installFrom="..." とするのもダメです).

また,お好みに合わせてさらに追加の情報を含めておくのも良さそうです.go versionclang --version を見ると,ターゲットの情報を出しておくと issue 調査時などに便利そうです.