GitHub Actions: paths-filterを使った変更ファイルベースのJobs/Steps実行制御


GitHub Actionsワークフローにおける実行トリガーは特定ファイル（群）に限定したものであれば、`on.<push|pull_request>.paths`([ref](https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions))である程度絞ることが可能ではあるが、複数JobsやStepsを組み合わせた複雑なワークフローを組む場合にはどうしてもスコープを広く取る必要がでてくる。 そのようなケースにおいても後続のJobやStepで、変更されたファイル（ファイル群 or ファイルの種類、etc）に基づいて細やかに実行制御をしたいことがある。 

```yaml
on:
  pull_request:
    branches:
      - master
    types: [opened, synchronize, closed]
    paths:
      - 'path1/test.js'
      - 'path2/**.js'
```

ここでは[paths-filter](https://github.com/dorny/paths-filter)アクションを使ったPull Requestトリガーのワークフローにおける変更ファイルベースの細やかなJobs/Stepsの実行制御方法について簡単に紹介する。

## paths-filterとは？

Pull Request、Branchなどにpushされたファイルに基づいてワークフローのstepやjobの細やかな実行制御を可能にするGitHub Actions。

- https://github.com/dorny/paths-filter

READMEのサンプルコードをそのままコピペだが、このように`dorny/paths-filter@v2`をuses指定して、`with.filters`で限定したpath（ここではsrc）を指定することで配下のファイルに変更があったかどうかの判定結果を後続のstepにおいて、`steps.<step-id>.outputs.src`で取得できる。

```yaml
- uses: dorny/paths-filter@v2
  id: changes
  with:
    filters: |
      src:
        - 'src/**'

  # run only if some file in 'src' folder was changed
- if: steps.changes.outputs.src == 'true'
  run: ...
```

## Jobsレベルでの実行制御

複数Jobで構成されるワークフローにおけるpaths-filterを使った実行制御について。ここでは、次のようなinfra job実行後にapp1〜3 jobが並列開始されるワークフローを題材とする。

```
            ┌──> app1 job
            │
infra job ──├──> app2 job
            │
            └──> app3 job
```

以下のサンプルでは、paths-filterを活用してapp1〜3に該当するそれぞれのソース (`src/app[1-3]/**`)に変更があったかどうかを確認し、変更があったと判断されたJobのみを実行する。 
具体的には、 infra jobとapp[1-3] jobの間に、change jobを挟んで、このchange jobにおいてpaths-filterを使って各jobに該当するコードの変更を確認している。例えば`src/app1/**`配下のコードに変更があると `steps.filter.outputs.app1`にtrueが格納されるので、それをchanges jobのoutputs (`outputs.app`)として設定している。 このoutputsは後続のapp1 jobでは`needs.changes.outputs.app1`として参照可能なので、if文でこの値がtrueの場合にのみ同Jobのstepsを実行するように条件判断している。

```yaml
name: paths-filter job level conditional execution
on:
  pull_request:
    branches:
      - "master"
    types: [opened, synchronize, closed]

jobs:
  infra:
    runs-on: ubuntu-latest
    steps:
    - run: echo "do infra job"

  changes:
    runs-on: ubuntu-latest
    needs: infra
    outputs:
      app1: ${{ steps.filter.outputs.app1 }}
      app2: ${{ steps.filter.outputs.app2 }}
      app3: ${{ steps.filter.outputs.app3 }}
    steps:
    - uses: actions/checkout@v2
    - uses: dorny/paths-filter@v2
      id: filter
      with:
        filters: |
          app1:
            - 'src/app1/**'
          app2:
            - 'src/app2/**'
          app3:
            - 'src/app3/**'
  app1:
    needs: changes
    if: ${{ needs.changes.outputs.app1 == 'true' }}
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - run: echo "do app1 job"

  app2:
    runs-on: ubuntu-latest
    needs: changes
    if: ${{ needs.changes.outputs.app2 == 'true' }}
    steps:
    - uses: actions/checkout@v2
    - run: echo "do app2 job"
 
  app3:
    runs-on: ubuntu-latest
    needs: changes
    if: ${{ needs.changes.outputs.app3 == 'true' }}
    steps:
    - uses: actions/checkout@v2
    - run: echo "do app3 job"

  other:
    runs-on: ubuntu-latest
    needs: changes
    steps:
    - uses: actions/checkout@v2
    - run: echo "do other job"
```

以下は、`src/app1/hoge.txt`ファイルの更新を契機に起動されたワークフローのSummaryページである。app1のみが実行され、他のapp2、app3はスキップされていることが分かる。

![paths-filter-job-level.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103420/ca30161f-03b6-092d-caa4-a370f448814c.png)



## Stepsレベルでの実行制御

ここでは、Stepsレベルの実行制御例を紹介する。 次のようなinfra job実行後にapp jobが開始され、同一Job内においてapp1〜app3 stepが順番に実行されるワークフローを題材とする。

```
<<app job>>
app1 step ──> app2 step ──> app3 step
```

以下のサンプルでは、app job内で、paths-filterを活用してapp1〜3に該当するそれぞれのソース (`src/app[1-3]/**`)に変更があったかどうかを確認するstep ( id:`filter`)を設定して、変更があったと判断されたstepのみを実行する。 
例えば`src/app1/**`配下のコードに変更があると `steps.filter.outputs.app1`にtrueが格納されるのでこれを後続のapp1 stepでif文によりtrueかどうかを判定してtrueである場合にのみそのstepを実行する。

```yaml
name: paths-filter step level conditional execution
on:
  pull_request:
    branches:
      - "master"
    types: [opened, synchronize, closed]

jobs:
  infra:
    runs-on: ubuntu-latest
    steps:
    - run: echo "do infra job"

  app:
    runs-on: ubuntu-latest
    needs: infra
    steps:
    - uses: actions/checkout@v2
    - uses: dorny/paths-filter@v2
      id: filter
      with:
        filters: |
          app1:
            - 'src/app1/**'
          app2:
            - 'src/app2/**'
          app3:
            - 'src/app3/**'
    - if: steps.filter.outputs.app1 == 'true'
      run: echo "do app1 step"
    - if: steps.filter.outputs.app2 == 'true'
      run: echo "do app2 step"
    - if: steps.filter.outputs.app3 == 'true'
      run: echo "do app3 step"
    - run: echo "do other step"
```

以下は、`src/app1/hoge.txt`ファイルを更新を契機に起動されたワークフローのSummaryページである。app1 ｓtepのみが実行され、他のapp2、app3はスキップされていることが分かる。

![paths-filter-step-level.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103420/e2928828-b6f8-67e1-74c6-02612c3239e0.png)


なお、paths-filterで判断可能なソースの変更は下記のように`added`, `modified`, または`deleted`を使って変更の種類を絞り込むことが可能である。例えば、削除を含まない場合は `added|modified`のように指定すればよい。

```
    - uses: dorny/paths-filter@v2
      id: filter
      with:
        filters: |
          app1:
            - added|deleted|modified: 'src/app1/**'
          app2:
            - added|deleted|modified: 'src/app2/**'
          app3:
            - added|deleted|modified: 'src/app3/**'
```

他にも、paths-fileterにはいろいろなオプションが利用可能なので、下記Exampleを一読いただけれと思う。
- https://github.com/dorny/paths-filter#examples