Files
Zachary Lowden 819c35a242 chore: pin the Node base image to an exact patch, align .nvmrc and engines (#3779)
* chore: pin the Node base image to an exact patch and align .nvmrc / engines

The root Dockerfile built on `node:24-alpine3.24`, which resolves at build
time. The same Dockerfile rebuilt tomorrow can therefore produce a different
Node — and a different V8 — with no commit recording that it changed, which
leaves "did our fix miss a case?" and "did the engine change under us?" with
no evidence separating them.

Pin both stages to `node:24.19.0-alpine3.24`, set `.nvmrc` to match, and
narrow `engines.node` from `>=22.0.0` to `>=24.0.0 <25`.

No-op today, verified rather than assumed: `node:24-alpine3.24` and
`node:24.19.0-alpine3.24` resolve to the same manifest
(sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43),
that image's config carries NODE_VERSION=24.19.0, and the image currently
serving production reports v24.19.0.

`engines` is bracketed at MAJOR granularity on purpose: a patch-exact floor
would make it a fourth place to bump on every base-image patch, which is the
drift this change is about rather than a fix for it. `engine-strict` is not
set, so pnpm warns rather than failing, and nothing in CI reads the field.

Add src/__tests__/node-version-consistency.test.ts to hold the invariant:
every root-Dockerfile node stage pins an exact patch, the stages agree, and
.nvmrc and engines agree with them — plus a population floor, because every
one of those verdicts is universally quantified over the extractor's output
and an empty list satisfies all of them. Blinding the extractor was watched
to leave the pin verdict green and red only that control.

Only the root Dockerfile is pinned. The apps/* and containers/* images float
in the same way but are separate services on separate cadences, so pinning
them is a real behaviour change and belongs in its own PR.

Also removes deployment-specific identifiers from three comments in
src/instrumentation.node.ts — this is a public repo — and corrects two
comments in src/server/liveness-heartbeat.ts that restated a base-image tag
(one of them made stale by this very change). Comments only; no code change.

* test(node-version): close two Dockerfile spellings the extractor was blind to

The `FROM node:` extractor anchored at `/^FROM\s+.../gm`, which Docker's own
grammar is looser than. Two legal forms slipped an UNPINNED floating stage past
the whole file with all seven tests green — verified by appending each to the
real Dockerfile and watching the suite pass:

  from node:24-alpine3.24 AS sneaky       (lowercase directive)
    FROM node:24-alpine3.24 AS indented   (leading whitespace)

Both were built under Docker to confirm they are valid: the lowercase form
builds with only a ConsistentInstructionCasing warning, the indented form with
no warning at all. Nothing else covered this — there is no Dockerfile linter in
CI.

The pattern is now `/^[ \t]*FROM[ \t]+(?:--\S+[ \t]+)*node:(\S+)/gim`. `[ \t]`
rather than `\s` because under `m` a `\s` spans newlines and can join a bare
`FROM` to a `node:` on the next line into a stage that does not exist. The `i`
flag's reach over `node:` is defensive only: an uppercase image reference is not
legal, so the only thing it can match is a stage that could never build.

The count control was the deeper problem: an equality on the MATCHED count
cannot move when the extractor goes blind, so an added-but-unmatched stage left
it sitting at 2. Two controls replace that reasoning with a measurement — the
total `FROM` directive count, which moves for any stage in any spelling, and a
coverage control comparing the extractor against a deliberately dumber second
reader that tokenises instead of pattern-matching the image reference.

Also closed a vacuity hole found while re-running the mutant battery: the pin
verdict was a for-of loop, and a loop over an empty list is green, so an
extractor returning nothing produced the file's strongest-sounding pass while
checking no stage at all. It now asserts its population first, and the pin rule
moved into a named function driven over a fixture — asserted only against live
data, which does not violate it, the rule could be neutered to a constant and
stay green forever.

Message fixes, no assertion weakened:
  - Moving both stages to `node@sha256:...` is STRONGER than a tag pin, but red
    several tests with wording blaming the pattern. The failure now says so.
  - `>=24.0.0 <25.0.0` denotes exactly the range `>=24.0.0 <25` does and was
    rejected on spelling. Accepted now; `<25.0.1` is a genuinely different range
    that admits major 25, and is still rejected, with a control pinning both.
  - Corrected a control comment that cited a double space after `FROM` as the
    likely gap. `[ \t]+` has always handled runs of spaces and tabs; the comment
    pointed the next reader away from the two gaps that were open.

Separately, a docblock still named a private infrastructure repository and one
of its manifest filenames. This is a public repository; the reference carried no
value to a public reader and is gone.
2026-08-10 13:02:22 -05:00

2 lines
8 B
Plaintext