Sansan Builders Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

【R&D DevOps通信】GitHub Actions の Problem Matchers でコード中に注釈を入れる (flake8を例に)

DSOC R&Dの島です。ArcグループにてR&Dエンジニアを務めています。前回のGitHub Actionsに関する記事の流れで、今回は Problem Matchers を紹介します。

このProblem Matchersの使い道として、例えば任意のlinterの出力をpull requestのコード差分中に注釈 (Annotation) として表示できます。こんな感じです。

f:id:sansan_shima:20210209003557p:plain
flake8のGitHubアノテーション出力例

本記事では、Pythonの flake8 を題材に、Problem Matchersの導入方法を示します。flake8以外の任意のツールにも応用可能です。

基本のGitHub Actionsワークフローを作成

.github/workflows/python.yml のような新規ワークフローの定義ファイルを作成します。GitHubのWeb上で作成するとテンプレートを選択でき、簡単に環境構築できるのでお勧めです。

f:id:sansan_shima:20210209001820p:plain
Workflow template画面

ここでは最もシンプルな 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に指摘されていました。

f:id:sansan_shima:20210209003900p:plain
GitHub Actionsのログで見るflake8の出力

Problem Matchersを設定する

ではいよいよ本題に入ります。ここまでに設定したワークフローについていくつか改善したいポイントがあります。

  1. GitHub Actionsワークフローのログまで見に行かないと、flake8の指摘事項がわからない
  2. GitHub Actionsの成功/失敗のステータスを制御したい

flake8の指摘事項をコード中に挿入する

GitHub Annotations をコード中に入れ込む設定をします。Checks APIの文書から詳細な仕様を確認できます。

docs.github.com

このChecks APIを使ったアノテーション登録が"正統派"ですが、もっと簡単にアノテーションを入れ込む方法も用意されています。それが本記事の冒頭で紹介した Problem Matcher です。

github.com

このリンクにある公式の解説が十二分にわかりやすいので、わざわざ拙い説明をするまでもないのですが、以下なぞっていきましょう。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
...

これは以下のような情報を含んでおり、その下に示すような正規表現でマッチさせられることがわかります。

  1. Pythonファイル名
  2. 行番号
  3. 列番号
  4. エラー・警告の別、エラー番号
  5. エラーメッセージ
/(?<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

f:id:sansan_shima:20210209003557p:plain
flake8のGitHubアノテーション出力例

flake8向けサードパーティーアクション

以上、Problem Matchersの設定方法を述べましたが、実はflake8については、このサードパーティーアクションを入れると一発で終わります。*4

github.com

- name: Setup flake8 annotations
  uses: rbialon/flake8-annotations@f8c29dc2e054df26e0171b0005e99454f7db57a0  # v1

従って、flake8向けについては前述のJSONファイルを作ったりの手順は不要です。そのほかProblem Matchersの説明にあるように、setup-pythonsetup-dotnet といった既存のアクションでも内部的にProblem Matchersの準備を整えてくれているものがあります *5 。これまでに述べた方法は、そういった先人の用意がないツールの出力に対応する際の知恵として覚えておきましょう。

flake8をサポートするサードパーティーアクションは以下のように群雄割拠の様相で、どれにすればいいのやら迷ってしまいます。いくつか試した限りでは rbialon/flake8-annotations が最もお勧めです。Checks APIを叩いてゴリゴリがんばっているものが多く*6、そんな中これはProblem Matchersを使用しているので実装がごく簡単です。

github.com

エラーの際のステータスを制御する

以降は細かい使い勝手の改良を図ります。まずはGitHub Actionsのステータスです。

パターン1: エラー・警告は一律で失敗にする

テンプレートではflake8のオプションに --exit-zero を付けているので、エラーがあっても正常終了扱いになります。もしステータスを失敗 (赤い×) にして気づきやすくしたいならば、--exit-zero を外します。警告やエラーが1つでもあればバツになり、完璧であれば通過します。

run: |
  flake8 . --max-complexity=10 --max-line-length=127

f:id:sansan_shima:20210209185735p:plain
ステータス表示の例

パターン2: エラーのみを失敗にする

flake8のエラーのみを失敗にして、警告は見逃したい場合は、エラーだけをgrepする方法が考えられます。

GitHub Actionsのシェルは set -e されている扱いで、コマンドが異常終了するとワークフローは即死します。grepでヒットしない場合もそれに該当するので罠でした。(参考: GitHub Actions: using grep when no lines selected - GitHub Actions - GitHub Support Community)

- 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月現在ベータ機能)。

f:id:sansan_shima:20210210000127p:plain
今回の差分に無いファイルへのアノテーション例

もし今回の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:サードパーティーアクションの注意点については、こちらをご参照ください。GitHub Actions のセキュリティ強化 - GitHub Docs

*5:こちらは拙著のdotnet buildの例で、何も意識せずただビルドするだけでアノテーションが入ります: GitHub Actions で .NETプロジェクトの静的コード解析を行う

*6:Checks APIを使用するアクションはprivateリポジトリでうまく動作しないこともありました。

© Sansan, Inc.