GitHub Actions を用いて Pull-Request で shellcheck をする方法

## はじめに

(Ba)sh スクリプトを静的解析してくれる便利なツール [shellcheck](https://github.com/koalaman/shellcheck) が、GitHub Actions [標準の実行環境](https://help.github.com/en/articles/software-in-virtual-environments-for-github-actions#ubuntu-1804-lts) から使えるので **Pull-Request に含まれた (Ba)sh を自動的にチェックをする** ワークフローを書きました。

## 実行結果

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32736/ef31abf2-55b2-6295-dec8-b6ff736e9bc3.png)

## ワークフローのサンプル

:warning: 2022-10-25追記： `checkout` 時に `fetch-depth: 0` オプションを使わない方法を思いついたので以下サンプルを更新しました。**2 回 `checkout` している** 理由は [GitHub Actions を用いて Pull-Request で変更されたファイル一覧を取り出す方法](https://qiita.com/osakiy/items/0e18eee444446307b970) に詳細があります。

``` yml:.github/workflows/static_analysis_on_pr.yml
name: Static analysis

on:
  pull_request:
    types: [opened, synchronize]
    paths: '**.sh'

jobs:
  shellcheck:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
      with:
        ref: ${{ github.event.pull_request.base.sha }}
    - uses: actions/checkout@v3
    - name: Check scripts with shellcheck
      env:
        EXCLUDE_OPTS: SC2086,SC2001
      run: |
        git diff ${{ github.event.pull_request.base.sha }}.. \
          --diff-filter=AM --name-only -- '*.sh' | while read script ; do \
            shellcheck ${EXCLUDE_OPTS:+-e ${EXCLUDE_OPTS}} $script || touch error ; \
        done
        test ! -e error
```

## ポイントの紹介

### `on.<push|pull_request>.paths` でマッチした時のみトリガーする

``` yml
on:
  pull_request:
    types: [opened, synchronize]
    paths: '**.sh'
```

今回は (Ba)sh スクリプトのチェックが目的なので、関係のないファイルが更新されたときはワークフローが走らないようにします。push または pull_request では `paths` に **定義されているパターンにマッチするファイルが一つでも含まれている時だけワークフローを実行する** といった制限を設けることができます。

- [workflow-syntax-for-github-actions#onpushpull_requestpaths](https://help.github.com/en/articles/workflow-syntax-for-github-actions#onpushpull_requestpaths)

注意点としては `'*'` は `/` を**含めないため** `paths:'*.sh'` としてしまうと、サブディレクトリのスクリプトを更新してもトリガーされません。このあたりの具体例については、最近公開された以下のチートシートがとても参考になります。

- [workflow-syntax-for-github-actions#filter-pattern-cheat-sheet](https://help.github.com/en/articles/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet)

また、今回のサンプルでは `reopend` の時は実行しないように、`types: [opened, synchronize]` のようにアクティビティタイプに変更しています。こちらは前回の記事の  [types でワークフローのトリガーを細かく制御する](https://qiita.com/osakiy/items/df80db383ce33b82c797#types-%E3%81%A7%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%81%AE%E3%83%88%E3%83%AA%E3%82%AC%E3%83%BC%E3%82%92%E7%B4%B0%E3%81%8B%E3%81%8F%E5%88%B6%E5%BE%A1%E3%81%99%E3%82%8B) が参考になるかもしれません。

### `jobs.<job_id>.steps.uses` で外部のアクションを参照する

``` yml
uses: actions/checkout@v3
```

`uses: {owner}/{repo}@{ref}` という構文で、パブリックリポジトリとして公開されているアクションを参照することができます。

- [configuring-a-workflow#referencing-actions-in-your-workflow](https://help.github.com/en/articles/configuring-a-workflow#referencing-actions-in-your-workflow)

今回は、以下で紹介されている [actions/checkout](https://github.com/actions/checkout) という便利なアクションを用いました。

- [configuring-a-workflow#using-the-checkout-action](https://help.github.com/en/articles/configuring-a-workflow#using-the-checkout-action)

このアクションは名前のとおり、ワークスペースにリポジトリを取得した後にチェックアウトしてくれます。ここでポイントなのが、デフォルトでは `$GITHUB_SHA` にチェックアウトされる点です。Pull-Request における `$GITHUB_SHA` は他のイベントとは異なり、Pull-Request のブランチからベースブランチに Merge した先頭コミットを指します。

- [events-that-trigger-workflows#pull_request](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request)

> `GITHUB_SHA`
> Last merge commit on the GITHUB_REF branch

つまり、ワークスペースで **お試し Merge** をして、その先頭コミットにチェックアウトしてくれます。こうすることで「Pull-Request ではうまくいっていたが、いざ Merge してみたら失敗してしまった」ということが避けられるようになっています。 Merge については Actions の実行時ログからも確認することができます。

``` bash:RUN　actions/checkout@v3
git checkout --progress --force refs/remotes/pull/19/merge
(...)
HEAD is now at 5aaad4d Merge eeb2415444ae2c220766235724e679e624ce12d2
into 31bdf0f35c15a591b0b3cf5afa6a754e18bb02d7
```

### 更新があった `*.sh` ファイルだけを取り出して `shellcheck` をかける

``` yml:
run: |
  git diff ${{ github.event.pull_request.base.sha }}.. \
    --diff-filter=AM --name-only -- '*.sh' | while read script ; do ...
```

前述の `paths: '**.sh'` は **Pull-Request のコミットに 1 つでも `.sh` が含まれていたらトリガーする** という制御なので、`.sh` 以外のファイルが含まれている場合も考えられます。したがって、更新のあった拡張子が `.sh` のファイルだけを改めて取り出そうと思います。まず `git diff ${{ github.event.pull_request.base.sha }}..` では、ベースブランチの `SHA` と `$GITHUB_SHA` の差分を得ます。 `SHA` については以下を参考にしました。

- [webhook-events-and-payloads](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#webhook-payload-example-35)

次に、削除やリネームの時はチェックの対象外としたいので `--diff-filter=AM` で `A` 新規追加と `M` 変更のみをフィルタしています。最後に `--name-only -- '*.sh'` で拡張子が `.sh` にマッチするファイル名を取得し、これらを `shellcheck` の対象としました。

### shellcheck の除外オプション

``` yml
env:
  EXCLUDE_OPTS: SC2086,SC2001
```

[shellcheck を使おう](https://qiita.com/znz/items/63a3d581e8ed6ff11b8e) でも紹介されていますが、実行時に `-e CODE1[,CODE2...]` とすることで、指定したルールをチェックの対象外とすることができます。今回のサンプルでは、[SC2086](https://github.com/koalaman/shellcheck/wiki/SC2086) と [SC2001](https://github.com/koalaman/shellcheck/wiki/SC2001) のルールをチェックの対象外としています。

### 失敗した場合は

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32736/76fd8286-c983-dccd-7a5a-f1edc9a2fa64.png)

Pull-Request 上ではこのように表示されます。 失敗してしまった場合は Details から Actions の実行時ログを参照して、何故エラーしたかを確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32736/3c3895dc-a65f-bd40-f7a1-f562a83126e2.png)

上記の例では [SC2070](https://github.com/koalaman/shellcheck/wiki/SC2070) というルールに沿っていない点を指摘されているため、コードを修正するか、または 対象外のルール登録をする必要があります。他のルールについては [shellcheck/wiki](https://github.com/koalaman/shellcheck/wiki) から確認することができます。

## おわり

今回 GitHub Actions [標準の実行環境](https://help.github.com/en/articles/software-in-virtual-environments-for-github-actions#ubuntu-1804-lts) に [shellcheck](https://github.com/koalaman/shellcheck) が含まれていることに気付いて、このようなチェックの仕組みを作ってみました。 `uses:` から気軽に使えるように、そのうち [Marketplace](https://github.com/marketplace?type=actions) かパブリックなアクションとして公開してみたいと思います。