Dependency management
npm dependencies are pinned by the committed package-lock.json, and installs
run only reviewed lifecycle scripts. For the threat model and rationale behind
these controls, see Supply-chain security.
Install contracts
CI, the devcontainer, and Netlify install lock-exact and script-free, then
explicitly re-enable the one reviewed hook: the hugo-extended rebuild that
fetches the pinned Hugo binary. Per environment:
- CI:
npm run ci:min; jobs that build the site follow withnpm run ci:prepare. - Devcontainer:
npm run install:safe, the same contract, keeping optional dependencies. - Netlify:
npm run install:safe, run by the Netlify build command after the inert auto-install, between clean-working-tree checks:- Lock drift or any other Git-visible change fails the build.
- For failures on paths the install never touched, see Stale Netlify build cache below.
- Local:
npm run install:safe, or a standardnpm install, which follows the lock while it agrees withpackage.jsonand gates lifecycle scripts by the allowlist rather than disabling them; see local setup.
The nested Docsy theme setup follows the same contract: the prepare step
invokes Docsy’s own lock-exact, script-free theme-dependency install.
Stale Netlify build cache
Netlify keeps a build cache per deploy context:
- One for production
- One per already-built PR, seeded from the production cache on the PR’s first build.
Each cache includes a clone of the repository, and checking out a commit
that drops a git submodule leaves the submodule’s working tree in place, so a
removed submodule can ride a cache back into later builds as untracked residue
and fail the clean-working-tree checks: the deploy log shows the path in a
??-prefixed status line.
Clear the affected build cache rather than adding the path to .gitignore:
- Production:
- Clear cache and deploy site, under Deploys > Trigger deploy.
- Deploy Previews: each already-built PR holds its own cache copy, untouched
by a production clear after the fact.
- Clear it from the PR’s latest deploy page with Retry > Clear cache and retry with latest branch commit. There is no bulk clear across PRs.
After removing a git submodule, clear the production build cache as part of the removal, before the residue seeds per-PR caches.
Updating dependencies
Routine updates
npm run update:packages bumps package.json only. The
release cooldown applies to the offered versions. Then
regenerate the lock and commit both files together:
npm install --package-lock-only --ignore-scripts
Script-bearing packages
When adding or updating a package that has, or needs, an allowScripts entry,
the contributor making the change:
- Reviews the new version’s lifecycle scripts.
- Records the outcome, committed together with the dependency change and vetted
in PR review: a needed script as an exact-version approval, an unneeded one
as a name-level denial (
false, which needs no update on later bumps). - For a new approval, also adds the package to the Renovate automerge exclusion
in
.github/renovate.json5: every bump of an approved package needs the steps above, so its update PRs must wait for a contributor.
Lock-file maintenance
- You changed dependencies: regenerate the lock as in
routine updates and commit it together with
package.json. - Merge conflict on the lock file: take the
mainversion and rerun the regeneration command. - The lock file changed, but you didn’t change dependencies (a
postinstallcheck warns when an install does this): that signals drift; restore the lock and investigate rather than committing the rewrite.
Supply-chain controls
Release cooldown
Version resolution ignores releases younger than the configured minimum age.
- Enforcement:
min-release-agein.npmrc. - Scope:
- Only resolving operations are affected; lock-exact installs (
npm ci) don’t resolve versions. - npm gives project config precedence over user config, so a stricter cooldown
in your user
.npmrcis relaxed to the project value here; to keep yours for an invocation, set thenpm_config_min_release_ageenvironment variable, which outranks both.
- Only resolving operations are affected; lock-exact installs (
- Renovate: applies its own cooldown to the update PRs it opens, set by
minimumReleaseAgein.github/renovate.json5; longer for the updates that merge without human review.
Lifecycle-script allowlist
Installs run a package’s lifecycle scripts only when its exact name and version
are listed in the allowScripts allowlist:
- Enforcement: the
allowScriptsmap inpackage.json, made fail-closed bystrict-allow-scriptsin.npmrc. - Denials:
- An entry set to
falserecords a reviewed denial: the package installs, its script is skipped. - Denials grant nothing, so they cover the package by name, across versions.
- An entry set to
- Interplay with
--ignore-scripts:- The allowlist only filters: it never re-enables scripts that
ignore-scriptsdisables, so script-free installs run none, allowlisted or not. - A reviewed exception takes an explicit
--ignore-scripts=falseat the call site.
- The allowlist only filters: it never re-enables scripts that
npm version floor
Installs fail when the active npm is older than the engines floor: the oldest version that supports the controls above.
- Enforcement:
enginesinpackage.jsonsets the floor.engine-strictin.npmrcmakes it fail closed.
- Floor policy:
- The floor rises as npm fixes enforcement gaps in the controls.
- It follows npm versions bundled with Node LTS releases, so a default toolchain passes the check.
- Netlify:
- Netlify’s Node-bundled default npm may be older than the floor;
NPM_VERSIONinnetlify.tomlpins one that satisfies it. - Bump the pin at least when the floor rises.
- Netlify’s Node-bundled default npm may be older than the floor;
Inert Netlify auto-install
Netlify’s automatic install at the start of a build is
neutralized by NPM_FLAGS in netlify.toml:
--dry-run: npm resolves and logs what an install would change, but writes nothing.--ignore-scripts: lifecycle scripts stay disabled by explicit instruction, not as a side effect of the dry run.
Scope: NPM_FLAGS is a Netlify build setting, not npm config; it applies
only to the automatic install, never to the build command’s npm runs.
Defense in depth: the real install is npm ci, which
replaces node_modules wholesale, so auto-install or build-cache residue there
does not survive into the build even though node_modules is invisible to the
clean-working-tree checks (they see only Git-visible changes).
No bare npx
Repository wiring (package scripts, CI, helper scripts, contributor docs) never
invokes a bin as npx BIN: on a stale or missing node_modules, npx falls
back to the public registry and executes whatever package holds that name. Its
install prompt is no defense: it’s skipped in non-interactive contexts and
invites a reflexive yes elsewhere. Localized copies of contributor docs catch up
with this rule through drift tracking.
- Instead:
- Package scripts invoke dependency-provided bins directly; npm puts
node_modules/.binon theirPATH, and a missing bin fails loudly with zero registry traffic. - Contexts without that
PATHentry (docs, standalone scripts) usenpm exec --no -- BIN, which never installs.
- Package scripts invoke dependency-provided bins directly; npm puts
- Enforcement: review discipline; there is no automated check.