えっちな grep をつくった

H(uman-friendly) な grep コマンド hgrep をつくりました.

github.com

f:id:rhysd:20211123211157p:plain
'\w+ で検索した時の出力

ファイルを特定のパターンで検索し,マッチした箇所を構文ハイライトしたコード片で表示します.超ざっくり言うと,ripgrep で検索して bat でマッチ箇所付近を表示するような感じです.

grep -C によるコンテキスト表示に似ていますが,マッチ行が近い時は1つのコード片にまとめる,周囲何行を表示するかをヒューリスティックに少し賢く決めているなど,ちょっと出力は工夫しています.

動機

手元のリポジトリでコードを検索する時は

  • 単純に grep で検索してマッチ結果を眺める
  • grep | fzf のように検索結果を fzf で絞り込んだりプレビューする
  • vim $(grep -l ...) のように検索結果をエディタで開く

あたりを使い分けているのですが,マッチ結果全体を個々のマッチの周囲を見ながら眺めるというのがやりにくいと常々思っていました. 要は GitHub のコード検索結果のような出力を手元でも見たいなというのが,hgrep を使った動機です.

インストール方法

  • リリースページから zip をダウンロードして解凍し,なかに入っている実行ファイルを /usr/local/bin などにコピーしてください.
  • cargo を使って cargo install hgrep でインストールできます.ソースからビルドする場合は feature flags を使って自分のほしい機能だけを有効にしてインストールすることもできます(バイナリサイズや依存パッケージを減らせる).
  • Homebrew (macOS もしくは Linux),MacPortsmacOS),pkgin(NetBSD)などのパッケージマネージャもサポートしています.

詳しくはドキュメントを参照してください.

使い方

ripgrep と同じように使えます.普段 rg コマンドを使ってるなら,それを hgrep に置き換えるだけでほぼ問題ないと思います.

hgrep [options] pattern [path...]

例えば ./src 以下の なんとかPrinter を検索したければ

hgrep '\w+Printer' ./src

のようにするとマッチ箇所のコードスニペット一覧が出力されます.

また,grep -nH の出力を標準入力から受け取ることもできます.hgrep ではサポートできていない検索方法を使いたい時や,一旦ファイルに保存したり加工した結果を表示する時に便利です.

grep -nH pattern -R [path...] | hgrep

例えば ./src 以下の なんとかPrinter を検索したければ

grep -nH '\w+Printer' -R ./src | hgrep
rg -nH '\w+Printer' ./src | hgrep

カスタマイズ

プリンタを指定する

hgrep はコード片をハイライトして表示するのに使うプリンタ実装が syntectbat の2つあり,--printer オプションで指定できます.

  • syntect: sytnect ライブラリを使って自前で実装したもの(デフォルト)
  • bat: bat をライブラリとして使って実装したもの

初めは bat を表示部分に使おうと思っていたのですが,パフォーマンスや出力結果に色々不満が出てきてしまったので,結局 syntect で自前実装したという経緯があり,特に理由が無ければ syntect がおすすめです.

  • bat プリンタの2倍〜4倍ほど高速
  • マッチ範囲がハイライトされるなど,出力やレイアウトが hgrep 向けに最適化されている
  • より多くのカラーテーマに対応
  • --background オプションによる背景色の描画(bat はカラーテーマの背景色を反映しない)
  • ターミナル互換性周りの改善(├ や ┬ などの unicode 文字がうまく表示できないターミナル向けのオプションや16色しか使えないターミナルへの対応)

syntect プリンタにはこれらの利点があります.

カラーテーマを選ぶ

--list-themes オプションを使うと各カラーテーマのプレビューを表示できるので,その中から気に入ったテーマを見つけることができます.

# 背景色なしでプレビュー
hgrep --list-themes
# 背景色ありでプレビュー
hgrep --list-themes --background

デフォルトの Monokai の他にも30種類のカラーテーマが用意されています.

また,あまりカラフル過ぎると見づらいという人向けに,Cyanide や Carbonight などの色数を抑えたカラーテーマもあります

ayu-dark ayu-mirage ayu-light
ayu-dark ayu-mirage ayu-light
Carbonight predawn Material
carbonight predawn cyanide

デフォルトで使うオプションを指定する

シェルの alias コマンドを使って,hgrep コマンドを上書きしてください.

# --hidden: 隠しファイルを検索対象にする
# --theme: ayu-dark をカラーテーマに使う
# --background: 背景色を描画する
alias hgrep='hgrep --hidden --theme ayu-dark --background'

これを例えば bash なら .bash_profile あたりに設定しておくと良いと思います.

また,less などの pager を使いたい場合は,下記のように関数を使って hgrep コマンドを上書きしてください.

# less を使ってページングする
function hgrep() {
    command hgrep --term-width "$COLUMNS" "$@" | less -R
}

標準出力を別プロセスにつなぐとターミナルのウィンドウサイズが分からなくなるので,--term-width$COLUMNS で明示的に指定する必要があります.

コマンド補完

--generate-completion-script オプションでシェルの補完スクリプトを標準出力に出力します.これを補完スクリプトに設定することで,オプションなどの補完が効くようになります.

# Zsh を使っていて set comps=~/.zsh/site-functions している場合
hgrep --generate-completion-script zsh > ~/.zsh/site-functions/_hgrep

BashZsh,Fish,PowerShell,Elvish に対応しています.

実装周りの話

依存ライブラリ

rayonripgrepsyntect あたりをライブラリとして使ってます.

  • rayon: 有名なお手軽データ並列処理ライブラリ.便利すぎる.
  • ripgrep: Rust から rg コマンドとほぼ同等の機能・パフォーマンスの grep 実装を直接使うことができます.ライブラリとしてもよく整備されています.
  • syntect: シンタックスハイライトするためのライブラリです.Sublime Text のハイライト定義やカラーテーマを使うことができ,多くのカラーテーマや言語ごとの構文ハイライトルールの資産を使うことができます.さらに,複数のスレッドで並列にハイライト処理を行えるようにもなっていて(後述),よくできてます.

パフォーマンス

最初は ripgrep と bat をライブラリとして使い,ripgrep の検索結果を bat の bat::PrettyPrinter で表示する小さいツールを考えていたのですが,残念ながらパフォーマンスが満足のいくものになりませんでした.

問題はコードをハイライトして出力する部分で,

  • そもそも構文ハイライトは重い処理です.大量の正規表現マッチを繰り返してコードの各部分のハイライト色を決定します
  • 正確に構文ハイライトするにはファイルの頭から順番にハイライトを計算していく必要があります.なのでファイルの後ろに行くほど時間がかかります
  • 構文ハイライトに使うハイライトルール定義を合計で数 MB ほどロードする必要があります

これらを直接解決するのは難しいので,十分なパフォーマンスを出すために,処理を並列化することにしました.そこで rayon を使い,ファイル単位でスレッドを割り当ててファイル内検索(grep)→ コード片にまとめる処理 → ハイライトの計算 を並列で行って,最後に出力処理だけは直列化して行っています.

ここで前述の syntect の並列ハイライト対応をうまく活かすことができました.残念ながら bat::PrettyPrinter は並列に使える実装になってませんでした.

syntect を使った場合 bat を使った場合

これによってハイライト処理が劇的に速くなり,2倍以上高速化しました.

マルチスレッド絡みのバグ

syntect で並列にハイライト処理をした時だけごく稀にクラッシュする問題を見つけて修正したりしました.普段使いでは一度だけ再現したクラッシュバグで,その後ベンチマークで高速で初期化→入力→描画の処理をひたすら繰り返すと10回に1回程度の頻度で再現させることができ,スレッド数を1に絞ると再現しなくなるというものでした.

該当箇所だけを抜き出すとこんな感じです.

use lazycell::AtomicLazyCell;

struct X {
    cell: AtomicLazyCell<Regex>,
}

impl X {
    fn new() -> Self {
        Self { cell: AtomicLazyCell::new() }
    }

    fn get_regex(&self) -> &T {
        if let Some(x) = self.cell.borrow() {
            x
        } else {
            let regex = ...;
            self.cell.fill(regex).ok();
            self.cell.borrow().unwrap()
        }
    }
}

lazycell は値の初期化を遅らせる機能を提供するライブラリで,AtomicLazyCell は最初「空」の状態で初期化され,fill() が呼ばれた時に初めて初期化されます(初期化より前に値を取ろうとするとクラッシュします).borrow() はすでに初期化された値があればその値への参照を Some で返し,そうでなければ None を返します

get_regex() メソッドは「cell が初期化されていればすでにある値を返し,初期化されていなければ値を生成して cell を初期化してから値を返す」というメソッドです.クラッシュを起こしたのは self.cell.borrow().unwrap() の部分で,直前で fill を呼んでいるにも関わらず borrow()None を返しているということを示しています.

このバグは2つ以上のスレッドがほぼ同時に self.cell.fill(regex) を実行した場合にのみ起こります.AtomicLazyCell は内部でロックを取って値を初期化するスレッドを1つに限定しているのですが,

  1. スレッド1が fill() の呼び出しを開始
  2. スレッド1が fill() の呼び出し内部で cell のロックを取得
  3. スレッド2が fill() の呼び出しを開始
  4. 既にスレッド1が cell のロックを取得しているのでスレッド2はロックを取れない
  5. fill() はロックが取れなかった場合は諦める実装になっているので,スレッド2は fill() から return する
  6. スレッド2はすぐに次の行の borrow() を実行する.しかしこの時,スレッド1はまだ cell に値をセットできていない.そのため borrow()None を返す(後続の unwrap() でクラッシュ)

という流れでした.

これは lazycell が,fill() で値を初期化しに逝く時に既にロックを誰かが取っていると即諦める仕様になっているのが問題なので,once_cell を使うことで解決できました.

once_cell は複数スレッドが同時に1つの cell を初期化しようとした時,処理をキューイングして後に来たほうを(busy loop で)待たせる実装になっているのでこの問題は起こりません.busy loop なので,待たされている側のスレッドが長時間待たされると計算資源を食いつぶしてまずいですが,少なくとも今回は Regex の初期化でそこまで時間がかかる処理ではなかったので問題ありませんでした.

まとめ

Human-friendly な出力の grep コマンド,hgrep をつくりました.完全に自分の使いたいユースケースに向けてつくってますが,もしよければ試してみてください.とりあえず欲しい機能はすべて揃っている状態です.

パフォーマンスは現時点でも問題ないですが,コード片の範囲を決める計算を O(n) から O(1) に落とす,ハイライトルール定義の読み込みを遅延する,構文ハイライトを計算する範囲を削ってハイライト処理をサボるなどいくつかアイデアはあるので,時間がある時にでも試しに実装してみようかなと思ってます.

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 で教えてもらえるとありがたいです(日本語で大丈夫です).