GitHub Actionsにpnpm run fooと書いてあっても、package.jsonにfooがあるとは限らない。workflow-script-auditで静的に照合する

GitHub Actionsのworkflowにpnpm run fooと書いてあっても、対象のpackage.jsonfooというscriptが実在するとは限らない。誰かがscript名を変更・削除して、workflow側の更新を忘れる——それだけのことだが、workflow YAML単体のlintでは、この2ファイル間のmismatchは見つからない。

workflow-script-audit

workflow-script-auditは、GitHub Actionsのworkflowが呼んでいるnpm/pnpm/yarnのscript名を抽出し、実際にresolveされるpackage.jsonscriptsと静的に照合する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.jsonscriptsには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.ymlpnpm 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させないことに特に気をつけている。--filterworking-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_DYNAMICUNRESOLVED_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名らしき文字列は実行対象として扱わない、inline cd &&は対応、それ以外の複雑な組み立ては対象外)
  • 静的解析のみ。実行・依存installは一切行わない

AI codingに限った問題ではないが、workflowとpackage.jsonを別々の変更として扱う場面——AIが片方だけを編集する、レビューで見落とす——では起こり得るケースの一つだと考えている。

GitHub

ソースコード、synthetic fixture、実例の再現scriptはこちらで公開している。

https://github.com/iwadjp/workflow-script-audit

関連するAI coding調査toolは、まとめページに整理しています。

Uncategorized

Posted by iwadjp