GitHub Actionsで特定のパスのファイルだけApprove数を増やしたい

自社のDB操作系のPRについてはApproveを2つ以上という制約があり、最初ブランチプロテクションでやろうと思ったのだが、マージ先に対するルールしかかけないことでやりたいことをやるには多段マージが必要になり、それはあまりに手間なので、Actionsでやった。このActionをマージ条件に加えておけば、指定したパスのファイルの変更があった場合はApproveを定義した数以上もらうことを強制できる。

name: Require Two Approvals for Protected Paths

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review, edited]
  pull_request_review:
    types: [submitted]

permissions:
  contents: read
  pull-requests: read

jobs:
  require-two-approvals:
    # Draft PRは対象外
    if: github.event.pull_request.draft == false
    runs-on: ubuntu-latest
    env:
      # 監視対象のパス(正規表現)。例: db/migrations配下
      PROTECTED_PATTERN: ^db/migrations
      # 必要な最小承認数
      REQUIRED_APPROVALS: "2"

    steps:
      # (任意)レポジトリのチェックアウトが必要な場合だけ
      - name: Checkout
        uses: actions/checkout@v5

      - name: Detect if protected paths changed
        id: diff
        uses: actions/github-script@v7
        with:
          # 正直これはgit diffでもいい
          script: |
            const pattern = new RegExp(process.env.PROTECTED_PATTERN || '');
            const {owner, repo, number} = context.issue;
            const per_page = 100;
            let page = 1;
            const changed = [];

            while (true) {
              const {data} = await github.rest.pulls.listFiles({
                owner, repo, pull_number: number, per_page, page
              });
              if (!data.length) break;
              changed.push(...data.map(f => f.filename));
              if (data.length < per_page) break;
              page++;
            }

            core.info(`Changed files:\n${changed.join('\n') || '(none)'}`);
            const touched = changed.some(f => pattern.test(f));
            core.setOutput('touched', String(touched));

      - name: Count current approvals (latest state per user)
        if: steps.diff.outputs.touched == 'true'
        id: approvals
        uses: actions/github-script@v7
        with:
          script: |
            const {owner, repo, number} = context.issue;
            const per_page = 100;
            let page = 1;
            const latest = new Map(); // userId -> latest review state

            while (true) {
              const {data} = await github.rest.pulls.listReviews({
                owner, repo, pull_number: number, per_page, page
              });
              if (!data.length) break;
              for (const r of data) {
                // 各レビュアーの「最新状態」で上書き(APPROVED / CHANGES_REQUESTED / COMMENTED / DISMISSED)
                latest.set(r.user.id, r.state);
              }
              if (data.length < per_page) break;
              page++;
            }

            const approvals = [...latest.values()].filter(s => s === 'APPROVED').length;
            core.notice(`Approvals for protected path changes: ${approvals}`);
            core.setOutput('approvals', String(approvals));

      - name: Enforce minimum approvals for protected paths
        if: steps.diff.outputs.touched == 'true'
        run: |
          echo "Approvals: ${{ steps.approvals.outputs.approvals }} / required: ${REQUIRED_APPROVALS}"
          if [ "${{ steps.approvals.outputs.approvals }}" -lt "${REQUIRED_APPROVALS}" ]; then
            echo "❌ This PR changes protected paths and requires at least ${REQUIRED_APPROVALS} approvals."
            exit 1
          else
            echo "✅ Enough approvals for protected path changes."
          fi

書くまで知らなかったんだけど、

pull_request_review:
  types: [submitted]

を使うと、レビューコメントにフックできるから便利だった。