Blog
Programming challenges, experiments, and build notes behind short videos.
Programming challenges, experiments, and build notes behind short videos.

A staging deploy started failing in a way no local machine could reproduce: npm ci rejected the lockfile. The lockfile was fine. The code was fine. The cause took embarrassingly long to find — the lockfile had been regenerated on a laptop running one Node major, while the Docker image built with another. The fix that finally stuck was regenerating the lockfile inside the image (docker run node:24-alpine npm install), because nothing in the repo actually said which Node was the real one.
That last part is the interesting bit. The .nvmrc said one thing. The Dockerfiles said another. CI quietly tested a third. Every one of those files was locally reasonable, added by a sensible person on a sensible day — and they had been silently voting against each other for weeks.
Once you notice the shape, you see it in every repo. The .nvmrc is from the project's first week. engines.node got added during some incident and never touched again. FROM node:X-alpine was copy-pasted between Dockerfiles. A workflow hardcodes node-version: 20 because that was current when someone wrote the deploy job. A netlify.toml remembers a migration from two years ago.
The existing tooling all checks one pair at a time: nvmrc-check compares the Node you are running against one file, ls-engines checks your dependency graph against your Node, engine-strict yells at install time, one machine at a time. Nothing compares the declarations against each other — and the space between declarations is exactly where my lockfile bug lived.
So, in the tradition of next-env-audit: if the check doesn't exist, build the check.
One command, no config, static analysis only:
npx node-driftIt recursively finds every place a repo declares a Node version — .nvmrc, .node-version, engines.node and volta.node, asdf/mise files, every FROM node:… across all Dockerfile stages, compose services, .gitlab-ci.yml, GitHub Actions setup-node steps and job containers, netlify.toml, devcontainers — and then judges the set as a whole:
dev pins
✓ .nvmrc 22
docker & deploy
✖ Dockerfile FROM node (stage "build") node:24-alpine
✖ netlify.toml NODE_VERSION 18
ci
✓ ci.yml setup-node matrix 20, 22, 24
✖ deploy.yml setup-node (job "deploy") 20
this repo believes in 4 different Node versions (18, 20, 22, 24). pick one.Report-only by default, exit code gating with --fail-on drift for CI, --json for machines.
The naive version of this tool would flag every mismatch and be unusable, because not every disagreement is a bug. Most of the design work went into the rules, not the parsers.
First, the anchor: what does this repo mean by "our Node version"? node-drift picks it by precedence — volta.node, then .nvmrc, then .node-version, then asdf/mise — or you force it with --expect 22. Everything else is judged against the anchor, at major granularity: a Docker image on a different major is an error, 22.9 vs 22.11 between pins is a note.
Second, CI matrices are respected. A matrix testing Node 20, 22, and 24 is not drift — it is coverage, and a tool that flags it gets uninstalled the same afternoon. The rules that survived:
And third, floating versions get called out: node:latest, lts/*, an untagged FROM node. They are not wrong today; they quietly change meaning at every Node release, which is its own kind of drift.
Eleven file formats, each with one delightful gotcha. Dockerfiles need ARG substitution including ${VAR:-fallback}, and multi-stage builds where FROM build references a stage, not an image. .nvmrc accepts LTS codenames, so lts/jod has to resolve to 22. GitHub Actions allows node-version-file: .nvmrc, which the tool follows to its target and marks as the good practice it is.
My favorite: in YAML, node-version: 22.10 unquoted is the number 22.1. Your CI has been testing Node 22.1 — well, whatever setup-node resolves that to — since the day you wrote it. node-drift detects the lost trailing zero and tells you to quote it.
The rule that kept every parser honest: never guess. An unresolvable ${{ inputs.node }}, a digest-pinned image, an ARG with no default — all become explicit notes in the report, not silent assumptions. False positives are how report-only tools die.
Before publishing I ran it across eleven of my own repos. Three had genuine drift — a CMS Dockerfile one major behind the rest of its monorepo, a CI verify job two majors ahead of the image it was supposed to verify, and, pleasingly, the exact repo whose lockfile incident started this whole thing still carried the mismatch that caused it. Zero false positives across the other eight.
The tool's own repo audits itself in CI on every push, Linux and Windows both. Its verdict: "everyone agrees on Node 22. suspiciously disciplined."
The tool is called nodrift. Before writing a line of code I checked npm — npm view nodrift returned 404, name free, great. Then, at publish time: 403 Forbidden — package name too similar to existing package no-drift.
npm's typosquat protection normalizes punctuation, so no-drift — an unrelated timer library, untouched since 2022 — permanently owns every spelling of the name. Lesson recorded: a 404 on a package name does not mean the name is claimable. The npm package is node-drift; the installed command is still plain nodrift; both spellings work with npx.
npx node-drift # report
npx node-drift --fail-on drift # gate your CISource, drift rules, and the fixture repos are on GitHub. If your repo has a creative way of declaring a Node version that node-drift misses — that is exactly the kind of bug report I want. And if you run it and it says your repo believes in one Node version: suspiciously disciplined. I have yet to see it.

