きっかけは依存更新のPRでした。
パッチバージョンが3つ上がっているだけのPRを眺めていて、ふと「このPRをマージしたら、次のデプロイでうちのCIは何を実行することになるのか」を自分がきちんと説明できないことに気づいたんです。
npm ci はロックファイルどおりにパッケージを展開するコマンドだ、という理解で10年近くやってきました。ただ、展開の途中で依存側の postinstall が動くことも知っている。知っているのに、自分たちのプロジェクトでそれが何本走っているのかを数えたことが一度もなかった。
正直なところ、そこが一番まずいと思いました。運用を考えると、止めるかどうかより先に「今どうなっているか」を数値で持っていないほうが問題です。
まず本数を数えた
対象は自社コーポレートサイトのリポジトリです。Astroの静的サイトで、GitHub Actionsから npm ci → npm run build → S3へ配信、という構成になっています。
ロックファイルには hasInstallScript というフラグがあります。インストール時にスクリプトが走るパッケージには、npmがこれを立ててくれます。
node -e "
const lock = require('./package-lock.json');
const all = Object.keys(lock.packages).filter((k) => k.startsWith('node_modules/'));
const withScript = Object.entries(lock.packages).filter(([, v]) => v.hasInstallScript);
console.log('packages:', all.length);
for (const [name, v] of withScript) console.log(' install script:', name, v.version);
"
# packages: 841
# install script: node_modules/esbuild 0.27.4
# install script: node_modules/fsevents 2.3.3
# install script: node_modules/msw 2.12.14
# install script: node_modules/playwright/node_modules/fsevents 2.3.2
# install script: node_modules/sharp 0.34.5
841分の5。割合でいえば0.6%です。
数字そのものより、内訳のほうが私には効きました。package.json に書いてある直接依存は21件(開発依存を入れて26件)で、そのうちインストールスクリプトを持つのは sharp だけ。残りは自分で選んだ覚えのないパッケージです。
| パッケージ | どこから来たか | インストール時に走る内容 |
|---|---|---|
| sharp 0.34.5 | 直接依存(画像最適化) | node install/check.js || npm run build |
| esbuild 0.27.4 | astro 6.0.8 / vite 7.3.1 経由 | node install.js |
| msw 2.12.14 | shadcn 4.1.0 経由(2段下) | node -e “import(’./config/scripts/postinstall.js’).catch(() => void 0)“ |
| fsevents 2.3.3 / 2.3.2 | optional依存(macOS用) | Windows/Linuxでは展開自体されない |
msw はモックサーバのライブラリです。うちのサイトはモックサーバを使っていません。shadcn の依存として入ってきて、そのインストール時スクリプトが .catch(() => void 0) で失敗を握りつぶす形になっている。悪意があるという話ではなく、失敗しても何も言わない処理が、自分の知らないところで自分の権限で走るという構造だけが残ります。
CIで動くときは、この権限にはリポジトリのチェックアウト内容と、ジョブに渡している環境変数が含まれます。うちの deploy.yml はOIDCでAWSのロールを引き受けてからビルドしているので、順序次第ではクレデンシャルが取れる位置に来ます。ここは設計として見直す価値があると考えています。
「止めたら画像最適化が死ぬ」と思っていた
--ignore-scripts を付ければインストール時スクリプトは走りません。これは知っていました。
ただ、うちには sharp がいます。ネイティブのバイナリを使う画像最適化ライブラリで、インストール時にビルドやバイナリ取得をやるやつです。止めたらAstroの画像最適化が落ちて、ビルドが赤くなる。そう予想していました。
なので最初は「CIのうちテストだけ --ignore-scripts にして、ビルドジョブは従来どおり」という折衷案を考えていたんです。
実際にやってみたら、予想が外れました。
npm ci --ignore-scripts >/dev/null
(cd node_modules && find . -type f | sort) > /tmp/without-scripts.txt
npm ci >/dev/null
(cd node_modules && find . -type f | sort) > /tmp/with-scripts.txt
diff /tmp/without-scripts.txt /tmp/with-scripts.txt | wc -l
wc -l < /tmp/with-scripts.txt
diff の行数は 0、ファイル総数は 36,639。
スクリプトを止めて入れた node_modules と、走らせて入れた node_modules が、ファイル一覧のレベルで完全に一致していました。念のため esbuild(7ファイル)、sharp(33ファイル)、msw(589ファイル)、@img、@esbuild の各ディレクトリについて中身のハッシュも突き合わせましたが、こちらも全部同じ。
ビルドも通りました。
| 計測項目 | 通常の npm ci | npm ci —ignore-scripts |
|---|---|---|
| インストール所要時間 | 12.87秒 | 13.13秒 |
| 生成ファイル数 | 36,639 | 36,639(差分0行) |
| astro build | 117ページ / 9.42秒 | 117ページ / 9.29秒 |
| 画像最適化(webp変換) | 12件成功 | 12件成功 |
理由は展開後のツリーを見て納得しました。node_modules/@img/sharp-win32-x64 と node_modules/@esbuild/win32-x64 が、どちらの方法でもちゃんと入っている。プラットフォーム別のビルド済みバイナリが optionalDependencies として配られていて、npmはプラットフォームに合うものだけを普通に展開します。ここにスクリプトは介在しません。
つまり sharp や esbuild のインストールスクリプトは、ビルド済みバイナリが手に入らなかった環境向けのフォールバックとして置いてあるものでした。うちの環境(Node v22.14.0 / npm 11.12.0 / Windows x64、CIはubuntu-latestのNode 22)では出番がない。
今思えば、「ネイティブモジュール=インストール時ビルドが必須」というのは私の頭が数年前で止まっていただけでした。ドキュメントや解説にはそう書いてあることが多いんですが、パッケージ側の配り方はここ数年で変わっています。
止まるもの、止まらないもの
では --ignore-scripts を入れれば安心かというと、そうではありません。ここは分けて考える必要があると思っています。
止められるのは、インストール時に自動で走るコードだけです。依存パッケージを import した瞬間に走るトップレベルのコードは対象外で、ビルドやテストで読み込めば普通に実行されます。今回の構成でいえば、astro build が esbuild を読み込む時点でそのコードは動く。インストール時スクリプトを止めることは、攻撃面を消す施策ではなく、「自分のコードを1行も書いていない段階で任意コードが動く」という一番早い入口を閉じる施策です。
もうひとつ、自分たちのスクリプトにも影響が出ます。.npmrc に ignore-scripts=true を書くと、依存側だけでなく自プロジェクトのライフサイクルスクリプトも止まる。これは最小構成を別ディレクトリに作って確かめました。
mkdir hooks-check && cd hooks-check
cat > package.json <<'EOF'
{
"name": "hooks-check",
"version": "1.0.0",
"scripts": {
"prebuild": "node -e \"console.log('prebuild ran')\"",
"build": "node -e \"console.log('build ran')\"",
"postbuild": "node -e \"console.log('postbuild ran')\""
}
}
EOF
npm run build --silent
# prebuild ran
# build ran
# postbuild ran
echo "ignore-scripts=true" > .npmrc
npm run build --silent
# build ran
明示的に呼んだ build は動きますが、prebuild と postbuild は黙って飛ばされます。エラーも警告も出ません。
うちのサイトは package.json に prepare も prebuild も持っていないので実害はありませんでした。実際、.npmrc を置いた状態で npm run build を流しても117ページ・9.38秒で通っています。ただ、husky で git hooks を入れているリポジトリや、ビルド前に型定義を生成しているリポジトリだと、ここで静かに壊れます。壊れ方が「何も起きない」なので、気づくのはだいぶ後です。
CIへの入れ方と、まだ雑なところ
現時点での案はこうです。リポジトリ直下の .npmrc に ignore-scripts=true の1行だけを置き、ワークフロー側では必要な場合に限って明示的にリビルドする形にしています。
# .github/workflows/deploy.yml(抜粋)
- name: Install dependencies
run: npm ci
- name: Rebuild allowed native packages
run: npm rebuild sharp
- name: Build
run: npm run build
この npm rebuild sharp の行は、正直に言えば改善の余地があります。今回の実測では sharp はリビルドなしで動いているので、この行は現時点では不要です。それでも残しているのは、将来ランナーのアーキテクチャが変わってビルド済みバイナリが降ってこなくなったときに、無言で画像最適化だけ落ちるのを避けたいからです。ただ、これは「効いているかどうか誰も確認しない行」になりやすい。パッケージマネージャ側に許可リストの仕組み(インストールを許すパッケージだけを列挙する方式)があるなら、そちらに寄せたほうが運用は素直です。次の依存整理のタイミングで比較しようと考えています。
コストの話もしておきます。この対策で増える月額費用は0円です。CIの実行時間も、今回の計測では0.3秒の差でしかありません。代わりに払うのは、壊れたときの調査時間です。「入れた覚えのない設定のせいでビルドが落ちた」を1回やると、だいたい半日持っていかれる。だから .npmrc をコミットするなら、コメントに理由と、外すときの判断基準まで書いておくべきだと考えています。
設定したつもりで効いていなかった話
もうひとつ、ついでに試したものが空振りしました。
公開直後のバージョンを一定期間インストールしない、いわゆるクールダウンの設定です。npmの設定一覧には確かに min-release-age というキーがあって、既定値は null になっています。
npm config ls -l | grep -i release
# min-release-age = null
npm config set min-release-age 7 --location=project
cat .npmrc
# min-release-age=7
npm config get min-release-age
# null
.npmrc に書き込まれているのに、読み戻すと null のままでした。値を 10080(分)や 7d に変えても同じ。同じファイルに書いた ignore-scripts=true はちゃんと true で返ってくるので、ファイルが読まれていないわけではありません。手元の npm 11.12.0 では、このキーはまだそういう状態だ、ということだと理解しています。
ここは好みが分かれるところですが、私はこういう「書いたのに効かない設定」が一番こわいと思っています。設定ファイルに1行足すと、人は対策済みだと認識してしまう。導入するなら、npm config get で読み戻せるかどうかまでを確認手順に入れるべきです。クールダウン自体は、依存更新のPRを作る側(RenovateやDependabot)で待たせるほうが、今のところ確実に効きます。
振り返って
今回わかったことを、順番のまま並べておきます。
- うちの依存841件のうち、インストール時にコードが走るのは5件。直接選んだのは1件だけだった
--ignore-scriptsを付けても生成物は1ファイルも変わらず、ビルドも画像最適化も通った- ただし止まるのはインストール時の入口だけで、読み込み時に走るコードは別問題
- 自プロジェクトの
pre/postスクリプトも黙って止まる。壊れ方が静かなので確認手順が要る - クールダウン系の設定は、書いただけでは効いていないことがある
数えるところから始めて、ここまで半日もかかっていません。同じ手順は他のプロジェクトにもそのまま流せるので、まずは受託案件のリポジトリを順に測っていくつもりです。パッケージマネージャごとに許可リストの作法が違うので、そのあたりを揃えた社内の標準 .npmrc を作るところまでは持っていきたいと考えています。