actionlint v1.4 → v1.6 で実装した新機能の紹介

前回の actionlint の記事 GitHub Actions のワークフローをチェックする actionlint をつくった からちょうど1ヶ月経ち,今日 v1.6.0 をリリースしました.前回時点では v1.4.0 で,そこから v1.4.1, v1.4.2, v1.4.3, v1.5.0, v1.5.1, v1.5.2, v1.5.3, v1.6.0 とリリースを重ねています.git diff によると v1.4.0 〜 v1.6.0 で

155 files changed, 14816 insertions(+), 2981 deletions(-)

のコード変更を行ったようです.

github.com

この記事では大きめの機能追加や改善をいくつか紹介します.

  • 有名アクションの input/output チェック
  • スクリプトインジェクション脆弱性のチェック
  • -format オプションによる柔軟なエラー出力
  • ドキュメントの再構成
  • Windows でカレントディレクトリの実行ファイルが意図せず実行できてしまう脆弱性への対処
  • Playground の機能追加

変更の完全な履歴についてはチェンジログを確認してください.

有名アクションの input/output チェック

アクションには入力と出力があり,action.yml メタデータに定義されています.入力には必須のものと必須でないものがあります.

これらの input/output についての情報は,そのアクションの action.yml をフェッチしないと分かりません.ネットワークリクエストは遅いですし,ネットワークが制限された環境(コンテナ内など)でも使えるようにする必要があるため,フェッチするのは避けたいです.そのため,事前に有名なアクションの action.yml の情報をクロールしてコード生成で持っておくことで,input/output のチェックを実現しています.簡単な調査では,有名なアクションのチェックさえできれば全体アクション実行の9割以上はカバーできそうなので,とりあえずこれでヨシとしています.

on: push

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/cache@v2
        # ERROR: 必須の input "key" がない
        with:
          # ERROR: 定義されていない input
          keys: |
            ${{ hashFiles('**/*.lock') }}
            ${{ hashFiles('**/*.cache') }}
          path: ./packages
      - run: make

Playground

この例は actions/cache の input のチェックをしています.actions/cachekey という必須の input があるのでそれが指定されていないこと,action.yml に定義されていない keys input が指定されていることの2点がエラーとして報告されます.

on: push

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      # ERROR: id: cache のステップはまだ実行されていない
      - run: echo ${{ steps.cache.outputs.cache-hit }}
      - uses: actions/cache@v2
        id: cache
        with:
          key: ${{ hashFiles('**/*.lock') }}
          path: ./packages
      # OK
      - run: echo ${{ steps.cache.outputs.cache-hit }}
      # ERROR: cache_hit という output は存在しない
      - run: echo ${{ steps.cache.outputs.cache_hit }}

Playground

この例は actions/cache の output のチェックをしています.actions/cache はキャッシュがヒットしたかどうかを cache-hit という output にセットします.actionlint では steps.cache.outputs のオブジェクト型に action.yml に基づいてプロパティを定義することで,型チェックで正しい output を使えているかをチェックします.ここでは存在しない cache_hit プロパティにアクセスしようとしてエラーになっています.また,steps.cache.outputs の型は,id: cache のステップ以降でのみ定義されるので,id: cache より前のステップではアクセスできずエラーになります.

action.yml の情報はスクリプトを組んで go generate で更新するようにしていて,毎週 CI で実行され,更新があれば自動で pull request が生成される仕組みになっています.

スクリプトインジェクション脆弱性のチェック

GitHub Actions の ${{ }} は単に文字列として置換されます.例えば

- run: echo '${{ github.event.pull_request.title }}'

という step があると,ジョブ実行時に ${{ }} の中身が評価されて置換され,

- run: echo 'pull request のタイトル'

スクリプトとして実行されます.

ここで悪意のあるユーザが '; malicious_command ... というタイトルで pull request を作成するとどうなるでしょう?

- run: echo ''; malicious_command ...

と置換され,malicious_command が実行できてしまいます.ジョブ実行時には設定されたパーミッションで credential が生成されますし,フックによってはシークレットにアクセスすることもできますので,秘匿情報を盗むことができてしまいます.

これを防ぐためには

- run: echo "$TITLE"
  env:
    TITLE: ${{ github.event.pull_request.title }}

というように環境変数を通してアクセスする回避策があります.

ここでの問題は github.event.pull_request.title が信頼できない入力であるという点です.どの入力が信頼できないかは,GitHub Security Lab のブログ記事に一覧があります.

actionlint では,run: などのスクリプトインジェクションの危険がある箇所で,これらの信頼できない入力が ${{ }} で直に使われていないかをチェックします.

on: pull_request
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Print pull request title
        # ERROR: 信頼できない入力の使用
        run: echo '${{ github.event.pull_request.title }}'
      - uses: actions/stale@v4
        with:
          repo-token: ${{ secrets.TOKEN }}
          # OK: アクションの入力はスクリプトに直接渡されないので使って良い
          stale-pr-message: ${{ github.event.pull_request.title }} was closed
      - uses: actions/github-script@v4
        with:
          # ERROR: この入力はスクリプトで評価される
          script: console.log('${{ github.event.head_commit.author.name }}')

Playground

このチェックは @azu さんに機能リクエストをいただいて実装しました.

-format オプションによる柔軟なエラー出力

Go テンプレート構文を使ってエラーメッセージの出力フォーマットを柔軟に指定できるようにしました.構文を知っている人向けに説明すると,. はエラーオブジェクトのスライスになっていて,各エラーオブジェクトの .Message.Line フィールドなどを利用して出力を整形します.

例えば,

actionlint -format '{{json .}}'

とすると json アクションを通してエラーメッセージの列が JSON 文字列化され

[{"message":"unexpected key \"branch\" for ...

のようにオブジェクトの配列で出力されます.出力を jq などで操作するのに便利です.

また,もう少し複雑な例として,

actionlint -format '{{range $err := .}}### Error at line {{$err.Line}}, col {{$err.Column}} of `{{$err.Filepath}}`\n\n{{$err.Message}}\n\n```\n{{$err.Snippet}}\n```\n\n{{end}}'

とすると Markdown 形式でエラーを出力でき

f:id:rhysd:20210811220522p:plain

のようにエラーが出力されます.

ドキュメントでは ::error コマンドを使ってエラーアノテーションを付ける例やフォーマットの使い方についても説明していますので,詳しくはそちらを参照してください.

この機能は @ybiquitous さんに機能リクエストをいただいて実装しました.

ドキュメントの再構成

以前はすべてのドキュメントをリポジトリ直下の README.md にすべて突っ込んでいたのですが,チェック項目やセクション数が増えてきて非常に長くなってしまっていました.長い README は読まれないので,ドキュメントの内容ごとにファイルを分けて整理しました.

  • README.md: イントロダクション,簡単なチュートリアル,他のドキュメントへのリンク,ライセンスについてのみ記載しています
  • docs/checks.md: actionlint が行うすべてのチェック項目のリストです.ワークフロー例とエラー出力例,Playground へのリンクをチェック項目ごとに記載しています
  • docs/install.md: インストール方法です.ビルド済みバイナリ,Homebrew,ダウンロードスクリプトgo install によるソースからのビルドのそれぞれについて説明しています
  • docs/usage.md: actionlint コマンドの使い方(特定のエラーを無視する方法,-format オプションの使い方,exit status の意味など),Playground の使い方について説明しています.また,reviewdog, Problem Matchers, super-linter との連携についても解説しています
  • docs/config.md: コンフィグファイルの使い方について説明しています
  • doc/api.md: actionlint を Go のライブラリとして使う方法について解説しています
  • doc/reference.md: 各種リソースへのリンク集です

Windows でカレントディレクトリの実行ファイルが意図せず実行できてしまう脆弱性への対処

Windows では foo というように絶対パスではないコマンドを実行すると,カレントディレクトリにある foo.exe を実行できてしまうという挙動があります.Go の exec.LookPath("foo") でもカレントディレクトリの foo.exe を返してしまいます.

golang/go#38736

actionlint でも shellcheck コマンドや pyflakes コマンドのパスを取得するのに exec.LookPath を使っていたので,カレントディレクトリに shellcheck.exepyflakes.exe があるとそちらを意図せず実行してしまう問題がありました.現在は execabs を使うことでこの問題を修正済みです.

Playground の機能追加

https://rhysd.github.io/actionlint/

actionlint を Wasm にビルドすることでブラウザ上でも動くようにした Playground ですが,より手軽に使えて共有できるようにいくつか改善を行いました.

f:id:rhysd:20210812212418p:plain

  • 'Permalink' ボタンを設置しました.これをクリックすると今のコードエディタのソースの状態を URL のハッシュにエンコードすることで永続化します.バグ報告時に Playground で再現させてそのパームリンクを提供してもらえると助かるなと思って実装しています.
  • URL の入力フォームを設置しました.URL を入力してから Check ボタンを押すと,URL 先のファイルをフェッチしてきます.もちろんですが GitHub Pages からフェッチできる URL のみ使えます.
    • https://github.com/owner/repo/tree/branch/...: GitHub 上の特定のファイルをブラウザで表示させた時の URL
    • https://raw.githubusercontent.com/owner/repo/branch/...: GitHub 上でホストされている生のファイルの URL
    • https://gist.github.com/owner/...: Gist 上の特定のファイルをブラウザで表示させた時の URL
    • https://gist.githubusercontent.com/owner/...: Gist 上でホストされている生のファイルの URL

まとめ

前回の記事以降も継続して改善・機能追加を行っていきました.もしよければ試しに使ってみて,何か改善点・問題点などあれば issue で教えてもらえるとありがたいです(日本語でも英語でも大歓迎です).

GitHub Actions のワークフローをチェックする actionlint をつくった

GitHub Actions のワークフローを静的にチェックする actionlint というコマンドラインツールを最近つくっていて,概ね欲しい機能が揃って実装も安定してきたので紹介します.

github.com

なぜワークフローファイルの lint をすべきなのか

GitHub Actions が正式リリースされてからだいぶ経ち,GitHub 上での CI は GitHub Actions が第一候補となってきているように感じます.僕も新規にリポジトリを作成して CI をセットアップする場合はほぼ GitHub Actions を使っています.

ですが,GitHub Actions には下記のような問題があり,actionlint でそれらを解決・緩和したいというのが理由です.

  • ワークフローを実装する時は,GitHub に push して CI が実行されるのを待って結果を確認するという作業が繰り返し必要になり,時間がかかります.手元で分かる間違いはなるべく手元でチェックし,この繰り返し回数を減らしたいです.
  • GitHub Actions サービス側のワークフローのチェックは非常に『緩い』です.例えば,想定していないキーがあっても単に無視されるため,キー名をタイポしていても気付きません.また matrix.os のような式内でのプロパティアクセスはプロパティが存在しなくてもエラーにならず,単に null に評価されます.また,ブランチ名やタグ名のフィルタに使う glob パターンも簡単な構文チェックしか行っておらず,例えば間違えて正規表現を使ってしまったりしてもエラーになりません.
  • ワークフローが「一応実行は成功しているが実は意図通り動いていない」(静かに壊れている)ということがよくあります.例えばありがちなのは actions/cache で key: ${{ matrix.platform }}-${{ hashFiles(...) }} のようにキャッシュのキーを指定していて,matrix.platform が存在しない場合です(他所のワークフローからコピペしてきた場合などに matrix の値の名前をミスってることがあります).この場合 matrix.platformnull になり,文字列化すると空文字列になるため,キャッシュのキーは意図した値になりません.ですが,これは実行してすぐは普通に動いてしまうので間違いに気付かず,後にジョブ間での難しいキャッシュ周りの問題を引いてしまいます.

コマンドラインでの使い方

ローカルにインストールする場合は以下のいずれかの方法で使ってください.

  • (macOS の場合)Homebrew を使うただし,M1 Mac は動作確認が取れていないのでビルド済みバイナリを提供できていないのでこの方法が使えません.x86_64 用のバイナリをダウンロードして Rosetta2 で動かしていただくか,Go 1.16 以上で手元でソースからビルドしてください v1.4.1 で Apple M1 サポートしました(追記: 2021/7/12)
  • リリースページから実行バイナリをダウンロードして解凍し,PATH が通ったディレクトリに置く
  • ソースからビルドする: go install github.com/rhysd/actionlint/cmd/actionlint@latest

インストールした actionlint コマンドを lint をかけたいリポジトリ内で引数無しで実行すると,自動で .github/workflows 以下のワークフローファイルを見つけてきてチェックします.基本的にはこれだけです.

$ actionlint

ワークフローを指定したい場合は引数にパスを渡します.

$ actionlint /path/to/workflow.yml

その他オプションについては actionlint -help を確認してください.

f:id:rhysd:20210711213405p:plain
actionlint 実行例

見つけたエラーはエラー箇所のコードスニペットと一緒に標準出力に出力されます.エラーが見つからなかった場合は何も出力しません.

CI での使い方

自動で最新版の actionlint をカレントディレクトリにダウンロードしてくるダウンロードスクリプトを使うのが一番簡単です.

例えば GitHub Actions なら下記のようなジョブで実行できます.GitHub Actions でのコマンド実行は端末から実行された扱いにならずそのままでは出力に色が着かないので -color フラグを用います.

jobs:
  actionlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Run actionlint
        shell: bash
        run: |
          bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
          ./actionlint -color

また,-oneline フラグを使うと reviewdog のようなツールにも簡単に integrate できます.

ブラウザでの使い方

actionlint を WebAssembly にコンパイルし,ブラウザでも Playground として利用できるようにしています.

https://rhysd.github.io/actionlint/

ページ内のエディタにワークフローの YAML を貼り付けると,自動で lint 結果が更新されます.エラーメッセージをクリックすると,エディタのカーソルがエラー位置に移動します.全てローカルのブラウザで完結しているので,貼り付けたワークフローがリモートのサーバに送信されることはありません.一応 iOS などのモバイル端末でも動きます.

f:id:rhysd:20210711213628j:plain
actionlint playground

actionlint で実装した静的チェック

actionlint では方針としてワークフロー内のミスを見つけることにフォーカスしています.1回の実行でできるだけ多くのエラーを見つけ,かつ false positive(誤検知)を最小限に抑えることを目指しています. なので,インデントの崩れなどのコードスタイルについてはチェックしません.もしそういったチェックが欲しい場合は yamllint などのツールを別途使用してください.

actionlint ではざっくり下記のチェックを実装しています.

  • ワークフロー構文の構文チェック.必要なキーが無かったり,不要なキーがあったりするとエラーになる
  • ${{ }} 内の式構文の構文チェックおよび意味チェック.式の型チェック,コンテキストオブジェクトのプロパティチェック,関数のシグネチャチェックなど
  • shellcheck を使った run:シェルスクリプトチェック
  • pyflakes を使った run:Python スクリプトチェック
  • on: に書く Webhook イベントのチェック
  • branches: などに書く glob パターンの構文チェックおよび ref name のチェック
  • cron: に書く CRON の構文チェックおよび実行頻度チェック
  • runs-on: に書く runner ラベルのバリデーション
  • uses: に書くアクションのフォーマットチェックとローカルアクションの input チェック
  • shell: に書くシェル名のバリデーション(例えば powershellWindows 系の runner でないと使えないなど)
  • needs: に書く依存ジョブのチェック.ジョブの存在チェック,循環依存チェックなど
  • Job ID や Step ID のユニーク性のチェック
  • その他,ハードコードされたパスワードの検知,環境変数名のチェックなど...

actionlint による有用なチェックの例

チェック一覧はこちらのドキュメントに例と actionlint の出力,playground へのリンク付きで網羅的に書いてありますが,この記事内でそれを全て書くのは長くなりすぎるのでやめておきます.

代わりに,ここでは actionlint が有用な例をいくつか紹介します.コメントで 'エラー: ' と書かれているところが実際に actionlint で指摘される箇所です.

キー名やラベル名のタイポ

on:
  push:
    # エラー: branches: が正しい
    branch: main

キー名は間違っていても GitHub Actions は特に指摘してくれません.単にワークフローが走らないだけなので,自力でタイポに気付く必要があります.actionlint はどの要素がどういうキーを持っているべきかを知っているので,こういったミスをチェックできます.

strategy:
  matrix:
    # エラー: linux-latest は存在しない.ubuntu-latest が正しい
    os: [linux-latest, windows-latest]
runs-on: ${{ matrix.os }}

actionlint は runs-on: に書かれた runner ラベルが正しいかどうかもチェックします.上記のように,${{ matrix.os }} のように間接的に指定していても matrix の値を見に行ってチェックします.

Webhook のバリデーション

on:
  # エラー: pull_request が正しい
  pullreq:
  issues:
    # エラー: opened が正しい
    types: created
  schedule:
    # エラー: CRON シンタックスが間違っている(要素数が1つ足りない)
    - cron: '0 */3 * *'
    # エラー: 実行のインターバルが短すぎる
    - cron: '* */3 * * *'
  push:
    tags:
      # エラー: 正規表現は使えない
      - '^v\d+$'
      # エラー: glob パターンの構文エラー(? は * に付けられない)
      - 'v*.*.*?'

on: には Webhook イベントを書けます.どういうイベントがあるかはドキュメントに書いてあるので,actionlint は正しいイベントおよびイベントタイプが指定されているかをチェックできます.

cron: に指定するスケジュールの構文チェックや実行頻度のチェックも行います.実行頻度が1分に1回以上だとエラーになります.

さらに,tags:branches:paths: に使う glob パターンについても構文が正しいかや Git の ref name に使えない文字が使われていないかなどをチェックします.ここに正規表現を書けると勘違いしているケースが結構あるみたいなのですが,GitHub Actions 側ではエラーになりません.actionlint ならそういったケースも拾えます.

${{ }} 内の式の型チェック

# エラー: 構文チェック: 文字列リテラルにはシングルクォートしか使えない
- run: echo '${{ contains(runner.os, "windows-") }}'
# エラー: 意味チェック: github.repository は文字列型なので owner プロパティは存在しない
- run: echo '${{ github.repository.owner }}'

GitHub Actions では ${{ }} で囲んだ中に式を書いて評価させることができます.actionlint ではその式を構文木にパースして型チェックを行います.

  • github.*job.* などのグローバルに自動で定義されるコンテキストオブジェクト
  • matrix.*steps.*, env.*needs.* などの,ユーザが書いたワークフローに応じて適宜定義されるコンテキストオブジェクト
  • contains(), toJSON(), format(), hashFiles() などの組み込み関数

これらを適宜解析しながら型チェックを行います.

strategy:
  matrix:
    node: [14, 15]
    package:
      - name: 'foo'
        optional: true
      - name: 'bar'
        optional: false
# エラー: matrix に定義されていない値(コピペしてきて変え忘れが多い)
runs-on: ${{ matrix.os }}
steps:
  # エラー: 存在しないキー.matrix.package の型は {name: string, optional: bool}
  - run: echo '${{ matrix.package.dev }}'
  # エラー: startswith() は第1引数に文字列型の値を取るので,object 型の matrix.package は使えない
  - run: echo '${{ startswith(matrix.package, 'foo') }}'

actionlint は matrix: に定義された値を解析し,matrix.* オブジェクトに適宜型を付けます.存在しないプロパティにアクセスしようとした時や,型が合わないときなどにエラーにできます.

# エラー: startswith() は第1引数に文字列型の値を取るので,object 型の runner は使えない
- run: echo ${{ startswith(runner, 'linux-') }}
# エラー: hashFiles() は少なくとも1つ以上引数が必要
- run: echo ${{ hashFiles() }}
# エラー: format() 引数の数とプレースホルダの数が合っていない
- run: echo ${{ format('{0}: {1}', 1) }}

${{ }} 内の式で使える関数は組み込み関数のみでドキュメントに定義が載っています.actionlint はこれら全ての関数シグネチャの情報を持っていて,型チェックなどを行います.これらの関数の定義は結構複雑で,オーバーロードがあったり(contains() は文字列でも配列でも使えるなど),デフォルト引数(join() のセパレータのデフォルトは ',')があったり,可変長引数(hashFiles() は1つ以上の引数を取る)があったりします.actionlint ではそれらを解析できるように実装しています.

また,steps.* オブジェクトには各ステップの output 値がセットされていきます.これらの値は,そのステップが実行されるまでは存在しないため,アクセスすると null になってしまいます.これはステップを他のワークフローからコピペしてきた時によく起きる間違いです.

# エラー: ここではまだ get_value ステップが実行されていないため steps.get_value.outputs は存在しない
- run: echo '${{ steps.get_value.outputs.name }}'
# ここで steps.get_value に値がセットされる
- run: echo '::set-output name=foo::value'
  id: get_value
# OK
- run: echo '${{ steps.get_value.outputs.name }}'

run: に書くスクリプトの lint

test:
  runs-on: ubuntu-latest
  steps:
    # エラー: "$FOO" のようにクォートで括るべき
    - run: echo $FOO
test-win:
  runs-on: windows-latest
    # PowerShell で実行されるので shellcheck でチェックしない
    - run: echo $FOO

actionlint は run: に書かれたシェルスクリプトshellcheck を使ってチェックします.actionlint は run: に書かれているスクリプトがどのシェルで実行されるかを解析し,bashsh だった場合のみ shellcheck を実行します.shellcheck コマンドがシステムに存在しない時は単にチェックをスキップします(-shellcheck オプションで制御可能).

# エラー: 未定義変数 hello
- run: print(hello)
  shell: python

また,GitHub Actions では shell: python を指定することで run:Python スクリプトを書くこともできます.actionlint ではこれらの Python スクリプトを shellcheck と同様に pyflakes を使ってチェックします.pyflakes コマンドがシステムに存在しない時は単にチェックをスキップします(-pyflakes オプションで制御可能).

actionlint の設定ファイル

.github/actionlint.yml に設定ファイルを置くことができます.ただ,僕は lint の設定ファイルを極力管理したくないので,設定ファイルは基本的に必要無いようにしています.現在は,self-hosted runner を使っている時のカスタムラベルの値だけを設定に書くことができます(runs-on: のラベルのバリデーションで使われます).

設定ファイルが必要になった際は,actionlint -init-config でデフォルトの設定ファイルを生成できるので,ゼロから書く必要はありません.

actionlint の実装について

全て Go で実装しています.

ワークフローファイルは go-yaml/yaml を使って YAML構文木に一旦パースし,YAML 構文木をトラバースしてワークフロー構文独自の構文木に再構成します (parse.go).

各チェックはルールごとにワークフロー構文木の visitor の pass として分けて実装していて(rule_*.go),ルールの取捨選択やルールごとのテストがやりやすいように設計しています.ワークフロー構文木 1 pass でチェックを終えられるように,visitor に pass を登録して,visitor は構文木を巡りながら各 pass を適用していきます.

${{ }} 内の式の解析は真面目に字句解析・構文解析・意味解析を実装しました(expr_*.go).glob パターンの解析も独自にバリデータを実装しています(glob.go).

実装の方針として,できるだけ多くのエラーを検知できるようにというのがあるので,たとえ1つエラーを見つけても処理は続行します.例えばワークフロー構文木の生成の過程でエラー(必要なキーが無いなど)が見つかっても,気にせずにその後の解析を続けます.壊れたワークフローをチェックしても問題ないように,go-fuzz を使った fuzzing を行ったりもしています.

この辺りの細かい実装の話はもし需要があれば別途どこかに書くかもしれません.

単体テストはまだ十分ではないですが,一応実世界でのテストはしていて,GitHub API を使って GitHub の上位1000リポジトリから 1500 以上の GitHub Actions のワークフローファイルをかき集めてきて,それらに actionlint を適用し,検出したエラーを一通り目でチェックしました.その過程で見つかった誤検知などの問題は一通り修正しています.

まとめ

GitHub Actions ワークフローの静的チェックを行うツール actionlint を実装しました.CI のワークフローの実装やデバッグは時間がかかって大変なので,そういった作業が少しでもこれで軽減できれば良いなと思ってます.

簡単に試せるようになっているので,お手元のワークフローファイルを試してみていただき,フィードバック(誤検知の報告や機能提案など)を issue で教えてもらえるとありがたいです(日本語で大丈夫です).

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 調査時などに便利そうです.