はじめに
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 で以下のように表示される変更差分があるとします
そのとき、 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
問題なければ以下のような実行結果になります
エラーが発生した場合は以下のように Super-Linter の実行結果を表示します
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 アーキテクチャの場合などまだ課題はありますが、おおよそのケースで使えそうです