Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/pin-node-base-image.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions Dockerfile.railwayapp
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.test
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/integration/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions test/unit/ci/node-base-pin.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, { image?: string }> }

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)
})
})
Loading