🛡️

保護ブランチでも paths フィルターを使いたい [GitHub Actions]

2022/07/11に公開

課題

保護ブランチのステータスチェックと paths フィルターは相性が悪いです。
詳しくは下記にまとめてありますが Required にしたステータスがスキップされた場合にマージがブロックされてしまいます。

https://zenn.dev/snowcait/articles/0206264b4aa99a

解決方法

paths フィルターは PR の差分が 300 ファイルを超えたときに無視されてしまう問題も抱えているので使うのは潔く諦めて自前で判定します。

全体像

paths フィルターを使う場合はクライアントやサーバー等の単位でワークフローを分けるかと思いますが、結果の判定に needs を使いたいので 1 つのワークフローにまとめます。

ワークフロー

paths ジョブで対象かを判定して各ジョブの if で実行するかどうかを判断します。
結果は passing ジョブで受け取り、このジョブを保護ブランチの Required に設定します。

.github/workflows/ci.yml
name: CI

on:
  pull_request:

jobs:
  paths:
    runs-on: ubuntu-20.04
    timeout-minutes: 5
    outputs:
      client: ${{ steps.changes.outputs.client == 'true' || steps.changes.outputs.workflow == 'true' }}
      server: ${{ steps.changes.outputs.server == 'true' || steps.changes.outputs.workflow == 'true' }}
      workflow: ${{ steps.changes.outputs.workflow == 'true' }}

    steps:
      - run: cat $GITHUB_EVENT_PATH
      - uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: |
            client:
              - 'client/**'
            server:
              - 'server/**'
            workflow:
              - '.github/workflows/ci.yml'

  client-lint:
    needs: [ paths ]
    if: needs.paths.outputs.client == 'true'
    runs-on: ubuntu-20.04
    timeout-minutes: 5
    defaults:
      run:
        working-directory: client

    steps:
      - uses: actions/checkout@v3
      - run: sh lint.sh

  client-test:
    needs: [ paths ]
    if: needs.paths.outputs.client == 'true'
    runs-on: ubuntu-20.04
    timeout-minutes: 5
    defaults:
      run:
        working-directory: client

    steps:
      - uses: actions/checkout@v3
      - run: sh build.sh
      - run: sh test.sh

  server-lint:
    needs: [ paths ]
    if: needs.paths.outputs.server == 'true'
    runs-on: ubuntu-20.04
    timeout-minutes: 5
    defaults:
      run:
        working-directory: server

    steps:
      - uses: actions/checkout@v3
      - run: sh lint.sh

  server-test:
    needs: [ paths ]
    if: needs.paths.outputs.server == 'true'
    runs-on: ubuntu-20.04
    timeout-minutes: 5
    defaults:
      run:
        working-directory: server

    steps:
      - uses: actions/checkout@v3
      - run: sh test.sh

  workflow-lint:
    needs: [ paths ]
    if: needs.paths.outputs.workflow == 'true'
    runs-on: ubuntu-20.04
    timeout-minutes: 5

    steps:
      - uses: actions/checkout@v3
      - uses: reviewdog/action-actionlint@v1.27.0
        with:
          actionlint_flags: -shellcheck= -pyflakes=

  # Required status checks
  passing:
    needs:
      - client-lint
      - client-test
      - server-lint
      - server-test
      - workflow-lint
    if: failure() == false && contains(needs.*.result, 'cancelled') == false
    runs-on: ubuntu-20.04
    timeout-minutes: 5

    steps:
      - run: echo "$json"
        env:
          json: ${{ toJSON(needs) }}

paths ジョブ

YAML
  paths:
    runs-on: ubuntu-20.04
    timeout-minutes: 5
    outputs:
      client: ${{ steps.changes.outputs.client == 'true' || steps.changes.outputs.workflow == 'true' }}
      server: ${{ steps.changes.outputs.server == 'true' || steps.changes.outputs.workflow == 'true' }}
      workflow: ${{ steps.changes.outputs.workflow == 'true' }}

    steps:
      - run: cat $GITHUB_EVENT_PATH
      - uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: |
            client:
              - 'client/**'
            server:
              - 'server/**'
            workflow:
              - '.github/workflows/ci.yml'

判定をグルーピングして outputs に結果を出力します。
文字列であることに注意してください。

ワークフローが変更された場合はすべてのジョブを実行します。
(本当は変更が加わったジョブだけ実行したいところですがこのあたりはワークフローがまとまってしまっている弊害ですね)

dorny/paths-filter を使っていますが他にも似たようなアクションはあるようです。

https://github.com/dorny/paths-filter
https://github.com/lykahb/paths-filter
https://github.com/tj-actions/changed-files

*-lint, *-test ジョブ

YAML
  client-lint:
    needs: [ paths ]
    if: needs.paths.outputs.client == 'true'
    runs-on: ubuntu-20.04
    timeout-minutes: 5
    defaults:
      run:
        working-directory: client

    steps:
      - uses: actions/checkout@v3
      - run: sh lint.sh

Required の対象にしたいジョブを同じワークフロー内に記述します。
needspaths ジョブを指定し outputs を基に実行するかどうか判断します。

ステップが多くなったりして分けたくなった場合はカスタムアクションReusable workflow を使うと良いでしょう。

*.sh を実行しているところは実際のコマンドに置き換えてください。
ディレクトリ構成はこうなっています。
モノリポだと実際はもっと多くのディレクトリがあるでしょうが簡略化しています。

ディレクトリ構成
> tree
├─.github
│  └─workflows
├─client
└─server

ちなみに lint と test を分けてあるのは以下の理由です。

  • どちらかが失敗しても両方の結果が分かる
  • 並列化して実行時間の短縮
  • Lint は Required に含めないことが可能(差分のみを対象に Lint している場合など)

passing ジョブ

YAML
  # Required status checks
  passing:
    needs:
      - client-lint
      - client-test
      - server-lint
      - server-test
      - workflow-lint
    if: failure() == false && contains(needs.*.result, 'cancelled') == false
    runs-on: ubuntu-20.04
    timeout-minutes: 5

    steps:
      - run: echo "$json"
        env:
          json: ${{ toJSON(needs) }}

保護ブランチの Required に設定するためのジョブです。
needs には本来 Required にしたいジョブを設定します。

失敗がなくタイムアウトまたはキャンセルがない場合に実行されます。
実行されなかった場合は Required の要件を満たさないのでマージがブロックされます。

補足

公式ドキュメントに記載されている方法だとステータスが 2 重になってしまい非常に分かりにくいので採用しませんでした。
この問題は GitHub の方も認識しているようなのでそのうち改善されることを期待しています。

完了通知

先に紹介したワークフローだと passing は成功かスキップだったので失敗時に通知を送りたい場合は別途ジョブを用意する必要があります。
そのためだけにステータスが増えてしまうのは好ましくないので passing で処理する方法です。

ジョブの ifalways() にして通知するステップで成功か失敗を判定します。
失敗時は exit 1 でこのジョブを失敗させます。

最新コミット以外で通知されてもあまり意味がないのでその判定もしてあります。
(Re-run したときは避けられなさそうですが判定する方法あるんでしょうか?)
タイミングによってはすり抜けるのでコメントに SHA も明示しておきます。

あまり必要ない気もしますが passing のログでも失敗したジョブが分かるようにログに出力してあります。

.github/workflows/ci.yml
  # Required status checks and Notification
  passing:
    needs:
      - client-lint
      - client-test
      - server-lint
      - server-test
      - workflow-lint
    if: always()
    runs-on: ubuntu-20.04
    timeout-minutes: 5
    env:
      GH_TOKEN: ${{ github.token }}
      GH_REPO: ${{ github.repository }}
      number: ${{ github.event.number }}
      sha: ${{ github.event.pull_request.head.sha }}  # 厳密には github.sha だが PR 上での分かりやすさを優先する

    steps:
      - run: echo "$json"
        env:
          json: ${{ toJSON(needs) }}
      - name: PR
        id: pr
        run: |
          json=$(gh api repos/${GITHUB_REPOSITORY}/pulls/${number})
          echo "$json" | jq
          echo "::set-output name=json::$json"
      - name: Success or Skipped
        run: gh pr comment $number --body "@${GITHUB_ACTOR} CI passing at ${sha}"
        if: >-
          contains(needs.*.result, 'failure') == false &&
          contains(needs.*.result, 'cancelled') == false &&
          github.event.pull_request.head.sha == fromJSON(steps.pr.outputs.json).head.sha
      - name: Failure or Cancelled
        run: |
          echo '[failure]'
          echo "$needs" | jq -r 'to_entries | map(select(.value.result == "failure")) | .[].key'
          echo '----------'
          echo '[cancelled]'
          echo "$needs" | jq -r 'to_entries | map(select(.value.result == "cancelled")) | .[].key'
          echo '----------'
          gh pr comment $number --body "@${GITHUB_ACTOR} CI failure at ${sha}"
          exit 1
        env:
          needs: ${{ toJSON(needs) }}
        if: >-
          (
            contains(needs.*.result, 'failure') ||
            contains(needs.*.result, 'cancelled')
          ) &&
          github.event.pull_request.head.sha == fromJSON(steps.pr.outputs.json).head.sha

Discussion