0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PRのテスト項目にチェック漏れがあったらCIを落とす|jq+awkの41行スクリプトと踏みやすい3つの落とし穴

0
Posted at

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つあります。

samples/pass.md
## 概要
ログ出力を追加

## テスト
- [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 を返します。

scripts/check_pr_tests.sh
#!/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 のワークフロー

.github/workflows/pr-test-checklist.yml
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 の既定値などの仕様は、下記の公式ドキュメントで確認しました。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?