DSOC R&Dの島です。ArcグループにてR&Dエンジニアを務めています。前回のGitHub Actionsに関する記事の流れで、今回は Problem Matchers を紹介します。
このProblem Matchersの使い道として、例えば任意のlinterの出力をpull requestのコード差分中に注釈 (Annotation) として表示できます。こんな感じです。
本記事では、Pythonの flake8 を題材に、Problem Matchersの導入方法を示します。flake8以外の任意のツールにも応用可能です。
基本のGitHub Actionsワークフローを作成
.github/workflows/python.yml
のような新規ワークフローの定義ファイルを作成します。GitHubのWeb上で作成するとテンプレートを選択でき、簡単に環境構築できるのでお勧めです。
ここでは最もシンプルな Python application のテンプレートを選択しました。初期状態でflake8の実行もしてくれます。これを起点に作りましょう。以下はpytestを省略するなどいくつか変更しています。
# This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Python on: pull_request: types: [synchronize, opened] paths: - '**.py' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --exit-zero --max-complexity=10 --max-line-length=127
この状態で、何か問題のあるPythonコードをpushしてみましょう。インデントのスペース数が足りない(E111)、演算子の両側にはスペースが必要(E225) といった指摘がされるであろうコードです。
def increment(x): return x+1 a=1 print(increment(a))
pushしてしばらく待つと、ステータスが正常終了(✔)として付くと思います。GitHub Actionsのログを確認しに行きます。意図通りflake8に指摘されていました。
Problem Matchersを設定する
ではいよいよ本題に入ります。ここまでに設定したワークフローについていくつか改善したいポイントがあります。
- GitHub Actionsワークフローのログまで見に行かないと、flake8の指摘事項がわからない
- GitHub Actionsの成功/失敗のステータスを制御したい
flake8の指摘事項をコード中に挿入する
GitHub Annotations をコード中に入れ込む設定をします。Checks APIの文書から詳細な仕様を確認できます。
https://docs.github.com/en/rest/reference/checks#runsdocs.github.com
このChecks APIを使ったアノテーション登録が"正統派"ですが、もっと簡単にアノテーションを入れ込む方法も用意されています。それが本記事の冒頭で紹介した Problem Matcher です。
このリンクにある公式の解説が十二分にわかりやすいので、わざわざ拙い説明をするまでもないのですが、以下なぞっていきましょう。linterの標準出力にマッチするような正規表現を定義しておくだけで済み、お手軽です。
まず、flake8の出力仕様を確認します。
$ flake8 . .\main.py:2:3: E111 indentation is not a multiple of four .\main.py:2:11: E226 missing whitespace around arithmetic operator .\main.py:8:20: W292 no newline at end of file ...
これは以下のような情報を含んでおり、その下に示すような正規表現でマッチさせられることがわかります。
- Pythonファイル名
- 行番号
- 列番号
- エラー・警告の別、エラー番号
- エラーメッセージ
/(?<file>.+):(?<line>\d+):(?<column>\d+):\s*(E|W)(?<error>\d+)\s*(?<message>.*)/
これを念頭に、以下のような内容のJSONファイル2個をそれぞれ .github/flake8_error.json
.github/flake8_warning.json
というような名前で作成します。ほとんど同じで、EかWかの差です *1 *2。
"file": 1
の意味するところは、正規表現の何番目のグループがファイル名を表しているか
です。line
以下も同様です。
- .github/flake8_error.json
{ "flake8_error": [ { "owner": "hoge", "severity": "error", "pattern": [ { "regexp": "^([^:]+):(\\d+):(\\d+):\\s+(E\\d+\\s+.+)$", "file": 1, "line": 2, "column": 3, "message": 4 } ] } ] }
- .github/flake8_warning.json
{ "flake8_warning": [ { "owner": "hoge", "severity": "warning", "pattern": [ { "regexp": "^([^:]+):(\\d+):(\\d+):\\s+(W\\d+\\s+.+)$", "file": 1, "line": 2, "column": 3, "message": 4 } ] } ] }
そして、GitHub Actionsのワークフロー定義YAMLファイルに以下を追加します。flake8の実行前に入れてください。(参考: toolkit/commands.md at master · actions/toolkit · GitHub)
- name: Add problem matcher run: | echo "::add-matcher::.github/flake8_error.json" echo "::add-matcher::.github/flake8_warning.json"
これでGitHub Actionsが実行されると、コード差分が以下のようになります *3。
flake8向けサードパーティーアクション
以上、Problem Matchersの設定方法を述べましたが、実はflake8については、このサードパーティーアクションを入れると一発で終わります。*4
- name: Setup flake8 annotations uses: rbialon/flake8-annotations@f8c29dc2e054df26e0171b0005e99454f7db57a0 # v1
従って、flake8向けについては前述のJSONファイルを作ったりの手順は不要です。そのほかProblem Matchersの説明にあるように、setup-python や setup-dotnet といった既存のアクションでも内部的にProblem Matchersの準備を整えてくれているものがあります *5 。これまでに述べた方法は、そういった先人の用意がないツールの出力に対応する際の知恵として覚えておきましょう。
flake8をサポートするサードパーティーアクションは以下のように群雄割拠の様相で、どれにすればいいのやら迷ってしまいます。いくつか試した限りでは rbialon/flake8-annotations
が最もお勧めです。Checks APIを叩いてゴリゴリがんばっているものが多く*6、そんな中これはProblem Matchersを使用しているので実装がごく簡単です。
エラーの際のステータスを制御する
以降は細かい使い勝手の改良を図ります。まずはGitHub Actionsのステータスです。
パターン1: エラー・警告は一律で失敗にする
テンプレートではflake8のオプションに --exit-zero
を付けているので、エラーがあっても正常終了扱いになります。もしステータスを失敗 (赤い×) にして気づきやすくしたいならば、--exit-zero
を外します。警告やエラーが1つでもあればバツになり、完璧であれば通過します。
run: | flake8 . --max-complexity=10 --max-line-length=127
パターン2: エラーのみを失敗にする
flake8のエラーのみを失敗にして、警告は見逃したい場合は、エラーだけをgrep
する方法が考えられます。
GitHub Actionsのシェルは set -e
されている扱いで、コマンドが異常終了するとワークフローは即死します。grep
でヒットしない場合もそれに該当するので罠でした。(参考: Discussions · community · GitHub)
- name: Lint with flake8 run: | FLAKE8_OUT=$(flake8 . --exit-zero --max-complexity=10 --max-line-length=127) echo "$FLAKE8_OUT" FLAKE8_ERRORS=$(echo "$FLAKE8_OUT" | grep -P '^.+:\d+:\d+:\s*E' || true) if [ -n "$FLAKE8_ERRORS" ]; then echo "::error::!!flake8 errors detected!!" exit 1 fi
以上2パターンを述べましたが、最初のテンプレートにあったように実際にはflake8の後ろでpytest
の実行をすることも多いでしょう。それも踏まえて、flake8のエラーの扱いは柔軟に決定すると良いと思います。
今回のpull requestの差分だけを対象にする
flake8はリポジトリ全体に実行しているので、今回のpull requestで編集していないファイルに対しても指摘されます。かつ、GitHubはそういうファイルも "Unchanged files with check annotations" として表示してくれます(2021年2月現在ベータ機能)。
もし今回のpull requestの差分のみを対象にしたい場合は、git diff
と合わせ技でがんばれば実現可能です。以下は素朴ですが私が作成した例です。
- name: Lint with flake8 run: | # 差分のあった.pyファイルのリストを得る git fetch DIFF_FILES=$(git diff remotes/origin/${{ github.base_ref }}..HEAD --diff-filter=ACDMR --name-only "*.py") # git diff に挙がったファイルについてのflake8エラーのみを拾う DIFF_EXISTS="false" for f in $DIFF_FILES; do FLAKE8_OUT=$(flake8 $f --exit-zero --max-complexity=10 --max-line-length=127) if [ -n "$FLAKE8_OUT" ]; then echo "$FLAKE8_OUT" DIFF_EXISTS="true" fi done # flake8エラーが1つでもあれば、アクション全体をエラーで終了 if [ "$DIFF_EXISTS" = "true" ]; then echo "::error::!!flake8 errors detected!!" exit 1 fi
GitHub Actionsワークフロー完成形
name: Python on: pull_request: types: [synchronize, opened] paths: - '**.py' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 - name: Setup flake8 annotations uses: rbialon/flake8-annotations@f8c29dc2e054df26e0171b0005e99454f7db57a0 # v1 - name: Lint with flake8 run: | git fetch DIFF_FILES=$(git diff remotes/origin/${{ github.base_ref }}..HEAD --diff-filter=ACDMR --name-only "*.py") # stop the build if there are Python syntax errors or undefined names flake8 . --select=E9,F63,F7,F82 --show-source --statistics DIFF_EXISTS="false" for f in $DIFF_FILES; do # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide FLAKE8_OUT=$(flake8 $f --exit-zero --max-complexity=10 --max-line-length=127) if [ -n "$FLAKE8_OUT" ]; then echo "$FLAKE8_OUT" DIFF_EXISTS="true" fi done if [ "$DIFF_EXISTS" = "true" ]; then echo "::error::!!flake8 errors detected!!" exit 1 fi
おわりに
- GitHub ActionsのProblem Matchersにより、標準出力フォーマットに合うような正規表現を柔軟に適用するだけで、コード中へのアノテーションを入れることができます。
- 本記事では、公式のPython applicationテンプレートにProblem Matchersの肉付けをすることで、flake8のチェック結果を使いやすく表示できました。
- YAMLべた書きから発展して独自アクション化できると、さらにYAMLもすっきりでき、「CI職人」化の抑制にもつながりそうです。細かいカスタマイズ性も残したいので悩ましいところではあります。
- 当社では広くSiderというサービスを活用しており、flake8等のlinterも簡単に導入できます。Siderが有用なサービスであることは揺るがないですが、単純なlinterの運用であれば本記事の方法によっての代用は可能という道筋がつきました。Siderはそこそこ高額 (月12ドル/1シート) という問題がある一方、GitHub Actionsは安価であり、開発者の人数が多いほどコストメリットがあります。
- GitHub Actionsのサードパーティーアクションの海をさまよっていると、それまでの自分の苦労が馬鹿らしくなるような非常に便利な代物をしばしば見つけられます。しかしながら多くのケースで、GitHubのスター数が少なくあまり知られていないように見受けられます。本記事で紹介した rbialon/flake8-annotations もその例で、まだまだGitHub Actionsの世界はスタートを切ったばかりという印象です。
*1:EとWはpep8のエラーで、実際にはほかにF(PyFlakes)などのコードも存在します。
*2:Problem Matchersのserverityの仕様によると、"error"または"warning"という文字列のみ有効なので、E/Wをsed等の何らかの方法でerror/warningに置換してあげれば、以下のように2つJSONファイルを用意しなくても可能です。
*3:本記事の執筆時現在では、アノテーションはWebブラウザからの閲覧時のみ見えます。iOSやAndroidのGitHubアプリでは表示されないようです。
*4:サードパーティーアクションの注意点については、こちらをご参照ください。https://docs.github.com/ja/actions/learn-github-actions/security-hardening-for-github-actions
*5:こちらは拙著のdotnet buildの例で、何も意識せずただビルドするだけでアノテーションが入ります: GitHub Actions で .NETプロジェクトの静的コード解析を行う
*6:Checks APIを使用するアクションはprivateリポジトリでうまく動作しないこともありました。