えっちな grep をつくった
H(uman-friendly) な grep コマンド hgrep
をつくりました.
ファイルを特定のパターンで検索し,マッチした箇所を構文ハイライトしたコード片で表示します.超ざっくり言うと,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),MacPorts(macOS),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
はコード片をハイライトして表示するのに使うプリンタ実装が syntect
と bat
の2つあり,--printer
オプションで指定できます.
初めは 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 |
---|---|---|
![]() |
![]() |
![]() |
Carbonight | predawn | Material |
---|---|---|
![]() |
![]() |
![]() |
デフォルトで使うオプションを指定する
シェルの 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
Bash,Zsh,Fish,PowerShell,Elvish に対応しています.
実装周りの話
依存ライブラリ
rayon,ripgrep,syntect あたりをライブラリとして使ってます.
- 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が
fill()
の呼び出しを開始 - スレッド1が
fill()
の呼び出し内部で cell のロックを取得 - スレッド2が
fill()
の呼び出しを開始 - 既にスレッド1が cell のロックを取得しているのでスレッド2はロックを取れない
fill()
はロックが取れなかった場合は諦める実装になっているので,スレッド2はfill()
から return する- スレッド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(-)
のコード変更を行ったようです.
この記事では大きめの機能追加や改善をいくつか紹介します.
- 有名アクションの 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
この例は actions/cache
の input のチェックをしています.actions/cache
は key
という必須の 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 }}
この例は 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 }}')
このチェックは @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 形式でエラーを出力でき
のようにエラーが出力されます.
ドキュメントでは ::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
を返してしまいます.
actionlint でも shellcheck
コマンドや pyflakes
コマンドのパスを取得するのに exec.LookPath
を使っていたので,カレントディレクトリに shellcheck.exe
や pyflakes.exe
があるとそちらを意図せず実行してしまう問題がありました.現在は execabs を使うことでこの問題を修正済みです.
Playground の機能追加
https://rhysd.github.io/actionlint/
actionlint を Wasm にビルドすることでブラウザ上でも動くようにした Playground ですが,より手軽に使えて共有できるようにいくつか改善を行いました.
- 'Permalink' ボタンを設置しました.これをクリックすると今のコードエディタのソースの状態を URL のハッシュにエンコードすることで永続化します.バグ報告時に Playground で再現させてそのパームリンクを提供してもらえると助かるなと思って実装しています.
- URL の入力フォームを設置しました.URL を入力してから Check ボタンを押すと,URL 先のファイルをフェッチしてきます.もちろんですが GitHub Pages からフェッチできる URL のみ使えます.
https://github.com/owner/repo/tree/branch/...
: GitHub 上の特定のファイルをブラウザで表示させた時の URLhttps://raw.githubusercontent.com/owner/repo/branch/...
: GitHub 上でホストされている生のファイルの URLhttps://gist.github.com/owner/...
: Gist 上の特定のファイルをブラウザで表示させた時の URLhttps://gist.githubusercontent.com/owner/...
: Gist 上でホストされている生のファイルの URL
まとめ
前回の記事以降も継続して改善・機能追加を行っていきました.もしよければ試しに使ってみて,何か改善点・問題点などあれば issue で教えてもらえるとありがたいです(日本語でも英語でも大歓迎です).
GitHub Actions のワークフローをチェックする actionlint をつくった
GitHub Actions のワークフローを静的にチェックする actionlint というコマンドラインツールを最近つくっていて,概ね欲しい機能が揃って実装も安定してきたので紹介します.
なぜワークフローファイルの 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.platform
はnull
になり,文字列化すると空文字列になるため,キャッシュのキーは意図した値になりません.ですが,これは実行してすぐは普通に動いてしまうので間違いに気付かず,後にジョブ間での難しいキャッシュ周りの問題を引いてしまいます.
コマンドラインでの使い方
ローカルにインストールする場合は以下のいずれかの方法で使ってください.
- (macOS の場合)Homebrew を使う.
ただし,M1 Mac は動作確認が取れていないのでビルド済みバイナリを提供できていないのでこの方法が使えません.v1.4.1 で Apple M1 サポートしました(追記: 2021/7/12)x86_64
用のバイナリをダウンロードして Rosetta2 で動かしていただくか,Go 1.16 以上で手元でソースからビルドしてください - リリースページから実行バイナリをダウンロードして解凍し,PATH が通ったディレクトリに置く
- ソースからビルドする:
go install github.com/rhysd/actionlint/cmd/actionlint@latest
インストールした actionlint
コマンドを lint をかけたいリポジトリ内で引数無しで実行すると,自動で .github/workflows
以下のワークフローファイルを見つけてきてチェックします.基本的にはこれだけです.
$ actionlint
ワークフローを指定したい場合は引数にパスを渡します.
$ actionlint /path/to/workflow.yml
その他オプションについては actionlint -help
を確認してください.
見つけたエラーはエラー箇所のコードスニペットと一緒に標準出力に出力されます.エラーが見つからなかった場合は何も出力しません.
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 などのモバイル端末でも動きます.
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:
に書くシェル名のバリデーション(例えばpowershell
は Windows 系の 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:
に書かれているスクリプトがどのシェルで実行されるかを解析し,bash
か sh
だった場合のみ 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 で教えてもらえるとありがたいです(日本語で大丈夫です).