diff --git a/.changeset/pin-node-base-image.md b/.changeset/pin-node-base-image.md new file mode 100644 index 00000000..10501e21 --- /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. diff --git a/Dockerfile.railwayapp b/Dockerfile.railwayapp index bba5109a..f2abb8df 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 328cdb5b..87fd12e4 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 b803246c..d640b023 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 9baea88a..7aea5c99 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 00000000..c39e01ed --- /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) + }) +})