🛠 Why `npm ci` Runs `prepare` for Dependencies — And How I Fixed My Husky Hook Nightmare
🛠 Why npm ci
Runs prepare
for Dependencies — And How I Fixed My Husky Hook Nightmare
When running npm ci
in CI/CD environments, you’d expect it to be a clean, script-free install based on your lockfile. But to my surprise, it was running prepare
scripts — not just for my project, but also for dependencies. This caused a mysterious failure, and in my case, it was due to Husky trying to install Git hooks… inside a Git repo dependency.
Let’s break down the why, the gotchas, and the fix.
🤯 The Problem: npm ci
Runs prepare
for Git Dependencies
You might assume npm ci
is a strict, minimal install — kind of like yarn --frozen-lockfile
. But in reality, npm still runs lifecycle scripts under the hood, including prepare
, and not just for your own project.
The key culprit is the prepare
lifecycle script, which runs when:
- You install a package from a Git URL or local file path
- You do
npm link
- You install your own project
- You install a dependency from Git (and this always happens in CI if such dependencies exist)
What it does:
prepare
is used to build/transpile the package after it’s cloned — think of creating thedist/
folder.
😵 My Case: Husky Failing to Install in a Git Dependency
I had a monorepo setup where a package depended on another internal Git repo. That internal repo had Husky installed, and its prepare
script looked like:
1
2
3
"scripts": {
"prepare": "husky install"
}
So when npm ci
ran in my CI job, it pulled the dependency from Git, triggered prepare
, and then Husky tried to install Git hooks into the Git repo… which wasn’t a real repo in the CI context. It failed hard with:
1
fatal: not a git repository (or any of the parent directories): .git
🔍 Debugging the Problem
At first, I tried:
npm ci --skip-prepare
– nope, doesn’t exist.SKIP_PREPARE=1
– no effect.- Adding
postinstall
instead ofprepare
– also runs, but not the root cause. - Disabling scripts entirely:
1
npm_config_ignore_scripts=true
This worked but broke everything else (like legitimate postinstall scripts).
✅ The Fix: Condition Husky Install to Only Run in the Main Repo
I realized the solution wasn’t to stop npm
from running prepare
globally — it was to make Husky smarter.
Here’s the fix:
Modify prepare
to only run husky install
if it’s the main project:
1
2
3
"scripts": {
"prepare": "test -d .git && husky install || echo 'Skipping husky install: not a git repo'"
}
Or if you’re on Windows too, use a JS script in prepare.js
:
1
2
3
4
5
6
7
8
9
10
11
12
const { execSync } = require('child_process');
const fs = require('fs');
try {
if (fs.existsSync('.git')) {
execSync('npx husky install', { stdio: 'inherit' });
} else {
console.log('Skipping husky install: not a git repo');
}
} catch (e) {
console.error('Failed to run husky install', e);
}
Now, Husky only tries to install in environments where .git
exists — i.e., not in the extracted Git dependency in CI.
🧠 Lessons Learned
npm ci
does runprepare
scripts — even for dependencies — if they’re Git-based or file-based.- You can’t skip
prepare
with a flag. - If you’re using Husky or other Git-hook tools in a dependency, guard it with logic to avoid running in subrepos or CI environments.
🏁 Final Tip
To check which scripts are being run during install, use:
1
npm install --foreground-scripts
This shows you what’s executing, and can help debug mysterious behaviors in CI environments.
Let me know if you want this in Markdown format or styled for Medium/Dev.to. Want to add anything about your own repo structure or CI setup to make it more personal?