TL;DR
- PR本文の「## テスト」見出しの下に
- [ ](未チェック)が残っていたら、GitHub Actions のジョブを失敗させます。 - 素朴な
grep -c '- \[ \]'では、別の見出しのチェック項目やコードブロック内の例まで数えます。改行が CRLF だと見出しの一致にも失敗し、未チェックがあるのに通ってしまいます。 - bash 5.2.37 / jq 1.7 / mawk 1.3.4・GNU Awk 5.2.1 で、合格・不合格・見出しなし・CRLF・コードブロックの5ケースと、イベントペイロード3件を確認しました。
全体像
PR本文のチェックボックスは、GitHub のタスクリスト記法(- [ ] が未完了、- [x] が完了)です。
素朴な方法で起きること
サンプルとして、次の本文を使います。テスト項目はすべて済んでいて、別の見出し「メモ」に未チェックが1つあります。
## 概要
ログ出力を追加
## テスト
- [x] 単体テストが通る
- [x] 手元で動作確認した
## メモ
- [ ] 後でドキュメントを更新
落とし穴1: 本文全体を grep すると、関係ない項目で落ちる
$ grep -c -- "- \[ \]" samples/pass.md
1
テストは終わっているのに、「メモ」の項目を数えて失敗扱いになります。数える範囲をテスト見出しの下に限定する必要があります。
落とし穴2: CRLF の本文だと見出しが一致せず、素通りする
本文の改行が CRLF(\r\n)だった場合、行末に \r が残ります。/^## テスト$/ のように行末を固定した範囲指定は一致しません。
$ sed -n '/^## テスト$/,/^## /p' samples/crlf.md | grep -c -- '- \[ \]'
0
samples/crlf.md には未チェックが1つあるのに、0件になりました。これを「0件なら成功」と判定すると、チェック漏れが通ります。先に tr -d '\r' で改行をそろえます。
落とし穴3: 本文が空だと jq が null という文字列を出す
イベントペイロードの body が null の場合(本文が空のPRを想定したサンプル event_null.json)、jq -r はそれを文字列 null として出力します。
$ jq -r ".pull_request.body" samples/event_null.json
null
// "" を付けて空文字にし、スクリプト側で「見出しがない」として扱います。
スクリプト
テスト見出しの下だけを読み、未チェックがあれば終了コード 1 を返します。見出しやチェック項目そのものがない場合は、テンプレートが消されたとみなして 2 を返します。
#!/usr/bin/env bash
# PR本文の「テスト」見出しの下にある未チェック項目(- [ ])を検出する。
# 使い方: ./check_pr_tests.sh [本文ファイル] (省略時は標準入力)
# 終了コード: 0=すべてチェック済み / 1=未チェックあり / 2=見出しかチェック項目がない
set -euo pipefail
heading="${TEST_HEADING:-テスト}"
input="${1:--}"
# CRLF を LF にそろえてから awk で該当セクションだけを読む
tr -d '\r' < "$( [ "$input" = "-" ] && echo /dev/stdin || echo "$input" )" |
awk -v want="$heading" -v gha="${GITHUB_ACTIONS:-}" '
# コードブロック(``` / ~~~)の中は数えない
/^[ \t]*(```|~~~)/ { infence = !infence; next }
infence { next }
/^#+[ \t]/ {
match($0, /^#+/); level = RLENGTH
title = substr($0, level + 1); sub(/^[ \t]+/, "", title); sub(/[ \t#]+$/, "", title)
if (insec && level <= seclevel) insec = 0 # 同じか上位の見出しで終了
if (!insec && index(title, want) == 1) { insec = 1; seclevel = level; found = 1 }
next
}
insec && /^[ \t]*[-*+][ \t]+\[[ xX]\]/ {
if ($0 ~ /\[[xX]\]/) { checked++ }
else {
unchecked++
item = $0; sub(/^[ \t]*[-*+][ \t]+\[ \][ \t]*/, "", item)
if (gha == "true") printf "::error title=未チェックのテスト項目::%s\n", item
else printf "未チェック: %s\n", item
}
}
END {
if (!found) { print "テスト見出し(" want ")が見つかりません"; exit 2 }
if (checked + unchecked == 0) { print "テスト見出しの下にチェック項目がありません"; exit 2 }
printf "checked=%d unchecked=%d\n", checked, unchecked
exit (unchecked > 0 ? 1 : 0)
}
'
ポイントは次の4つです。
- 見出しは「テスト」で始まるものに一致します(
## テスト項目も対象)。別の名前にするときはTEST_HEADINGで変えます。 - 同じか上位レベルの見出しが来たらセクションを閉じます。
### 補足のような下位見出しの項目は、テストの一部として数えます。 - コードブロック内の
- [ ]は記入例とみなして数えません。 -
GITHUB_ACTIONS=trueのときは、ワークフローコマンド::errorで注釈を出します。
動作を確認する
5つのサンプル本文
$ ./check_pr_tests.sh samples/pass.md; echo rc=$?
checked=2 unchecked=0
rc=0
$ ./check_pr_tests.sh samples/fail.md; echo rc=$?
未チェック: ステージングで確認した
未チェック: ロールバック手順を確認した
checked=1 unchecked=2
rc=1
$ ./check_pr_tests.sh samples/nosection.md; echo rc=$?
テスト見出し(テスト)が見つかりません
rc=2
$ ./check_pr_tests.sh samples/crlf.md; echo rc=$?
未チェック: 手元で動作確認した
checked=1 unchecked=1
rc=1
$ ./check_pr_tests.sh samples/fence.md; echo rc=$?
checked=2 unchecked=0
rc=0
fence.md は、## テスト項目 の下にコードブロック内の - [ ] と、### 補足 の下に大文字の - [X] を置いたものです。コードブロック内は数えず、[X] はチェック済みとして数えています。
| サンプル | 内容 | 期待 | 結果 |
|---|---|---|---|
| pass.md | テストは全部x、別見出しに未チェック | 0 | 0 |
| fail.md | テストに未チェック2件 | 1 | 1 |
| nosection.md | テスト見出しなし | 2 | 2 |
| crlf.md | CRLF、未チェック1件 | 1 | 1 |
| fence.md | コードブロック内の例+下位見出し+[X]
|
0 | 0 |
同じ5件を、mawk 1.3.4、GNU Awk 5.2.1、gawk --posix で実行し、終了コードがすべて一致することも確認しました。
CI上の出力
$ GITHUB_ACTIONS=true ./check_pr_tests.sh < samples/fail.md
::error title=未チェックのテスト項目::ステージングで確認した
::error title=未チェックのテスト項目::ロールバック手順を確認した
checked=1 unchecked=2
GitHub Actions のワークフロー
name: pr-test-checklist
on:
pull_request:
# 既定は opened / synchronize / reopened のみ。本文の編集でも再実行するため edited を足す
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
jobs:
checklist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: テスト項目がすべてチェック済みか確認
shell: bash # -o pipefail が付く
# 本文は ${{ }} で run に埋め込まず、イベントペイロードのファイルから読む
run: jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH" | ./scripts/check_pr_tests.sh
公式ドキュメントで確認した前提は次のとおりです。
-
editedを足す理由:pull_requestは、typesを指定しないとopened・synchronize・reopenedでしか動きません。チェックを付けるのは本文の編集なので、editedがないと再判定されません。 -
shell: bashを書く理由:shellを省略した場合はbash -e {0}です。shell: bashを指定すると-o pipefailも付き、パイプの途中(ここではjq)の失敗でもステップが失敗します。ステップの成否は終了コードで決まります。 -
本文を
${{ }}で埋め込まない理由:github.event.pull_request.bodyは信頼できない入力として扱うよう明記されています。ここではGITHUB_EVENT_PATH(イベントのペイロード全体が入ったファイル)からjqで読みます。公式が勧める「中間の環境変数に入れる」方法でも構いません(同じ3件の本文を環境変数経由で渡し、終了コードが一致することを確認しました)。
- name: 環境変数経由の書き方
shell: bash
env:
BODY: ${{ github.event.pull_request.body }}
run: printf '%s\n' "$BODY" | ./scripts/check_pr_tests.sh
ワークフローの静的チェックと run 行の確認
actionlint 1.7.12 で、上のワークフローは指摘なしでした。比較のため run: echo "${{ github.event.pull_request.body }}" | ... と書いた版を通すと、次の指摘が出ました。
$ actionlint .github/workflows/pr-test-checklist.yml; echo rc=$?
rc=0
$ actionlint .github/workflows/zz-bad-example.yml; echo rc=$?
.github/workflows/zz-bad-example.yml:7:24: "github.event.pull_request.body" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. ...
rc=1
bash 5.2.37 / jq 1.7 の環境で、ワークフローの run 行を bash --noprofile --norc -eo pipefail で実行し、GITHUB_EVENT_PATH にペイロード3件を渡して確認しました。event_inject.json は、本文に $(touch /tmp/pwned_by_body) とバッククォートを含めたものです。文字列として扱われ、ファイルは作られませんでした。
$ ./verify.sh
pass.md rc=0
fail.md rc=1
nosection.md rc=2
crlf.md rc=1
fence.md rc=0
event_null.json (run行) rc=2
event_fail.json (run行) rc=1
event_inject.json (run行) rc=1
$ ls /tmp/pwned_by_body
ls: cannot access '/tmp/pwned_by_body': No such file or directory
運用で気をつけること
- ジョブが失敗しても、それだけではマージは止まりません。止めるには、ブランチ保護(またはルールセット)の「Require status checks before merging」でこのジョブを必須にします。必須チェックは
successful・skipped・neutralで通る仕様です。if:でジョブをスキップする条件を付けると、そのまま通ってしまう点に注意します。 - 公式ドキュメントの
pull_requestの項には、「The pull_request webhook event payload is empty for merged pull requests and pull requests that come from forked repositories.」(マージ済みのPRと、フォークしたリポジトリからのPRでは、pull_request の webhook イベントペイロードが空になる)と書かれています(Events that trigger workflows)。フォークからのPRを受けるリポジトリでは、本文が取れるかを先に確かめてください。ペイロードにpull_request.bodyがない場合({}を渡して確認)、このスクリプトは「見出しなし」(終了コード2)で失敗します。 - チェックボックスは自己申告です。このCIが保証するのは「テスト項目に全部チェックが付いている」ことまでで、テストを実施した証拠にはなりません。自動テストのジョブと組み合わせて使います。
まとめ
- 数える範囲はテスト見出しの下に限定し、コードブロック内は除きます。
- 先に CRLF を LF にそろえ、本文が空の
nullは// ""で空文字にします。 -
pull_requestのtypesにeditedを足し、shell: bashで pipefail を有効にします。本文は${{ }}でrunに埋め込みません。 - 終了コードを 0/1/2 に分けると、「チェック漏れ」と「テンプレート未記入」を区別できます。
実行環境
- bash 5.2.37 / jq 1.7 / mawk 1.3.4 / GNU Awk 5.2.1 / actionlint 1.7.12(Linux x86_64)
- ワークフローの
run行は、上記の bash でbash --noprofile --norc -eo pipefailとして実行して確認しました。actions/checkoutは、2026-10-20 時点の最新メジャーバージョン v7(最新リリース v7.0.1)を指定しています。 - GitHub Actions のイベントや shell の既定値などの仕様は、下記の公式ドキュメントで確認しました。
参考
- タスクリスト: https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-tasklists
- pull_request イベントと既定の activity types: https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request
- shell の既定値と終了コード: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#exit-codes-and-error-action-preference
- GITHUB_EVENT_PATH: https://docs.github.com/en/actions/reference/workflows-and-actions/variables
- ワークフローコマンド(::error): https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
- スクリプトインジェクション: https://docs.github.com/en/actions/concepts/security/script-injections
- 安全な使い方(中間環境変数): https://docs.github.com/en/actions/reference/security/secure-use
- 必須ステータスチェック: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
- actions/checkout のリリース: https://github.com/actions/checkout/releases
- actionlint: https://github.com/rhysd/actionlint