Jenkins Pipelineにて特定のディレクトリに変更があった場合にのみビルドする

## 概要

レポジトリ内にドキュメントがあるなどのケースにおいて、ドキュメントの更新時にJenkinsのJobが実行されるとビルドマシンのコストを無駄に消費してしまう。
そのためJenkins Pipelineにて特定のディレクトリに変更があった場合にのみビルドする方法が求められるが、具体的にその方法がまとまっているページが見つけられなかったためここにまとめる。

## PathRestrictionについて

JenkinsPipelineの`checkout`には`PathRestriction`というクラスがあり、そこで`includedRegions`や`excludedRegions`を指定することで特定のファイルに差分があった(なかった)時のみビルドすることができる。
これは`Pipeline Syntax`のページで`Sample Step:checkout -> SCM: git -> 追加処理: Polling ignores commits in certain paths`を選択することでも確認できる。

しかしこの文法はMultibranch Pipelineにおいて[使えない](https://issues.jenkins.io/browse/JENKINS-36195)。

Multibranch Pipelineというものが具体的に何なのかは未調査。
少なくとも私の環境では以下の条件があるため使用不可能だと思われる。

1. Jenkinsfileがあるレポジトリとビルド対象のレポジトリが別
    - ジョブをトリガーするかどうかの判定はJenkinsfileのあるレポジトリにしか適応できない可能性がある。
      これはジョブがトリガーされなければcheckout自体がされないためだと推測している。
2. マスターマシンとスレーブマシンが別
    - 以前`library`について検証した際に問題になったがJenkinsfileはマスターマシンに展開されるため、スレーブマシンからは参照できなかった。
        - もちろんスレーブ側で明示的にcheckoutすることで参照すること自体は可能。
    - このことが影響している可能性があるが、1の問題の方が大きいと推測する。

## 具体的な方法

1. 前回のビルド時のコミットハッシュと今回のコミットハッシュを取得する
    - checkoutの引数に各パラメータがあるためこれを利用する。
      ```groovy
      map_vars = checkout( ... )
      print map_vars
      ```
        - 内容は以下のものだった
          `{GIT_BRANCH=origin/master, GIT_COMMIT=[ハッシュ], GIT_PREVIOUS_COMMIT=[ハッシュ], GIT_PREVIOUS_SUCCESSFUL_COMMIT=[ハッシュ], GIT_URL=[url]}`
        - 参考 : https://issues.jenkins.io/browse/JENKINS-35230?focusedCommentId=309355&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#comment-309355
    - 以下のようにすることで環境変数にパラメータを含めることができる。
      ```groovy
      env.GIT_COMMIT = map_vars.get('GIT_COMMIT')
      env.GIT_PREVIOUS_COMMIT = map_vars.get('GIT_PREVIOUS_COMMIT')
      //前回の成功ビルドの場合は以下のものを使う
      //env.GIT_PREVIOUS_COMMIT = map_vars.get('GIT_PREVIOUS_SUCCESSFUL_COMMIT')
      ```
2. 変更のあったファイルの一覧を取得する
    - `git diff-tree --no-commit-id --name-only -r {ハッシュ1} {ハッシュ2}`とすることでファイルの一覧を取得できる。
      (`bat`でのみ確認したが、shなどでも可能であるはず)
      ```groovy
      env.CHANGE_FILES = bat(returnStdout: true, script: """
      @git diff-tree --no-commit-id --name-only -r ${env.GIT_PREVIOUS_COMMIT} ${env.GIT_COMMIT}
      """)
      ```
3. 対象のディレクトリに変更があった場合はビルドする。
    - 今回実装したのは微妙に意味が違うが、対象のディレクトリに変更がなければABORTすること。
    - 以下のfunctionを呼び出すことで対象のディレクトリに変更がなければ`ABORT`できるようになる。
      ```groovy
      CheckChanges = {
        includes = [
          "hoge/",
          "sub", // submoduleの場合はパス区切りを入れないようにする
        ]
        for (file in env.CHANGE_FILES.split("\n")) // 1行(ファイル)毎に確認する
        {
          print "### ${file} ###"
          for (x in includes) {
            print "- ${x}"
            if (file.startsWith(x)) return // 対象のディレクトリに変更があったのでそのままreturn
          }
        }
      
        // returnされずに到達した == 対象のファイルに変更がなかった
        currentBuild.result = 'ABORTED'
        error('SkipBuild')
      }
      ```
    - 補足
        - `ABORTED`を選択したのはビルド失敗の`FAILURE`と区別するため。
        - `error`の単独呼び出しだと`FAILURE`になってしまうので`ABORTED`の設定は必須
        - [NOT_BUILT](https://github.com/jenkinsci/jenkins/blob/cb64da57f4fa82ebe26e1fd074e65faefe36efd8/core/src/main/java/hudson/model/Result.java#L71)の方が適切なように思えるが、手元で試したところ`FAILURE`になってしまったため断念。
4. 3を呼び出す処理を挟むことで特定のディレクトリに変更があった場合にのみビルドするということが可能になる。
  ```groovy
      stage('check changes') { steps { CheckChanges() } }
  ```

### 今回使用したスクリプト全体

```groovy
CheckChanges = {
  includes = [
    "hoge/",
    "sub", // submoduleの場合はパス区切りを入れないようにする
  ]
  for (file in env.CHANGE_FILES.split("\n"))
  {
    print "### ${file} ###"
    for (x in includes) {
      print "- ${x}"
      if (file.startsWith(x)) return
    }
  }

  currentBuild.result = 'ABORTED'
  error('SkipBuild')
}

pipeline {
  stages {
    stage('Clone') { steps { script {
        map_vars = checkout( ... )
        env.GIT_COMMIT = map_vars.get('GIT_COMMIT')
        //env.GIT_PREVIOUS_COMMIT = map_vars.get('GIT_PREVIOUS_COMMIT')
        env.GIT_PREVIOUS_COMMIT = map_vars.get('GIT_PREVIOUS_SUCCESSFUL_COMMIT')
        env.CHANGE_FILES = bat(returnStdout: true, script: """
        @git diff-tree --no-commit-id --name-only -r ${env.GIT_PREVIOUS_COMMIT} ${env.GIT_COMMIT}
        """)
    } } }
    stage('check changes') { steps { CheckChanges() } }
  }
}
```