GitHub Actionsワークフローの図。中心にチェックリストを持つキャラクターが立ち、DependabotのPR免除におけるrequired status checkの課題と、botなら早期に成功終了させる解決策が描かれている。

はじめに

Reusable GitHub Actions workflowでPRのタイトルや本文の書式を検査するjobを組んでいると、必ずと言っていいほど直面する問題があります。Dependabotのようなbotが作るPRは本文を自分たちの都合で書けないため、検査対象から外したい、というものです。

一見単純そうに見えるこの要件ですが、素直に「呼び出し元でjobをif条件でskipする」という実装をすると、branch protectionでrequired status checkに登録しているリポジトリではPRが永久にマージ不能になる、という手痛い落とし穴にはまります。この記事では、その原因と、判定条件のもう一つの落とし穴、そして「実行はするが免除対象なら早期に成功で終わらせる」という解決パターンを整理します。

こんな人におすすめ

  • Reusable workflowでPR検査の共通化を進めている方
  • Dependabotなどbotが作るPRだけ検査ルールを変えたいと考えている方
  • branch protectionのrequired status checkまわりで「PRがマージできない」に悩んだことがある方
  • github.actor と github.event.pull_request.user.login の違いを整理したい方

required status checkのjobをif条件でskipしてはいけない

最初にやりがちな実装は、呼び出し元(caller)のworkflowで次のように書いてbotのPRだけjob自体をskipする方法です。

jobs:
  pr-lint:
    if: github.actor != 'dependabot[bot]'
    uses: ./.github/workflows/pr-lint.yml

これは一見自然に見えますが、branch protectionのrequired status checkに pr-lint を登録している場合、致命的な問題を引き起こします。if: によってjobがskipされると、そのjobに対応するcheck run自体がGitHub上に生成されません。branch protection側は「まだ実行されていないcheck」を待ち続けるため、PRはいつまで経ってもマージ可能になりません。

同じ「スキップ」でも、reusable workflow 内部 のstepやjobでskipする場合はcheck runが SKIPPED として正しく報告されます。スキップする場所が呼び出し元か呼び出し先の内部かによって挙動が変わる、という点は非自明で、実際にハマりどころになりました。

判定に使う値はgithub.actorではなくPR作成者にする

もう一つの罠は判定条件そのものにありました。最初の実装ではbot判定に github.actor を使っていましたが、これは「そのイベントを起こしたアクター」であり、PRの作成者とは別物です。

たとえば gh pr update-branch のようなブランチ更新操作を人間が実行すると、そのイベントの github.actor はbotではなく操作した人間のアカウントになります。結果として、bot判定を前提にした if: 条件をすり抜けて検査が走り、想定外に失敗する、という不安定な挙動が発生します。同じPRなのに、どのイベントで再実行するかによって結果が変わってしまうわけです。

この問題は、判定材料をイベント種別に依存しない値に置き換えることで解決します。

env:
  PR_TITLE: ${{ github.event.pull_request.title }}
  PR_BODY: ${{ github.event.pull_request.body }}
  PR_AUTHOR: ${{ github.event.pull_request.user.login }}
  EXEMPT_AUTHORS: ${{ inputs.exempt-authors }}

github.event.pull_request.user.login はPRそのものの作成者を指すため、誰がどんな操作でイベントを起こしたかに関係なく安定した値になります。

解決策: jobをskipせず、免除対象なら早期に成功終了する

2つの罠をまとめると、恒久的な解決策は「条件付きでjobをskipする」のではなく、「常に実行はするが、免除条件に該当すれば早期に成功として終わる」形にjob設計を倒すことです。免除ロジックはstep内部で exit 0 するだけで実現でき、check run自体は必ず生成されるためbranch protectionを妨げません。

# exempt-authors: 検査を免除するが、jobはskipせず成功として報告する。
# caller側でjobごとskipすると check が生成されず、required status checkに
# 登録しているrepoではPRがmerge不能になる。
while IFS= read -r author; do
  [ -z "${author}" ] && continue
  if [ "${author}" = "${PR_AUTHOR}" ]; then
    echo "::notice::PR作成者 ${PR_AUTHOR} は exempt-authors に含まれるため検査を免除する(checkは成功として報告する)。"
    echo "PR Lint: exempt (${PR_AUTHOR})"
    exit 0
  fi
done <<< "${EXEMPT_AUTHORS}"

免除リストは呼び出し元workflowの inputs.exempt-authors として渡す形にしておくと、リポジトリごとにDependabot以外のbotアカウントを追加したい場合にも対応しやすくなります。

workflow内のshellロジックを実際に実行してテストする

このような分岐ロジックは、YAMLの文字列を正規表現で照合するだけのテストでは「skip方式への先祖返り」を検知できません。そこで、workflow YAMLから対象step本体をawkで抽出し、その場で実行して終了コードを検証する、という手法を採用しています。

CHECK_SCRIPT=$(mktemp)
trap 'rm -f "$CHECK_SCRIPT"' EXIT
awk '
  /^      - name: 検査ステップ$/ { in_step = 1 }
  in_step && /^        run: \|$/ { in_run = 1; next }
  in_run { sub(/^          /, ""); print }
' "$WORKFLOW" > "$CHECK_SCRIPT"

こうしておくとロジックをテストコード側に二重管理する必要がなく、workflow本体を変更すればテストも追随します。将来また「jobをif条件でskipする」実装に戻してしまっても、この回帰テストが失敗コードとして検知してくれます。

つまづきやすいポイント

  • required status checkに登録済みのjobは、呼び出し元のif:でjob単位skipすると check run自体が生成されず、PRが永久にマージ不能になる
  • github.actor はイベントを起こした人であってPR作成者ではないため、bot判定の材料には不向き
  • 免除条件を満たさないPRで検査が正しく失敗するかどうかも合わせてテストしておかないと、「常に成功する」バグを見逃しやすい
  • ログイン名の完全一致だけの免除判定は、なりすましのリスクを考えると将来的な見直しの余地がある

まとめ

Reusable workflowでbotのPRだけ検査対象から外したい場合、jobをif条件でskipする実装はrequired status checkと相性が悪く、PRのマージ不能というわかりにくい形で問題が表面化します。
判定条件には github.actor ではなくPR作成者を表す値を使い、免除ロジックはjob skipではなくstep内部の早期成功終了として実装するのが安定した解き方です。
分岐ロジックをテストする際は、正規表現でYAMLを照合するのではなく、実際のshellコードを抽出して実行し終了コードを検証する方法も有効な選択肢になります。