ミツカリ技術ブログ

株式会社ミツカリの開発チームのブログです

GithubActionsに標準搭載されていない複雑な実行トリガーを表現する

ミツカリのたなしゅん(@tanashun_dev)です。

GithubActions、皆さん使っていますでしょうか? Pull request(PR)をトリガーにテストを実行したり、プッシュをトリガーにデプロイを実行したりしますね。

ほとんどの場面ではPRが作られたとき、プッシュされたとき、マージされたとき、などのGithubActionsが標準で用意してくれているトリガーで事足りるのですが、今回弊社で実行したいCIの理想的なトリガーの要件が複雑で、それを満たすのに少々苦労したのでその記録を残そうと思います。

要件

それぞれの要件となった背景は後述しますが、忙しい人のために先にどんな要件を満たすワークフローを作ったのかを示します。

  • PRがApproveされたとき
  • ベースブランチがmasterブランチである
  • CIを回す必要のあるファイルに変更があったときのみ
  • 複数人がApproveをつけた場合は最初の1人目のときのみ実行する
  • 一度ApproveされたPRに対して再度コミットを行った場合は過去のApprove数を無効として1人目から数え始める

上記を満たすワークフローを実装していきます。

背景

そもそも上記のような厳しい要件となった背景も記載します。

前提として今回追加しようとしたCIはlocalhost上でのE2Eテストです。 CIマシン上でlocalhostでアプリを起動して、E2Eテストを実行するようなCIです。

PRがApproveされたとき

E2Eテストなので、仮に落ちた場合、マージ後だと多くの場合はリバートすることになります。 リバートの手間を減らすならマージする前にE2Eが通っていることが確認できた方がよさそうです。 また、E2Eテストは実際にブラウザを起動してのテストなので、そこそこ時間がかかります。 そのため、何度も実行するのは非効率ですし、GithubActionsもタダではないのでコスト面も考慮するとPRごとに1回で済ませたいです。

現在の要求であれば、PRのオープンイベントでも満たせそうですね。オープン自体は1回しか発生しないので。

ですが、そもそもレビューが通らなかった場合、修正を行う必要があります。つまり、レビュー前の状態でE2Eが通っていたとしてもやり直す可能性が高いということになります。

以上の理由から、PRがApproveされてから実行する、というのが良さそうとなりました。

ベースブランチがmasterブランチである

これは特に説明の必要もないかもしれませんが、特定のブランチに対するPRでしか実行したくないというものです。 弊社では動くコードはmasterにマージするので、masterに入る変更は動くことを保証する必要があります。 動かないコードを複数回に分けてPRを作ることもありますが、その場合はmasterには直接入れずに中間ブランチへPRを作ることになっています。 動かないとわかっているコードでE2Eを実行する意味はないので、masterのみを対象とします。

CIを回す必要のあるファイルに変更があったときのみ

これもあるあるな要件ですね。 READMEしか変えてないのにE2Eが動くというのはちょっと無駄に感じますよね。時間的にもコスト的にも無駄ですからなるべく排除したいです。

複数人がApproveをつけた場合は最初の1人目のときのみ実行する

これも書いてあるとおりですが、Approveが複数回ついたときにその回数分E2Eが実行されるのは意味がないので排除したい意図です。

一度ApproveされたPRに対して再度コミットを行った場合は過去のApprove数を無効として1人目から数え始める

Approveされたものの、「あ、忘れてた」と変更を入れてしまう経験は誰しもありますよね。 この場合に、一つ前の条件に当てはまってE2Eが実行されないのは困りますね。コードに変更を入れているわけなので、検証し直す必要があります。

実装

前置きが長くなりましたが実装していきます。

まずはPRのApproveで動かすという部分です。

on:
  pull_request_review:
    types:
      - submitted
jobs:
  e2e:
    if: github.event.review.state == 'approved'

GithubActionsではonで基本的なトリガーを書きます。 pull_request_reviewのtypeをsubmittedにすると、レビューが送信されたとき、という条件になります。 これだけだとレビューがApproveではなくCommentだったり、RequestChangesであっても起動してしまいますので、Jobの実行条件にif文でステータスがapprovedであることを入れてあげます。

次にベースブランチがmasterであることの部分です。

こちらを見てください。

on:
  pull_request_review:
    types:
      - submitted
    branches: # ADD
      - master # ADD
jobs:
  e2e:
    if: github.event.review.state == 'approved'

GithubActionsを書いたことのある方であればこう書きたくなると思いますが、これは動作しません。 branchesはpull_requestイベントにはありますが、pull_request_reviewイベントでは使えないのです。 このあたりは使えてもいいような気がしますがなぜか使えません。

そのため以下のようにしてあげることで可能にします。

on:
  pull_request_review:
    types:
      - submitted
jobs:
  e2e:
    if: github.event.review.state == 'approved' && github.event.pull_request.base.ref == 'master' # ADD

Githubのコンテキスト内にある変数からベースブランチは取り出せるので、それを使って判定します。

この手の実装をするときは常に以下のページと睨み合うことになりますね。

docs.github.com

さて、ここまでは簡単でした。 次に、CIを回す必要のあるファイルに変更があったときのみ、を実装します。

これも、以下のように書きたくなることでしょう。

on:
  pull_request_review:
    types:
      - submitted
    paths:
      - 'e2e/**'

pathsを使ったパス指定です。 残念ながらこれもpull_request_reviewイベントにはありません。 単にコンテキストから取得できるものでもないので判定スクリプトを自作するしかありません。

以下のような汎用的なスクリプトを作りました。

#!/bin/bash
cd "$(dirname "$0")"

BASE_BRANCH=$1
HEAD_BRANCH=$2

# include/excludeに記載されたパターンに従って、git diff --name-onlyの引数を生成する
while IFS= read -r pattern || [[ -n "$pattern" ]]; do
  [ -z "$pattern" ] && continue
  PATH_PATTERNS+=("$pattern")
done < "include"

while IFS= read -r pattern || [[ -n "$pattern" ]]; do
  [ -z "$pattern" ] && continue
  # `!` を除去して exclude 形式に変換
  CLEANED_PATTERN="${pattern#!}"
  PATH_PATTERNS+=(":(exclude)$CLEANED_PATTERN")
done < "exclude"

# git diff のパスパターンはrootディレクトリからの相対パスで指定する必要があるため、スクリプトの実行ディレクトリを変更
cd ../../../../ # ここは適宜変えてください
git diff --name-only ${BASE_BRANCH}...${HEAD_BRANCH} -- "${PATH_PATTERNS[@]}"

include/excludeのファイルは e2e/***.ts のようないわゆるgitignoreの記法(pathspec)を使えるようにしています。

このスクリプトをJob内で実行して後続の処理を行うかどうかを分岐させることで実現しました。

さて、続いては、Approveをつけたのが何人目なのかの判定を実装しましょう。

  • 複数人がApproveをつけた場合は最初の1人目のときのみ実行する
  • 一度ApproveされたPRに対して再度コミットを行った場合は過去のApprove数を無効として1人目から数え始める

上記の両方を満たす処理として実装します。

これは以下のようなStepとして実装しました。

steps:
  - id: set_output
    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    run: |
      # このCommitについたレビューの中で、APPROVEDのものを取得
      reviews=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews --jq '[.[] | select(.state=="APPROVED" and .commit_id=="${{ github.event.pull_request.head.sha }}")]')
      # 最初のレビューがこのレビューのIDと一致すれば最初の承認
      first_approval_id=$(echo "$reviews" | jq -r '.[0].id')
      if [ "$first_approval_id" == "${{ github.event.review.id }}" ]; then
        echo "最初の承認です"
        echo "is_first_approval=true" >> $GITHUB_OUTPUT
      else
        echo "2人目以降の承認です"
        echo "is_first_approval=false" >> $GITHUB_OUTPUT
      fi

さすがにこれはコンテキスト情報だけではどうにもならないので、GithubAPIを使って実現しています。 GithubAPIはcurlで叩くこともできますが、GithubのCIランナーにはGithubCLIが標準インストールされていて、ghコマンドが使えるのでそちらを使ったほうが簡単です。

使用したAPIは以下です。

docs.github.com

特定のPRについたレビュー一覧を取得するエンドポイントですね。

こちらから取得された情報をjqを使って検索していきます。

レビューは特定のコミットハッシュ値に紐づけられているので、PRのheadのコミットハッシュ値と一致するレビューを抜き出せば、最新コミットに対するレビューだけに限定できるので、

一度ApproveされたPRに対して再度コミットを行った場合は過去のApprove数を無効として1人目から数え始める

を満たすことができます。

さて、これらをすべてまとめた実装が以下になります。

on:
  pull_request_review:
    types:
      - submitted
jobs:
  check_approval:
    name: Check if this is the first approval for this commit
    if: github.event.pull_request.base.ref == 'master' && github.event.review.state == 'approved' && github.event.pull_request.head.sha == github.event.review.commit_id
    runs-on: ubuntu-latest
    outputs:
      is_first_approval: ${{ steps.set_output.outputs.is_first_approval }}
    steps:
      - name: Check if this is the first approval for this commit
        id: set_output
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # このCommitについたレビューの中で、APPROVEDのものを取得
          reviews=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews --jq '[.[] | select(.state=="APPROVED" and .commit_id=="${{ github.event.pull_request.head.sha }}")]')
          # 最初のレビューがこのレビューのIDと一致すれば最初の承認
          first_approval_id=$(echo "$reviews" | jq -r '.[0].id')
          if [ "$first_approval_id" == "${{ github.event.review.id }}" ]; then
            echo "最初の承認です"
            echo "is_first_approval=true" >> $GITHUB_OUTPUT
          else
            echo "2人目以降の承認です"
            echo "is_first_approval=false" >> $GITHUB_OUTPUT
          fi
  check_diff:
    name: Whether the change requires running E2E tests
    needs:
      - check_approval
    if: needs.check_approval.outputs.is_first_approval == 'true'
    runs-on: ubuntu-latest
    outputs:
      should_run_e2e: ${{ steps.check_diff.outputs.should_run_e2e }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Check if the changed files require running E2E tests
        id: check_diff
        run: |
          # 比較ブランチを取得
          git fetch origin master
          git fetch origin ${{ github.event.pull_request.head.ref }}
          DIFF=$(./.github/workflows/scripts/e2e_filter/check_diff.sh origin/master origin/${{ github.event.pull_request.head.ref }})
          # DIFFが空でなければE2Eテストを実行する
          if [ -n "$DIFF" ]; then
            echo "E2Eテストを実行します"
            echo "should_run_e2e=true" >> $GITHUB_OUTPUT
          else
            echo "機能に影響のある変更がないのでE2Eテストは実行しません"
            echo "変更が認識されていない場合は .github/workflows/scripts/e2e_filter/check_diff.shの内容を確認してください"
            echo "should_run_e2e=false" >> $GITHUB_OUTPUT
          fi
  e2e:
    name: Execute e2e on localhost
    needs:
      - check_diff
    if: needs.check_diff.outputs.should_run_e2e == 'true'
    timeout-minutes: 30
    runs-on: ubuntu-latest

いかがでしたか?複雑なワークフロートリガーに苦戦されている方の参考になれば幸いです。


現在、ミツカリではITエンジニアを募集しています。興味のある方はぜひお気軽にご連絡ください!

herp.careers