11
7

More than 1 year has passed since last update.

ローカルで変更差分だけに Super-Linter を実行し、効率的に品質を上げる

Last updated at Posted at 2023-09-04

はじめに

Super-Linter は GitHub Actions で様々な静的解析ツール(Linter)をまとめて実行してくれるツールです

2023年9月時点で 49 種類の言語(JavaScriptやPython、SQL、Markdown など)、 67 種類の静的解析ツール(eslint、pylint、sql-lint、markdownlint など)に対応しています

大抵の言語はこれでまとめて静的解析してくれるため、 CI をかなり簡略的に書くことができます

未対応の言語(Swift、Elixir など)は個別に CI を書きます

以前、この Super-Linter をローカルで実行する方法について記事を書きました

GitHub Actions はパブリックリポジトリーなら無償で使えますが、プライベートリポジトリーでは有償なので出来るだけ実行回数を少なく抑えたいです

そのため、ローカルであらかじめ実行してエラーを排除してから、最終確認だけを GitHub Actions で行う、という手順を実装します

ただし、ローカルで実行すると必ず対象ディレクトリー配下の全てのファイルを対象にしてしまう、という問題点があります

NOTE: The flag:RUN_LOCAL will set: VALIDATE_ALL_CODEBASE to true. This means it will scan all the files in the directory you have mapped. If you want to only validate a subset of your codebase, map a folder with only the files you wish to have linted

もしリポジトリー内の全てのファイルを毎回静的解析していたら、恐ろしく時間がかかってしまいます

そこで、ローカルでの変更差分だけを対象とするようなスクリプトを実装します

実装したコードはこちら

変更差分の取得

各ファイルの状態取得

ローカルでの変更差分をスクリプトで取得するため、 git コマンドを使います

git status コマンドは作業ディレクトリー内の状態を確認するコマンドです

例えば VSCode で以下のように表示される変更差分があるとします

スクリーンショット 2023-09-03 14.10.14.png

そのとき、 git status の結果は以下のように表示されます

$ git status
On branch feature/no-renames
Your branch is up to date with 'origin/feature/no-renames'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   new.txt
        modified:   package.json
        deleted:    requirements.txt
        renamed:    webpack-sample/src/hello.js -> webpack-sample/src/hero.js

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        deleted:    LICENSE
        modified:   webpack-sample/src/index.js

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        new/

この出力形式は色々な説明を含んでいて親切ではありますが、スクリプトで使うには冗長です

--porcelain (ポーセリン = 磁器) オプションを指定して、スッキリした形式にします

$ git status --porcelain
 D LICENSE
A  new.txt
M  package.json
D  requirements.txt
R  webpack-sample/src/hello.js -> webpack-sample/src/hero.js
 M webpack-sample/src/index.js
?? new/

ファイルの状態が左側の文字で表現されています

  • A: 追加
  • M: 変更
  • D: 削除
  • R: 名前変更
  • ??: 未追跡(まだgit管理下に入っていない)

また、「M 」のように文字が左にある場合はステージされている状態、「 M」のように文字が右側にある場合はステージしていない状態です

これを上手く加工していけば対象差分のファイル一覧を取得できそうですね

ファイル名一覧の取得

awk コマンドを使えばスペースで区切られた文字列から指定した位置の文字列を取得することができます

ファイル名はスペース区切りの2列に存在しているので、 git status --porcelain の結果を awk '{print $2}' にパイプしてあげます

$ git status --porcelain | awk '{print $2}'
LICENSE
new.txt
package.json
requirements.txt
webpack-sample/src/hello.js
webpack-sample/src/index.js
new/

ただし、これだと問題があります

  • 名前変更したファイルは変更前のパスを取得している
  • 削除したファイルも取得している

最終的には変更差分ファイルに対して Super-Linter を実行するため、名前変更の場合は変更後のパスが必要であり、削除したファイルは対象外にすべきです

--no-ranames オプションを付けることで、名前変更を削除+追加として取得することができます

$ git status --porcelain --no-renames
 D LICENSE
A  new.txt
M  package.json
D  requirements.txt
D  webpack-sample/src/hello.js
A  webpack-sample/src/hero.js
 M webpack-sample/src/index.js
?? new/

また、 grep で D が左側にない = 削除以外に絞り込めば良さそうです

正規表現 ^ *D により、先頭(^)にスペースが0個以上あった後に D の文字がある = 削除したファイルを取得します

--invert-match を指定することで、それ以外のファイルを取得します

$ git status --porcelain --no-renames | grep --invert-match '^ *D'
A  new.txt
M  package.json
A  webpack-sample/src/hero.js
 M webpack-sample/src/index.js
?? new/

これを改めて awk にパイプします

$ git status --porcelain --no-renames | grep --invert-match '^ *D' |  awk '{print $2}'
new.txt
package.json
webpack-sample/src/hero.js
webpack-sample/src/index.js
new/

これで、追加もしくは変更されたファイルの一覧が取得できました

対象ファイルの一時ディレクトリーへのコピー

変更差分ファイルのコピー

以下のコードで変更差分のファイルを取得し、 ./tmp/lint ディレクトリー内にコピーします

繰り返し実行するため、毎回空にしてからコピーします

readonly WORKDIR="./tmp/lint"

rm -rf "${WORKDIR}"

mkdir -p "${WORKDIR}"

target_files=$(
    git status --porcelain --no-renames \
        | grep --invert-match '^ *D' \
        | awk '{print $2}'
)

for file in ${target_files}; do
    mkdir -p "${WORKDIR}/$(dirname "${file}")"
    cp -R "${file}" "${WORKDIR}/${file}"
done

rsync コマンドだと「前回差分があったファイル」だが「今回差分がなかったファイル」が残ってしまいます
(ファイルスク数指定の場合、 --delete オプションが効かない)

また、ディレクトリー全体が未追跡の場合、 ${file}new/ のようにディレクトリーが入ってきます

そのときにエラーにならないよう、 cp-R オプション(ディレクトリー内を全てコピー)を指定しています

shfmt を使用したい場合

シェルスクリプトの形式チェックツール shfmt を Super-Linter 上で実行したい場合、 ./tmp/lint ディレクトリー配下に .editorconfig ファイルが必要です
.editorconfig ファイルが shfmt の設定になっているため)

.editorconfig ファイルだけは無条件で毎回コピーしましょう

cp .editorconfig "${WORKDIR}/.editorconfig"

Super-Linter の実行

Super-Linter はローカルの Docker コンテナで実行します

毎回長いオプション指定をしたくないので、 docker-compose.super-linter.yml (ファイル名は何でも良い)に以下の内容を書いておきます

version: '3.2'
services:
  linter:
    image: "github/super-linter:slim-v5"
    environment:
      - RUN_LOCAL=true
      - USE_FIND_ALGORITHM=true
      - LINTER_RULES_PATH=../rules
    volumes:
      - ./tmp/lint:/tmp/lint
      - ./:/tmp/rules

イメージの指定

SUper-Linter のイメージには github/super-linter:slim-v5 を指定し、最新の軽量版を使います

軽量版では動かない Linter を使いたい場合は slim- を消してください

環境変数の指定

環境変数は最低限のものを指定しています

RUN_LOCAL=true でローカル実行を指定し、 USE_FIND_ALGORITHM=true でファイル探索を可能にします

静的解析の設定をカスタマイズしている場合、 LINTER_RULES_PATH=../rules を指定してください

また、特定の Linter を動かしたくない場合などは適宜環境変数を追加してください

ボリュームの指定

ローカルの ./tmp/lint をコンテナ上の /tmp/lint (静的解析の実行対象ディレクトリー)にマウントしています

これにより、 ./tmp/lint 配下のファイルを対象として Super-Linter が実行されます

環境変数 LINTER_RULES_PATH=../rules と併せてボリュームに ./:/tmp/rules を指定することで、プロジェクトのルートディレクトリー配下にある .eslintrc.yml などのカスタム設定が使用されます

M1 Mac 、 M2 Mac (Apple シリコン)を使用している場合の注意点

Super-Linter のイメージが amd64 アーキテクチャーにしか対応しておらず、 arm64 アーキテクチャーでは一部動作に問題があります

Super-Linter は GitHub Actions の Ubuntu 上で動かすことが基本的な利用方法なので、ここは仕方ないですが、、、

早くマルチアーキテクチャに対応して欲しいです

hadolint のエラー

M1 Mac 、 M2 Mac を使用している場合、 Super-Linter 内の hadolint (Dockerfile に対する静的解析)が Segmentation fault を起こして必ずエラーになってしまいます

この場合、 docker-compose.super-linter.yml に以下の環境変数を追加し、 hadolint は別の手段で実行しましょう

...
    environment:
      ...
      - VALIDATE_DOCKERFILE_HADOLINT=false

actionlint のエラー

Super-Linter 内の actionlint (GitHub Actions の定義ファイルに対する静的解析)がエラーになる場合があります

GitHub Actions を定義している YAML ファイル内に run: が複数存在する場合です

この場合、以下のようなエラーが発生します

`/usr/bin/shellcheck --norc -f json -x --shell bash -e SC1091,SC2194,SC2050,SC2154,SC2157 -` did not run successfully while checking script at line:28,col:9: /usr/bin/shellcheck was terminated. stderr: ""

この場合、 docker-compose.super-linter.yml に以下の環境変数を追加し、 actionlint は別の手段で実行しましょう

...
    environment:
      ...
      - VALIDATE_GITHUB_ACTIONS=false

スクリプトからのコンテナ起動

スクリプトからは以下のようにしてコンテナを起動できます

docker compose \
    --file docker-compose.super-linter.yml \
    up \
    --build \
    --exit-code-from linter

--exit-code-from linter で linter サービスの終了コードを docker コマンドの終了コードにします

これにより、静的解析でエラーを検知した場合、スクリプトをエラー終了させることができます

スクリプト全体

スクリプト全体を super-linter.sh として、以下のように実装します

#!/bin/bash
# -- super-linter.sh -----------------------------------------------------------
#
# Super Linter による静的コード解析を実行する
#
# Copyright (c) 2023 Ryo Wakabayashi
#
# ------------------------------------------------------------------------------

set -euo pipefail

readonly ME=${0##*/}
readonly WORKDIR="./tmp/lint"

display_usage() {

    cat << EOE

    Super Linter による静的コード解析を実行するスクリプト

    構文: ./${ME}

EOE

    exit

}

check_sanity() {

    [[ $(command -v docker) ]] \
        || whoopsie "Please install Docker first."

}

copy_target_files() {

    rm -rf "${WORKDIR}"

    mkdir -p "${WORKDIR}"

    target_files=$(
        git status --porcelain --no-renames \
            | grep --invert-match '^ *D' \
            | awk '{print $2}'
    )

    for file in ${target_files}; do
        mkdir -p "${WORKDIR}/$(dirname "${file}")"
        cp -R "${file}" "${WORKDIR}/${file}"
    done

    cp .editorconfig "${WORKDIR}/.editorconfig"

}

lint() {

    docker compose \
        --file docker-compose.super-linter.yml \
        up \
        --build \
        --exit-code-from linter \
        || whoopsie "Lint failed."

}

main() {

    while getopts h opt; do
        case $opt in
            h)
                display_usage
            ;;
            :)
                whoopsie "Option missing!"
            ;;
            \?)
                whoopsie "Invalid option!"
            ;;
        esac
    done

    check_sanity

    copy_target_files

    lint

}

whoopsie() {

    local message=$1

    echo "${message} Aborting..."
    exit 192

}

main "$@"

exit 0

実行すると、以下のような結果が表示されます

./super-linter.sh
[+] Running 1/0
 ✔ Container ci-example-linter-1  Created                                                                                                                      0.0s 
Attaching to ci-example-linter-1
ci-example-linter-1  | --------------------------------------------------------------------------------
ci-example-linter-1  | 
ci-example-linter-1  |                               /@@#///////@@/(@//@%/(@.@(       @@
ci-example-linter-1  |                           @@//////////////////////////////#*  @@@
ci-example-linter-1  |                         @////@//(///////////@@@@@///@//@/@**//@@(
ci-example-linter-1  |                       @///////@///////////////@@@@    (           @,
ci-example-linter-1  |                      @/(&/@////////////////////                     @
ci-example-linter-1  |                     @////////////////////////@@                      @
ci-example-linter-1  |                   @%////////(//////////%/////&@            @@       *,@           ______________
ci-example-linter-1  |              @@@@@/@/#/////(&//////////////////                       .@         /              \
ci-example-linter-1  |         *@@@@@.    .%///(//@//////////////////&.   .@@,                 @%      / Don't mind me  \
ci-example-linter-1  |       @@%           .&@&&/@.@//&/////(//////////    @@@@@@@@@         .. &@    / I'm just looking \
ci-example-linter-1  |     @@%               @@@@@   @&/////////////////#   @/       V  @@/ ,@@@ @   <  for some trash... |
ci-example-linter-1  | @@@%                   @@@@        .%@@@@//////#@ @   @@         @     .,.     \__________________/
ci-example-linter-1  |                                           @@@/@(  (@@@@% @/\      %
ci-example-linter-1  |                                            @@@@(    .     .@@/\   #
ci-example-linter-1  |                                              @                  %@%
ci-example-linter-1  | 
ci-example-linter-1  | --------------------------------------------------------------------------------
ci-example-linter-1  | 
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   ---------------------------------------------
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   --- GitHub Actions Multi Language Linter ----
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]    - Image Creation Date:[2023-04-10T23:42:21Z]
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]    - Image Revision:[a9fdf79c4a29f2752e1faddc6dafe73a5734fd5c]
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]    - Image Version:[a9fdf79c4a29f2752e1faddc6dafe73a5734fd5c]
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   ---------------------------------------------
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   ---------------------------------------------
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   The Super-Linter source code can be found at:
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]    - https://github.com/github/super-linter
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   ---------------------------------------------
...
ci-example-linter-1  | 2023-09-03 05:57:35 [INFO]   Linting [JAVASCRIPT_ES] files...
ci-example-linter-1  | 2023-09-03 05:57:35 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 05:57:35 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 05:57:35 [INFO]   ---------------------------
ci-example-linter-1  | 2023-09-03 05:57:35 [INFO]   File:[/tmp/lint/webpack-sample/src/hero.js]
ci-example-linter-1  | 2023-09-03 05:57:39 [INFO]    - File:[hero.js] was linted with [eslint] successfully
ci-example-linter-1  | 2023-09-03 05:57:39 [INFO]   ---------------------------
ci-example-linter-1  | 2023-09-03 05:57:39 [INFO]   File:[/tmp/lint/webpack-sample/src/index.js]
ci-example-linter-1  | 2023-09-03 05:57:43 [INFO]    - File:[index.js] was linted with [eslint] successfully
ci-example-linter-1  | 2023-09-03 05:57:43 [INFO]   
...
ci-example-linter-1  | 2023-09-03 05:58:13 [INFO]   The script has completed
ci-example-linter-1  | 2023-09-03 05:58:13 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 05:58:13 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 05:58:13 [NOTICE]   All file(s) linted successfully with no errors detected
ci-example-linter-1  | 2023-09-03 05:58:13 [INFO]   ----------------------------------------------
ci-example-linter-1 exited with code 0
Aborting on container exit...
[+] Stopping 1/0
 ✔ Container ci-example-linter-1  Stopped                                                                                                                      0.0s

エラーがある場合は以下のようになります

./super-linter.sh
[+] Running 1/0
 ✔ Container ci-example-linter-1  Created                                                                                                                      0.0s 
Attaching to ci-example-linter-1
ci-example-linter-1  | --------------------------------------------------------------------------------
...
ci-example-linter-1  | 2023-09-03 06:13:30 [INFO]   File:[/tmp/lint/super-linter.sh]
ci-example-linter-1  | 2023-09-03 06:13:30 [ERROR]   Found errors in [editorconfig-checker] linter!
ci-example-linter-1  | 2023-09-03 06:13:30 [ERROR]   Error code: 1. Command output:
ci-example-linter-1  | ------
ci-example-linter-1  | super-linter.sh:
ci-example-linter-1  |  53: Wrong amount of left-padding spaces(want multiple of 4)
ci-example-linter-1  | 
ci-example-linter-1  | 1 errors found
ci-example-linter-1  | ------
...
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   Linting [SHELL_SHFMT] files...
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   ---------------------------
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   File:[/tmp/lint/super-linter.sh]
ci-example-linter-1  | 2023-09-03 06:14:14 [ERROR]   Found errors in [shfmt] linter!
ci-example-linter-1  | 2023-09-03 06:14:14 [ERROR]   Error code: 1. Command output:
ci-example-linter-1  | ------
ci-example-linter-1  | --- /tmp/lint/super-linter.sh.orig
ci-example-linter-1  | +++ /tmp/lint/super-linter.sh
ci-example-linter-1  | @@ -50,7 +50,7 @@
ci-example-linter-1  |          cp -r "${file}" "${WORKDIR}/${file}"
ci-example-linter-1  |      done
ci-example-linter-1  |  
ci-example-linter-1  | -  cp .editorconfig "${WORKDIR}/.editorconfig"
ci-example-linter-1  | +    cp .editorconfig "${WORKDIR}/.editorconfig"
ci-example-linter-1  |  
ci-example-linter-1  |  }
ci-example-linter-1  |  
ci-example-linter-1  | ------
...
ci-example-linter-1  | 2023-09-03 06:14:17 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:17 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:17 [INFO]   The script has completed
ci-example-linter-1  | 2023-09-03 06:14:17 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:17 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:17 [ERROR]   ERRORS FOUND in EDITORCONFIG:[1]
ci-example-linter-1  | 2023-09-03 06:14:17 [ERROR]   ERRORS FOUND in SHELL_SHFMT:[1]
ci-example-linter-1  | 2023-09-03 06:14:17 [FATAL]   Exiting with errors found!
ci-example-linter-1 exited with code 1
Aborting on container exit...
[+] Stopping 1/0
 ✔ Container ci-example-linter-1  Stopped                                                                                                                      0.0s 
Lint failed. Aborting...

きちんとエラーを検出できています

pre-commit からの実行

シェルスクリプトにしたことで、 pre-commit からも実行できます

pre-commit は名前の通り、コミット前にチェックを自動実行して、エラーがあればコミットさせないツールです

pre-commit のインストール

以下のように pre-commit は Python モジュールとしてインストールできます

pip install pre-commit

asdf を使っている場合はインストール後に asdf reshim python を実行します

pre-commit の設定

ローカルのシェルスクリプトを実行する場合、以下のような設定を .pre-commit-config.yaml に記載します

---
repos:
  - repo: local
    hooks:
      - id: super-linter
        name: 'super-linter'
        entry: ./super-linter.sh
        language: system
        pass_filenames: false

pre-commit の手動実行

 手動で実行する場合は以下のコマンドを実行します

pre-commit run --all-files

問題なければ以下のような実行結果になります

スクリーンショット 2023-09-03 19.38.52.png

エラーが発生した場合は以下のように Super-Linter の実行結果を表示します

スクリーンショット 2023-09-03 19.41.48.png
...
スクリーンショット 2023-09-03 19.41.59.png

pre-commit の自動実行

pre-commit をコミット実行時に自動実行する場合、以下のコマンドを実行しておきます

pre-commit install

hadolint の実行

M1 Mac や M2 Mac で Super-Linter から hadolint が実行できない場合、 pre-commit から実行できます

.pre-commit-config.yaml に以下の設定を追加します

...
  - repo: https://github.com/hadolint/hadolint
    rev: v2.12.0
    hooks:
      - id: hadolint-docker

hadolint-docker としている場合、 Docker コンテナ上で hadolint が実行されます

hadolint のイメージはマルチアーキテクチャになっているため、 M1 Mac 、 M2 Mac でも問題なく動作します

actionlint の実行

M1 Mac や M2 Mac で Super-Linter から actionlint が実行できない場合、 pre-commit から実行できます

.pre-commit-config.yaml に以下の設定を追加します

...
  - repo: https://github.com/rhysd/actionlint
    rev: v1.6.25
    hooks:
      - id: actionlint-docker

actionlint-docker としている場合、 Docker コンテナ上で actionlint が実行されます

actionlint のイメージはマルチアーキテクチャになっているため、 M1 Mac 、 M2 Mac でも問題なく動作します

まとめ

シェルスクリプトで色々な工夫をすることで、変更差分だけに Super-Linter を実行することができました

arm64 アーキテクチャの場合などまだ課題はありますが、おおよそのケースで使えそうです

11
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
7