Skip to content
Joey Wang
Menu

Search

DevOps and ReliabilityFull Stack Development

Why npm ci Runs prepare Scripts for Git Dependencies

npm ci is not the script-free install it looks like; it still runs prepare scripts for Git-based dependencies, which broke Husky in CI.

· 3 min read

engineeringdevops #javascript#ci#devops

Audio summary

Running npm ci in CI/CD, you’d expect a clean, script-free install based on the lockfile. It isn’t. It still runs prepare scripts, not just for your own project but for dependencies too, and in my case that meant Husky trying to install Git hooks inside a Git repo dependency that wasn’t actually a real Git checkout in that context.

Here’s 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:

"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:

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:
    npm_config_ignore_scripts=true
    This worked but broke everything else (like legitimate postinstall scripts).

The fix: condition the Husky install on being 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:

"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:

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 runs prepare scripts, even for dependencies, whenever they’re Git-based or file-based. There’s no flag to skip it. If you depend on Husky or another Git-hook tool inside a dependency, guard its install with logic rather than assuming it will never run outside the main repo.

To see exactly which scripts are running during an install, use npm install --foreground-scripts. It’s the fastest way to catch this kind of thing before it turns into a CI mystery.