From 4012fbf82a45a4191ca9d9d878bb336be0b8548b Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 31 Oct 2026 15:28:13 -0400 Subject: [PATCH 1/2] build: pin the node base image in every file that names one Dockerfile pinned node:24.22.1-alpine by digest, but Dockerfile.railwayapp, Dockerfile.test and the two compose files still said node:24-alpine, so the relay image, the CI image, the Railway image and both migration containers could each start from a different base - and a floating tag moves under a release, so the same commit built twice could run different node code. All five now carry the same tag and digest, and a unit test holds them together: it fails if any reference loses its digest, and it fails if two different references appear, which is how #1005 left these behind. Verified: the new spec passes; reverting Dockerfile.test to node:24-alpine fails it on both counts; the rest of test/unit/ci is 26 passing. --- Dockerfile.railwayapp | 4 +- Dockerfile.test | 2 +- docker-compose.yml | 2 +- test/integration/docker-compose.yml | 2 +- test/unit/ci/node-base-pin.spec.ts | 82 +++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 test/unit/ci/node-base-pin.spec.ts diff --git a/Dockerfile.railwayapp b/Dockerfile.railwayapp index bba5109a5..f2abb8df9 100644 --- a/Dockerfile.railwayapp +++ b/Dockerfile.railwayapp @@ -1,7 +1,7 @@ ## Author Saransh Sharma @cynsar foundation ARG PNPM_VERSION=10.33.0 -FROM node:24-alpine as build +FROM node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 AS build ARG PNPM_VERSION ARG PORT @@ -30,7 +30,7 @@ COPY . . RUN pnpm build -FROM node:24-alpine +FROM node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 # Install dumb-init for proper signal handling RUN apk add --no-cache dumb-init diff --git a/Dockerfile.test b/Dockerfile.test index 328cdb5b4..87fd12e4c 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,6 +1,6 @@ ARG PNPM_VERSION=10.33.0 -FROM node:24-alpine +FROM node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 ARG PNPM_VERSION diff --git a/docker-compose.yml b/docker-compose.yml index b803246c7..d640b0232 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -126,7 +126,7 @@ services: retries: 5 nostream-migrate: - image: node:24-alpine + image: node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 container_name: nostream-migrate environment: DB_HOST: nostream-db diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml index 9baea88a7..7aea5c995 100644 --- a/test/integration/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -68,7 +68,7 @@ services: timeout: 5s retries: 5 migrations-test: - image: node:24-alpine + image: node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 environment: DB_HOST: db-test DB_PORT: 5432 diff --git a/test/unit/ci/node-base-pin.spec.ts b/test/unit/ci/node-base-pin.spec.ts new file mode 100644 index 000000000..c39e01ed0 --- /dev/null +++ b/test/unit/ci/node-base-pin.spec.ts @@ -0,0 +1,82 @@ +import chai from 'chai' +import { readFileSync, readdirSync } from 'fs' +import yaml from 'js-yaml' +import { join } from 'path' + +const { expect } = chai + +/** + * One node base image, pinned by digest, everywhere this repo names one. + * + * #1005 pinned the build stages of `Dockerfile` while `Dockerfile.railwayapp`, + * `Dockerfile.test` and both compose files still said `node:24-alpine`, so the + * relay image, the CI image, the Railway image and the two migration containers + * could each start from a different base. A floating tag also moves under a + * release: the same commit built twice can run different node code. A tag alone + * is not enough for the same reason, which is why the digest is required too. + * + * When the base moves, it moves in every file in the same commit — this test is + * the thing that makes that true instead of hoped for. + */ + +type Compose = { services?: Record } + +const NODE_REF = /^node:/ +const DIGEST_PINNED = /@sha256:[0-9a-f]{64}$/ + +const dockerfiles = (): string[] => + readdirSync(process.cwd()) + .filter((file) => /^Dockerfile(\.[\w-]+)?$/.test(file)) + .sort() + +const composeFiles = (): string[] => { + const roots = readdirSync(process.cwd()).filter((file) => /^docker-compose(\.[\w-]+)?\.ya?ml$/.test(file)) + const integration = readdirSync(join(process.cwd(), 'test/integration')) + .filter((file) => /^docker-compose(\.[\w-]+)?\.ya?ml$/.test(file)) + .map((file) => join('test/integration', file)) + + return [...roots, ...integration].sort() +} + +/** Every `FROM node:…` and `image: node:…` in the repo, with where it came from. */ +const references = (): Array<{ where: string; ref: string }> => [ + ...dockerfiles().flatMap((file) => + readFileSync(join(process.cwd(), file), 'utf-8') + .split('\n') + .map((line, index) => ({ line, where: `${file}:${index + 1}` })) + .filter(({ line }) => /^FROM\s/.test(line)) + .map(({ line, where }) => ({ where, ref: line.split(/\s+/)[1] })) + .filter(({ ref }) => NODE_REF.test(ref)) + ), + ...composeFiles().flatMap((file) => { + const services = (yaml.load(readFileSync(join(process.cwd(), file), 'utf-8')) as Compose).services ?? {} + + return Object.entries(services) + .filter(([, service]) => NODE_REF.test(service.image ?? '')) + .map(([name, service]) => ({ where: `${file}: ${name}`, ref: service.image as string })) + }) +] + +describe('node base image pin', () => { + const refs = references() + + it('finds the base image wherever it is named', () => { + // A glob that silently matches nothing would make every check below pass. + expect(refs.map(({ where }) => where)).to.include.members([ + 'Dockerfile:3', + 'Dockerfile.railwayapp:4', + 'docker-compose.yml: nostream-migrate', + 'test/integration/docker-compose.yml: migrations-test' + ]) + }) + + it('pins each one by digest', () => { + expect(refs.filter(({ ref }) => !DIGEST_PINNED.test(ref)).map(({ where, ref }) => `${where} -> ${ref}`)).to.deep.equal( + [] + ) + }) + + it('uses the same reference everywhere', () => { + expect([...new Set(refs.map(({ ref }) => ref))]).to.have.length(1) + }) +}) From ad8c5c4b9d2e6df34704741465d2505d811fe7b7 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 31 Oct 2026 15:32:29 -0400 Subject: [PATCH 2/2] chore: changeset for the node base image pin --- .changeset/pin-node-base-image.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/pin-node-base-image.md diff --git a/.changeset/pin-node-base-image.md b/.changeset/pin-node-base-image.md new file mode 100644 index 000000000..10501e212 --- /dev/null +++ b/.changeset/pin-node-base-image.md @@ -0,0 +1,9 @@ +--- +"nostream": patch +--- + +build: pin the node base image in every file that names one + +`Dockerfile` pinned `node:24.22.1-alpine` by digest, but `Dockerfile.railwayapp`, `Dockerfile.test`, `docker-compose.yml` and `test/integration/docker-compose.yml` still said `node:24-alpine`. The relay image, the CI image, the Railway image and both migration containers could each start from a different base, and a floating tag moves under a release — so the same commit built twice could run different node code without a change saying so. + +All five now carry the same tag and digest, and a unit test holds them there: it fails if any reference loses its digest, and it fails if two different references appear.