QACをGitHub Actionsのself-hosted runnerで動かしてみる
組込みCのリポジトリで、QACをGitHub Actionsから動かして、Pull Request(以下PR)に結果を出す仕組みを作った。
QAC自体の使い方というよりは、既存コードが多いプロジェクトでどうCIに載せるか、という話になる。
この記事中の製品名、リポジトリ名、変数名、パス、ネットワーク情報、件数などは一般化している。構成と考え方は実運用のものだが、そのままコピペして動くものではないのであしからず。
前提
今回のQACは、GitHubが用意しているRunnerでは動かせなかった。
- 商用ライセンスが必要
- Windowsにしかないツールや設定に依存している
-
cmd.exe前提のmakefileが残っている - 既存コードにQACの指摘がそれなりにある
- ちなみにいうとコンパイラも商用なのでRunnerじゃ厳しい
という感じで、普通にUbuntuのRunnerで動かすのは難しい。
なので、軽い検査はGitHub-hosted runnerでやって、QACとターゲット向けのビルドだけはWindowsのself-hosted runnerで動かすことにした。
最初から全部self-hosted runnerに載せるより、必要なものだけ分けたほうが、管理する範囲も小さくなると思う。
まず考えたこと
QACをCIに入れるときに、特に避けたかったのは以下。
| 困ること | 今回の対応 |
|---|---|
| 既存指摘でPRが埋まる | 変更部分だけを解析・表示する |
| 外部PRのコードを社内Runnerで実行する | forkからのPRでは実行しない |
| Runnerやライセンスがないのに成功扱いになる | 実行しない理由を必ず表示する |
| QACの実行失敗が「指摘0件」に見える | 失敗と指摘ゼロを別扱いにする |
| 再実行で前の結果が消える | 実行履歴を残し、空結果で上書きしない |
QACを起動するだけならそこまで難しくないのだが、CIとして運用するなら、このあたりを考えないとすぐに使われなくなる気がする。
変更したところだけ見たい
今回のコードベースには、すでにQACの指摘が残っている。ここでリポジトリ全体を解析して全部PRに出すと、数が多すぎて本当に見たい指摘が埋もれる。
なので、二段階で絞ることにした。
1. 変更したC/Hファイルだけを解析対象にする
まずPR差分から変更されたC/Hファイルを取る。変更されたモジュールだけに絞ってQACを実行することで、解析時間を減らせる。
ヘッダを変更したときは、同じ単位のCファイルも対象に含める。ヘッダの型定義やマクロの変更は、使っている側のCファイルで引っかかることがあるため。
- name: 解析対象を決める
id: changed
uses: ./.github/actions/changed-source-files
with:
base-ref: ${{ github.base_ref }}
2. PRの追加・変更行にある指摘だけを表示する
ただし、ファイル単位でQACを掛けると、今回触っていない既存行の指摘も出る。そこで、PRに表示する段階でもう一度絞る。
reviewdog \
-reporter=github-pr-check \
-filter-mode=added \
-fail-level=none \
< qac-findings.txt
-filter-mode=addedで、追加・変更された行にある指摘だけをPR上に出せる。
1段目は解析時間を減らすため、2段目はレビューのノイズを減らすためのもの。似ているが役割が違うので、どちらも必要になる。
self-hosted runnerを誰でも動かせる状態にしない
self-hosted runnerは、ライセンスや社内環境にアクセスできるPCで動く。外部の人が作ったPRをそのままチェックアウトして動かすのは、さすがによろしくない。
そのため、QACを実行してよいかどうかは、GitHub-hosted runner側で先に判定する。
preflight:
runs-on: ubuntu-latest
outputs:
run: ${{ steps.decide.outputs.run }}
steps:
- id: decide
env:
ENABLED: ${{ vars.CI_STATIC_ANALYSIS_ENABLED }}
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPOSITORY: ${{ github.repository }}
run: |
if [ "$ENABLED" != "true" ]; then
echo "run=false" >> "$GITHUB_OUTPUT"
echo "::notice::静的解析は未有効化です"
elif [ "$HEAD_REPOSITORY" != "$BASE_REPOSITORY" ]; then
echo "run=false" >> "$GITHUB_OUTPUT"
echo "::notice::forkからのPRでは専用Runnerを実行しません"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
QACのジョブ側は、この結果がtrueのときだけ実行する。
qac:
needs: preflight
if: needs.preflight.outputs.run == 'true'
runs-on: [self-hosted, windows, static-analysis]
ここではpull_request_targetを使わないようにしている。便利そうに見えるが、PRのコードを実行する用途では権限や信頼境界が分かりにくくなる。
forkのPRでQACが走らないのは少し不便かもしれないが、安全側に倒した。必要なら信頼できるブランチに取り込んでから実行すればよい、という考え方。
ライセンス競合と後始末
商用ツールは、同時実行するとライセンスがぶつかることがある。QACを何本も同時に起動して、全部ライセンス待ちになったら悲しい。
なので、QAC用のworkflowには専用のconcurrency groupを設定している。
concurrency:
group: static-analysis-runner
cancel-in-progress: false
軽いチェックなら、次のpushが来たときに古い実行をキャンセルしてよい。一方でQACのように時間が掛かるものは、途中で止めると生成物やプロセスが中途半端に残ることがある。
ジョブ開始時には、前回の異常終了で残ったQACプロセスと出力ディレクトリも掃除するようにした。ただし消すのは専用の生成物だけ。ワークスペース全体を消すのは、ちょっと怖い。
文字コードも地味に困る
組込み開発の既存環境だと、ソースやツールの出力がUTF-8ではないことがある。GitHubのChecksやPRコメント、JSON、ブラウザはUTF-8が前提なので、QACの結果を取り込む段階でUTF-8へ変換することにした。
今回の出力形式はツール設定から分かっていたので、既知の文字コードとして変換している。もしUTF-8と別の文字コードが混ざる可能性があるなら、先にUTF-8として厳密に読めるかを確認して、失敗したときだけ別の文字コードを試すほうが安全。
バイト列を見て「Shift_JISっぽい」と決めつけると、UTF-8の日本語を壊すことがある。文字コードは推測ではなく、ツール設定などの根拠を見て決めたほうがよさそう。
「スキップ」と「指摘ゼロ」は別
CIで一番避けたいのは、実行していないのに緑色で「問題なし」に見える状態。
今回の結果は、少なくとも次のように分ける。
| 状態 | 意味 |
|---|---|
| skipped | 未有効化、対象外、または安全上の理由で実行しなかった |
| failed | ツール、ライセンス、結果の生成などで失敗した |
| completed / 0 findings | 解析は完走し、表示対象の指摘がなかった |
| completed / findings | 解析は完走し、表示対象の指摘があった |
「対象ファイルがない」と「QACの結果ファイルが一つもできなかった」は、最終的な指摘件数だけを見るとどちらも0件になり得る。
ただ、前者は問題ないが、後者は明らかに問題。ここを同じ扱いにすると、解析が壊れていても気付けない。なので、スキップ理由や失敗理由はnoticeやコメントに出すようにしている。
PRコメントと詳細画面を分ける
PRには、レビュー中に見ればよい情報だけを出す。
- 解析対象のファイル数
- 実際に解析できた件数
- 指摘件数
- 実行状態
- 詳細画面へのリンク
pushのたびにコメントを増やすと読みにくいので、同じ種類のコメントは更新する形にした。以前に指摘があって直った場合は、消す代わりに「解消済み」にして残す運用もできる。監査証跡が必要な場合は、このほうが都合がよいと思う。
細かい指摘は、読み取り専用のダッシュボードで確認できるようにした。実行履歴、重要度、ファイル、ルール番号、メッセージで絞り込める。
JSONの更新は一時ファイルを書いてから置き換える形にしている。さらに、QAC失敗や空結果で、直前の有効な結果を上書きしないようにもした。
「最新の実行」は失敗しているが、「最後に正常に取れた結果」は残しておきたいことがあるため。
最初からPRを止めない
静的解析を導入したばかりのときは、どれくらい指摘が出るか、誤検知が多くないか、Runnerがどのくらい待たされるかも分からない。
この状態でいきなりPRをブロックすると、CI自体が邪魔者になりがち。
なので最初はcontinue-on-error相当で観測することにした。
- まずはPRを止めずに動かす
- 指摘量、誤検知、失敗理由、実行時間を確認する
- 対応方針が決まったルールだけを選ぶ
- 別の変更でブロッキングにして、Required checkへ追加する
MISRAのように重要度が複数あるルールセットでは、分類がない状態で全部をエラー扱いにしないほうがよい。重大なものだけ通知を出して、それ以外はCheck Runで見られるようにする、くらいから始めるのが無難だと思う。
CI自体の確認もする
GitHub Actionsの設定は、壊れてもコンパイルエラーのようには落ちないことがある。条件式を間違えてずっとスキップされたり、Runnerラベルを間違えて永遠にキュー待ちになったりする。
そのため、workflowを変更したときにローカルで確認するスクリプトを置いた。
- Actionの静的解析
-
run:ブロックを取り出してShellCheck -
run:内に外部入力を直接埋め込んでいないか - サードパーティActionを不変SHAで参照しているか
- 実際に検査したブロック数が0件ではないか
最後の「検査した数が0件ではないか」は、意外と大事。抽出処理が壊れて何も検査していなくても、「指摘なし」で成功する実装は作れてしまう。
まとめ
QACをGitHub Actionsのself-hosted runnerで動かすとき、難しいのはQACを呼び出す部分だけではなかった。
- 既存資産を責めず、今回の変更だけを見る
- self-hosted runnerを外部PRから守る
- ライセンスと生成物を共有資源として扱う
- 文字コードを境界で揃える
- 未実行、失敗、指摘ゼロを分ける
- いきなりブロックせず、様子を見てからゲート化する
静的解析を「全件を出す装置」ではなく、レビューで人が見るべきことを増やさないための仕組みにできれば、既存資産が多いプロジェクトでも入れやすくなると思う。
参考
-
Self-hosted runners - GitHub Docs
- self-hosted runnerはジョブごとにクリーンな環境にならないこと、管理者側でOS・ツールを保守することの説明。
-
Events that trigger workflows: pull_request_target - GitHub Docs
-
pull_request_targetで未信頼なPRのコードをビルド・実行しないことの注意
-
-
reviewdog
-
github-pr-check、-filter-mode=addedなど、PR差分への静的解析結果の表示に使用
-