保護ブランチでも paths フィルターを使いたい [GitHub Actions]
課題
保護ブランチのステータスチェックと paths
フィルターは相性が悪いです。
詳しくは下記にまとめてありますが Required にしたステータスがスキップされた場合にマージがブロックされてしまいます。
解決方法
paths
フィルターは PR の差分が 300 ファイルを超えたときに無視されてしまう問題も抱えているので使うのは潔く諦めて自前で判定します。
全体像
paths
フィルターを使う場合はクライアントやサーバー等の単位でワークフローを分けるかと思いますが、結果の判定に needs
を使いたいので 1 つのワークフローにまとめます。
ワークフロー
paths
ジョブで対象かを判定して各ジョブの if
で実行するかどうかを判断します。
結果は passing
ジョブで受け取り、このジョブを保護ブランチの Required に設定します。
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
を使っていますが他にも似たようなアクションはあるようです。
*-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 の対象にしたいジョブを同じワークフロー内に記述します。
needs
に paths
ジョブを指定し 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
で処理する方法です。
ジョブの if
を always()
にして通知するステップで成功か失敗を判定します。
失敗時は exit 1
でこのジョブを失敗させます。
最新コミット以外で通知されてもあまり意味がないのでその判定もしてあります。
(Re-run したときは避けられなさそうですが判定する方法あるんでしょうか?)
タイミングによってはすり抜けるのでコメントに SHA も明示しておきます。
あまり必要ない気もしますが passing
のログでも失敗したジョブが分かるようにログに出力してあります。
# 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