GitHub Actionsにpnpm run fooと書いてあっても、package.jsonにfooがあるとは限らない。workflow-script-auditで静的に照合する
GitHub Actionsのworkflowにpnpm run fooと書いてあっても、対象のpackage.jsonにfooというscriptが実在するとは限らない。誰かがscript名を変更・削除して、workflow側の更新を忘れる——それだけのことだが、workflow YAML単体のlintでは、この2ファイル間のmismatchは見つからない。
workflow-script-audit
workflow-script-auditは、GitHub Actionsのworkflowが呼んでいるnpm/pnpm/yarnのscript名を抽出し、実際にresolveされるpackage.jsonのscriptsと静的に照合するCLIツールだ。actionlintはworkflow YAMLとexpression構文を検証するが、package.jsonの中身までは見ない。パッケージマネージャ自身は、実際にCIが動いて初めてMissing scriptを報告する——それも|| trueで握りつぶされていれば、報告すらされない。
実例1: Hunty
公開repo Samuel1-ona/huntyのcommit 89882a4時点で、.github/workflows/security-tests.ymlは次のようにpnpm security:testを呼んでいた。
run: pnpm security:test || true
ところがroot package.jsonのscriptsにはsecurity:testが存在しない。|| trueがついているため、このstepは実際には何もsecurity testを実行していなくても、常に成功として報告される。この問題自体は、repository自身のissue #890でも独立に指摘されている。workflow-script-auditを実行すると、次のようにREVIEW_SUGGESTEDとしてflagされる。
REVIEW_SUGGESTED .github/workflows/security-tests.yml:39
- references script "security:test" not defined in root package.json (masked: step will report success anyway)
実例2: Exputra
もう一つ、before/afterがはっきり分かる実例がある。公開repo exputra/tosexputragroupsiputradigitalのcommit 0180b9e2時点で、.github/workflows/ci.ymlはpnpm run generate-content-jsonを呼んでいたが、この時点のpackage.jsonにはgenerate-content-jsonが存在しなかった。
その後のfix commit dfab7736(コミットメッセージ: “Fix missing script error in GitHub CI workflow by adding script to package.json")が、まさにこのscriptをpackage.jsonへ追加している。workflow file自体はこのfixで一切変更されていない。workflow-script-auditをbefore/afterそれぞれに対して実行すると、次のようにA/Bで結果が分かれる。
before (0180b9e2): REVIEW_SUGGESTED
- references script "generate-content-json" not defined in root package.json
after (dfab7736): CLEAN
実際にsource fixが行われた前後で判定が正しく反転する、という点で分かりやすい実例だ。
workspaceも解決する
script名がどのpackage.jsonへ解決されるべきかは、単純にrootとは限らない。workflow-script-auditは、次の優先順位で解決先を決める。
- inline shellの
cd <dir> && pnpm --filter <name>(pnpm-workspace.yamlのglob entry・exact entryどちらも対応)- YAMLの
working-directory: - それ以外はrepository root
解決先を誤ってrootへfallbackさせないことに特に気をつけている。--filterやworking-directoryが解決できない場合は、rootとして再チェックするのではなく、後述の通り別扱いにする。
推測しないケース
GitHub Actionsのexpression構文が絡む場合は、値を推測しない。
run: pnpm test:${{ matrix.suite }}
このような${{ ... }}を含むscript名は、実行時までどの文字列になるか静的には分からない。workflow-script-auditはこれを展開・推測せず、SKIPPED_DYNAMICとして明示的に区別する。同様に、--filterやworking-directoryの解決先が今回のcheckoutに存在しない場合(別workflow stepが実行時にclone・生成するdirectoryなど)も、rootとして扱わずUNRESOLVED_WORKSPACEとして区別する。
CLEANの意味
CLEANが意味するのは、次のことだけだ。
CLEAN = 静的に解決可能な範囲で、存在しないworkflow scriptを確認できなかった。
CLEANが意味しないことも明確にしておく。
- workflow全体が正しいことの保証ではない
- CIが成功することの保証ではない
SKIPPED_DYNAMICのdynamic expressionを検証済みという意味ではないUNRESOLVED_WORKSPACEのworkspaceを検証済みという意味ではない
CLEANの直後にSKIPPED_DYNAMICやUNRESOLVED_WORKSPACEの行が続く場合、それは「問題なし」ではなく「静的には判定できなかった」という意味なので、読み飛ばさないようにしている。
使い方
node workflow-script-audit.cjs --repo <path-to-a-repo>
Node.js以外の依存はなく、npm install・network access・gitのいずれも不要。動作確認はNode.js v24.15.0でのみ行っている。
制限
- bare形式の
pnpm <script>(runなし)は、<script>に:を含む場合のみscript呼び出しとして扱う。ローカルbinaryの実行と構造的に区別できないための制約 - workspace globは
prefix/*形状と、glob以外の完全一致directory名のみ対応 - 実際のYAML parserは使わず、
steps:のインデントを追う行単位のscanで実装している - 認識するshellの形は限定的(
echo/printf内のscript名らしき文字列は実行対象として扱わない、inlinecd &&は対応、それ以外の複雑な組み立ては対象外) - 静的解析のみ。実行・依存installは一切行わない
AI codingに限った問題ではないが、workflowとpackage.jsonを別々の変更として扱う場面——AIが片方だけを編集する、レビューで見落とす——では起こり得るケースの一つだと考えている。
GitHub
ソースコード、synthetic fixture、実例の再現scriptはこちらで公開している。
https://github.com/iwadjp/workflow-script-audit
関連するAI coding調査toolは、まとめページに整理しています。