Dependency management

How the site installs, verifies, and updates its npm dependencies

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. The rebuild runs through scripts/rebuild-hugo-extended.mjs, which retries the fetch with bounded backoff and refuses to run while any HUGO_* installer override is set. Installs keep optional dependencies: npm delivers platform-specific binaries (for example, the Dart Sass compiler in sass-embedded) as optional dependencies selected by os/cpu, so omitting them breaks the build. Per environment:

  • CI: npm run ci:min; jobs that build the site follow with npm run ci:prepare.
  • Devcontainer: npm run install:safe, the same contract.
  • 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 standard npm install, which follows the lock while it agrees with package.json and 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 head branch name for Deploy Previews, seeded from the production cache on the name’s first build. Cache lineages die only by explicit clear: branch deletion is invisible to Netlify, so a branch recreated under the same name (recycled bot-branch names included) re-attaches to the old cache.

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 branch 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 branches.

Updating dependencies

Routine version bumps arrive as Renovate PRs, gated by the release cooldown, and known-vulnerability fixes arrive alert-driven (Security updates). The remaining cases are manual; in each, commit the regenerated lock together with any package.json change.

Manifest changes

Whether you edited package.json by hand or bumped every in-range version with npm run update:packages (the release cooldown applies to the versions offered), reconcile the lock with the changed manifest:

npm install --package-lock-only --ignore-scripts

Unlike npm update (below), this rewrites only what the manifest change requires, leaving other entries as pinned. A merge conflict on the lock file takes the same recipe: keep the main version and rerun the command.

Script-bearing packages

When adding or updating a package that has, or needs, an allowScripts entry, the contributor making the change:

  1. Reviews the new version’s lifecycle scripts.
  2. 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).
  3. 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.

Transitive refreshes

No schedule re-resolves the lock wholesale (resolution is deliberate). To refresh transitive dependencies, run the following on demand at the repository root (the lock also covers the scripts/generate-community-data workspace):

npm update --package-lock-only --ignore-scripts

The release cooldown applies, with a sharp edge: a dependency whose only satisfying versions are younger than the cooldown (an exact pin is the common case) fails the whole resolution (ETARGET) until one ages. When the young release is one you reviewed and vouch for, exempt that name alone; the cooldown stays on for the rest of the tree:

npm_config_min_release_age_exclude=PACKAGE_NAME \
  npm update --package-lock-only --ignore-scripts

Replace PACKAGE_NAME with the vouched-for package. Keep the exemption per-invocation; a standing entry in .npmrc would permanently waive the cooldown for that name. Also review the refreshed lock for major hops: npm update honors the manifests’ declared ranges, and a parent that widens a range can pull a new transitive major.

Unexpected lock changes

If the lock changed but you didn’t change dependencies (a postinstall check warns when an install does this), that signals drift: restore the lock and investigate rather than committing the rewrite.

Security updates

Known-vulnerability fixes don’t wait for the weekly update PRs; they arrive alert-driven:

  • GitHub Dependabot security updates: a repository-side setting (no dependabot.yml), able to patch direct and transitive dependencies; for npm that can mean rewriting parent manifest entries, not only the lock.
  • Renovate vulnerability-alert PRs: opened immediately, for direct dependencies.

The overlap is deliberate; an occasional duplicate PR is accepted. With scheduled lock re-resolves disabled by design, these alert-driven paths are the only automated route for transitive fixes, so the repository-side setting stays on. The two paths meet the release cooldown differently:

  • Dependabot security updates deliberately override every release-age gate (.npmrc included): a fix version younger than the cooldown can land, and vetting it is the reviewing maintainer’s job.
  • Renovate’s PR is subject to the .npmrc gate when it regenerates the lock, so a younger-than-cooldown fix arrives as a failed artifact update; adopting it early takes the scoped exemption run by a maintainer.

Supply-chain controls

Supply-chain audit

The supply-chain audit test, scripts/supply-chain-audit.test.mjs, verifies the controls below from committed files alone on every test:local-tools run, so a regressed control fails a test rather than waiting for an incident. For the verification principles behind the audit itself, see its design page.

When the audit fails on your PR, the assertion message states the expected condition; the common cases:

  • You bumped a dependency that has an allowScripts entry: follow script-bearing packages; the failure message names the version the entry must move to.
  • You changed an install-path script, .npmrc, or netlify.toml: that failure is the point. The audit pins the install surface so that every change to it gets a deliberate review. Update the corresponding assertion together with your change, and say why in the PR.

Never loosen an assertion just to get to green: each one enforces a control on this page, so first work out which control your change relaxes.

Out of the audit’s scope:

  • GitHub workflow files
  • Renovate configuration (.github/renovate.json5): reviewed like code, not audit-pinned
  • The Docsy theme’s own dependency install (audited upstream)
  • The build-half npm scripts past the install boundary

Release cooldown

Version resolution ignores releases younger than the configured minimum age.

  • Enforcement: min-release-age in .npmrc. The scripts/generate-community-data subproject is an npm workspace rather than a separate lock home, so the root .npmrc and lock govern its resolution too.
  • 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 .npmrc is relaxed to the project value here; to keep yours for an invocation, set the npm_config_min_release_age environment variable, which outranks both.
  • Renovate: applies its own cooldown to the update PRs it opens, set by minimumReleaseAge in .github/renovate.json5; longer for the updates that merge without human review. The preset-supplied 3-day npm cooldown (security:minimumReleaseAgeNpm) is excluded so that it can’t override these ages, its age exemptions included; caution: an upstream rename of that preset would silently re-admit it. Update types Renovate can’t date (such as pin, replacement, rollback) fall outside its cooldown: their PRs open normally, at most showing a permanently pending stability status (not a required check), so normal review is the gate.

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 allowScripts map in package.json, made fail-closed by strict-allow-scripts in .npmrc.
  • Denials:
    • An entry set to false records a reviewed denial: the package installs, its script is skipped.
    • Denials grant nothing, so they cover the package by name, across versions.
  • Interplay with --ignore-scripts:
    • The allowlist only filters: it never re-enables scripts that ignore-scripts disables, so script-free installs run none, allowlisted or not.
    • A reviewed exception takes an explicit --ignore-scripts=false at the call site.

npm version floor

Installs fail when the active npm is older than the engines floor: the oldest version that supports the controls above.

  • Enforcement:
  • Floor policy:
    • The floor rises as npm fixes enforcement gaps in the controls.
    • The committed .nvmrc pins a Node.js release whose bundled npm satisfies the floor, so CI, Netlify, and nvm-managed local setups pass it by construction; Renovate keeps the pin updated. (A floating .nvmrc such as lts/* can’t promise this: CI runners resolve it from possibly stale caches.)
  • Netlify:
    • Netlify’s Node-bundled default npm may be older than the floor; NPM_VERSION in netlify.toml pins one that satisfies it.
    • Bump the pin at least when the floor rises.

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/.bin on their PATH, and a missing bin fails loudly with zero registry traffic.
    • Contexts without that PATH entry (docs, standalone scripts) use npm exec --no -- BIN, which never installs.
  • Enforcement: review discipline; there is no automated check.