Github Actions初心者がドキュメントをチェックする仕組みを作成した

## はじめに

最近社内で公式なドキュメントを管理する仕組みを作っていて、GitHub Pagesを使っています。
ドキュメントを編集する際にGitHubのPRを提出しています。
ドキュメントの編集するための開発環境があり、Docker内であればドキュメントのチェックをされる仕組みが存在しています。
ただし、開発環境のセットアップなしでドキュメントが編集されたら、ドキュメントは汚れてしまう恐れがあります。
そのためにGitHub Actionsを使って、PR作成時ドキュメントをチェックする仕組みを作ろうとしました。
初めてGitHub Actionsを使ったというのもあり、勉強になったことなど記録するようにします。

## チェックの内容

現時点で以下のチェックをしています。もし今後新しいチェックなど追加あり、勉強になったことがあれば、
この記事にどんどん追加します。

- ファイルの最後に新しい行があるか
- 改行コードはLFなのか
- markdownlintでのチェック
- textlintでのチェック
- cspellでのチェック

### ファイルの最後に新しい行があるか

以下の記事によれば、ファイルの最終行に改行がない場合、修正と無関係の差分がpull request上に出て、レビュアーの負荷になる場合があります。

https://qiita.com/hamacccccchan/items/11c17c7412a5aeb2ad74

Dockerイメージ内の開発はVscodeを使っています。「Files: Insert Final Newline」にチェックを入れています。
一律最終行に改行あることを統一したいため、チェックを入れました。

### 改行コードはLFなのか

Dockerイメージの中でドキュメントの編集をしていれば、改行コードはLFになります。ただ、普段開発する際には基本的にWindowsを使っているため、その環境でファイルを編集するとファイルが意図せずCRLFになってしまうことがあります。
そのためにチェックを追加しました。

### markdownlintでのチェック

ドキュメントは全部markdownで書かれています。マークダウンの記法に合っているのかをチェックするため、
markdownlintを使っています。

https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint

### textlintでのチェック

textlintとはLintと呼ばれる静的解析ツールで、テキストファイル（.txt）やMarkdownファイル（.md）等を対象に、校正ルールにもとづいて文章校正を行うツールです。
今回はマークダウンをチェックするためにtextlintを使っています。

https://ics.media/entry/220404/

### cspellでのチェック

cspellはスペルミスをチェックするツールです。日本語のチェックはできませんが、typescriptのソースの中のスペルミスをチェックするなどのため使っています。

## チェック方法

### Github Actions

Github Actionsの内容はは以下です。

```yml
on: [pull_request]
jobs:
  file-check:
    name: runner / file-check
    runs-on: ubuntu-latest
    steps:
      - name: Checnout base branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.base.ref }}

      - name: Checkout Pull Request
        uses: actions/checkout@v4

      - name: Check final new line exists
        run: bash ./.github/scripts/check-newline.sh

      - name: Check EOL is lf
        run: bash ./.github/scripts/check-lf.sh

      - name: Run yarn install
        run: yarn

      - name: Run markdownlint
        run: |
          echo "::add-matcher::.github/problem_matcher/markdownlint-matcher.json"
          yarn lint:md
        continue-on-error: true

      - name: Run textlint
        run: |
          echo "::add-matcher::.github/problem_matcher/textlint-matcher.json"
          yarn lint:text
        continue-on-error: true

      - name: Run cspell
        run: |
          echo "::add-matcher::.github/problem_matcher/cspell-matcher.json"
          yarn cspell
```

Pull requestがあったらリポジトリをチェックアウトし、順番通りに各チェックしていくイメージです。
そして、静的チェックはエラーがあっても処理が継続できるように`continue-on-error`のオプションを使いました。

### ファイルの最後に新しい行があるかのチェック方法

以下のshellコマンドを、Github Actionsから実行するようにしました。

```shell

#!/bin/bash
set -eux

git config --local core.quotepath false

no_check_extension_array=("jpeg" "jpg" "png" "svg" "ico")

git diff --diff-filter=d --name-only origin/${GITHUB_BASE_REF}..${GITHUB_SHA} |
    while read -r file; do
        extension="${file##*.}"
        if printf '%s\n' "${no_check_extension_array[@]}" | grep -qx "$extension"; then
            continue
        fi
        if [ -n "$(tail -c 1 "$file")" ]; then
            echo "[ERROR] File $file has no newline at the end of file"
            exit 1
        fi
    done
```

git diffでbaseブランチとの修正差分を取得し、確認します。
テキストファイルのみ最終行は改行なのかチェックします。
（テキストファイルの種類がかなり多いため、テキストファイルファイルではないものを除外するようにしました。）

ドキュメントのファイルパスに日本語が含まれることquoteされて、github actionsの実行時ファイルが見つかりません。
そのためこの一行でquoteしないように設定を変えています。

#### Check Out

Github Actions上、リポジトリをcheckoutし、利用するのは以下になると思います。
```yml
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
```
ただし、この方法だとコミットが増えてくると遅くなる大きなデメリットがあるため、あまりいい方法ではありません。別の方法があるのか試しました。

checkout時、github.event.pull_request.base.shaとgithub.event.pull_request.head.shaをチェックし、git diffで該当のshaチェックするのを試したのですが、意図しない差分が入るため結果的に使いませんでした。

結果的には以下の記事を参考に実装しました。

https://qiita.com/nade/items/3a5fbd77e2bc7df8a48a

`github.event.pull_request.base.ref`と`GITHUB_BASE_REF`、`github.sha`と`GITHUB_SHA`は同じハッシュ値であるのを確認しています。

|  変数名  |  説明  |
| ---- | ---- |
|GITHUB_BASE_REF|ワークフローの実行における base ref の名前または pull request のターゲット ブランチ。 これは、ワークフローの実行をトリガーするイベントが pull_request か pull_request_target である場合にのみ設定されます。 たとえば、main のようにします。|
|GITHUB_SHA|ワークフローをトリガーしたコミット SHA。 このコミット SHA の値は、ワークフローをトリガーしたイベントによって異なります。|

https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables

Github Actionsの実装的にもbaseブランチしか追加で取得していないです。
```yml
      - name: Checnout base branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.base.ref }}
```

#### ファイルパス

shellコマンドの最初のところで以下のコマンドを使っています。

```shell
git config --local core.quotepath false
```

#### git diff

本体のgitコマンドはこちらです。

```shell
git diff --diff-filter=d --name-only origin/${GITHUB_BASE_REF}..${GITHUB_SHA}
```

削除されたファイルはチェックしなくていいですので、--diff-filter=dオプションを使ったコマンドの結果から除外しています。
実際にチェックしたいのは差分のファイルだけですので、--name-onlyオプションを使って、変更されたファイル名だけ表示するようにしています。

### 改行コードはLFなのかのチェック方法

ファイルの最後に新しい行があるかのチェックと同様に、shellコマンドを使ってチェックしています。
以下の通りです。

```shell
#!/bin/bash
set -eux

git config --local core.quotepath false

no_check_extension_array=("jpeg" "jpg" "png" "svg" "ico") 

git diff --diff-filter=d --name-only origin/${GITHUB_BASE_REF}..${GITHUB_SHA} |
    while read -r file; do
        extension="${file##*.}"
        if printf '%s\n' "${no_check_extension_array[@]}" | grep -qx "$extension"; then
            continue
        fi
        if [ "$(awk -v RS='\r\n' 'END{print NR}' "$file")" -ne 1 ]; then
            echo "[ERROR] Please commit $file with LF instead of CRLF!"
            exit 1
        fi
    done
```

git diffでbaseブランチとの修正差分を取得し、確認します。
テキストファイルのみ改行コードがLFなのかチェックします。

その他、テキストファイル以外はLF固定にするため、`.gitattributes`というファイルも追加しました。
リポジトリに含まれる可能性のあるテキストファイルではないものは除外しています。

```text
# Set the default behavior, in case people don't have core.autocrlf set.
* text eol=lf
*.jpeg -text
*.jpg -text
*.png -text
*.svg -text
*.ico -text
```

https://git-scm.com/docs/gitattributes

### markdownlintでのチェック方法

実装上以下のものしかないのですが、説明します。

```yml
      - name: Run yarn install
        run: yarn

      - name: Run markdownlint
        run: |
          echo "::add-matcher::.github/problem_matcher/markdownlint-matcher.json"
          yarn lint:md
        continue-on-error: true
```
#### yarn

今のリポジトリ上、yarnを使ってパッケージ管理しています。
yarnについては以下のリンクをご参照ください。（npmと似ている）

https://qiita.com/akitaaa/items/c97ff951ca31298f3f24

markdownlint関連のコマンドはpackage.jsonに定義され、モジュールをインストールするのに
```shell
yarn
```
の一行でできます。

#### markdownlintのproblem matcher

lintのチェック結果を可視化をするのにGithubのproblem matcherという仕組みがあります。
エラー内容をjsonファイルの定義によってpull requestに表示させることができます。
以下のイメージです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3335050/92efc0cf-bf61-8647-19c7-fa8f86c4d174.png)

problem matcherのjsonファイルの定義は以下の通りです。
（実際にログに出るエラーの内容に基づいて、regexpの正規表現の定義を調整するイメージです。）

```json
{
  "problemMatcher": [
    {
      "owner": "markdownlint-matcher",
      "pattern": [
        {
          "regexp": "^([^:]*):(\\d+):?(\\d+)?\\s([\\w-\\/]*\\s.*)$",
          "file": 1,
          "line": 2,
          "column": 3,
          "message": 4
        }
      ]
    }
  ]
}
```

参考記事

https://github.com/actions/toolkit/blob/main/docs/problem-matchers.md

https://qiita.com/nanakenashi/items/1797bedfa343833173f2

#### yarn lint:md

このコマンドについてはpackage.jsonのscriptsに以下の定義があるためです。
実際にはpackage.jsonに記載されたコマンドが実行されます。

```json
"lint:md": "markdownlint-cli2 'src/**/*.md'"
```

### textlintでのチェック方法

markdownlintと同様に、problem matcherの仕組みを使っています。

#### textlintのproblem matcher

textlintのproblem matcherのjsonは以下の通りです。

```json
{
  "problemMatcher": [
    {
      "owner": "textlint-matcher",
      "severity": "error",
      "pattern": [
        {
          "regexp": "^([^\\s].*)$",
          "file": 1
        },
        {
          "regexp": "^\\s+(\\d+):(\\d+)\\s+✓*\\s*(error|warning|info)\\s+(.*)\\s\\s+(.*)$",
          "line": 1,
          "column": 2,
          "severity": 3,
          "message": 4,
          "code": 5,
          "loop": true
        }
      ]
    }
  ]
}
```

#### yarn lint:text

package.jsonに以下の定義があり、textlintのチェックが実行されます。

```json
"lint:text": "textlint 'src/**/*.md'"
```

### cspellでのチェック方法

markdownlintと同様に、problem matcherの仕組みを使っています。

#### cspellのproblem matcher

cspellのproblem matcherのjsonは以下の通りです。

```json
{
  "problemMatcher": [
    {
      "owner": "cspell",
      "pattern": [
        {
          "regexp": "^([^:]*):(\\d+):?(\\d+)?\\s(.*)$",
          "file": 1,
          "line": 2,
          "column": 3,
          "message": 4
        }
      ]
    }
  ]
}
```

#### yarn cspell

package.jsonに以下の定義があり、cspellのチェックが実行されます。

```json
"cspell": "cspell --no-progress '**/*'"
```

#### cspell.json

製品名など、英語として正しくないが、ドキュメントに含まれているため、cspellのチェック対象から除外したい場合があると思います。
その時はcspell.jsonを使います。
cspell.jsonの`ignoreWords`に入れれば、チェックの対象から除外できます。

https://cspell.org/configuration/

### ジョブの並列実行

今は複数のコマンドやチェックする内容を一つのジョブにまとめたのですが、
最初そうではなかったです。
当初の思いはチェック内容が違うため、ジョブがわかればわかりやすいと思ったのですが、実際に使う人がエラーの内容さえわかれば別にジョブに一つがいいと思いました。
後、Github actionsは各ジョブが分かれていて、リソースの共有していないです。そのため、ジョブが分かれていれば、各ジョブでのcheckoutとyarnになってしまいます。無駄にnode_modulesをインストールするのがよくありませんので、最終的には一つのジョブにまとめて、`continue-on-error`をtrueにしてエラーになっても実行できるようにしました。

少し紹介です。最終的に使ってはいませんが、Github Actionsのジョブマトリックスを試しました。
複数ジョブを同時に実行することができます。

https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow

## 最後に

以上がドキュメントチェックする仕組みになります。
皆さんのご参考になれれば幸いです！