Post

🛠 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 the dist/ 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 of prepare – 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 run prepare 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?

This post is licensed under CC BY 4.0 by the author.