The server gate ran node:22 while the dev gate ran node:24, and the two disagree about Intl.NumberFormat's compact currency padding and about which realm's Blob a Response hands back — so the frontend job had been red for 73 of its last 90 runs while the local gate was green, and nothing said so. Align everything on 24, with `.nvmrc` as the single source of truth: the CI container, the prod build stage, `engines`, and `@types/node` (which was two majors ahead of the runtime, so tsc was green over APIs the shipped engine does not have). The backend job's node only runs the checkout action, so it tracks the pin as hygiene — node 20 is EOL — but deliberately stays outside the contract. The pin is held by a contract test that reads every one of those places AND the engine running it, so the next drift is a red lane on its first run instead of a CI log nobody reads. Verified on node:24-bookworm (the CI lane: 228 files / 1552 tests, typecheck, lint, format:check, knip all exit 0) and on node:24-alpine (the prod build stage, v24.21.0, SPA built). Each assertion of the new test was mutation-checked red.
99 lines
5.1 KiB
Docker
99 lines
5.1 KiB
Docker
# syntax=docker/dockerfile:1
|
|
# Build/version info. APP_VERSION comes from the committed VERSION file (always correct,
|
|
# even for a plain `docker compose build`); GIT_SHA/BUILD_DATE are best-effort build-args
|
|
# the deploy scripts pass. An explicit APP_VERSION build-arg still overrides the file.
|
|
#
|
|
# GIT_SHA/BUILD_DATE differ on EVERY build: declare them as late as possible in each stage —
|
|
# anything below them can never cache.
|
|
ARG APP_VERSION=
|
|
ARG GIT_SHA=unknown
|
|
ARG BUILD_DATE=
|
|
|
|
# Stage 1: build the frontend SPA
|
|
# The major is pinned to `.nvmrc` and held there by frontend/src/lib/nodeEngine.contract.test.ts:
|
|
# this image, the CI gate's image and the dev machine must be ONE engine (see that test for why).
|
|
FROM node:24-alpine AS frontend
|
|
WORKDIR /fe
|
|
# Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules.
|
|
COPY frontend/package.json frontend/package-lock.json ./
|
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
|
COPY frontend/ ./
|
|
COPY VERSION ./.version
|
|
# Version args land here — immediately before the only step that reads them — so they cannot
|
|
# invalidate the dependency layers above.
|
|
ARG APP_VERSION
|
|
ARG GIT_SHA
|
|
ARG BUILD_DATE
|
|
# Vite inlines VITE_*-prefixed env at build time; version falls back to the VERSION file.
|
|
RUN VITE_APP_VERSION="${APP_VERSION:-$(cat ./.version)}" \
|
|
VITE_GIT_SHA="$GIT_SHA" \
|
|
VITE_BUILD_DATE="$BUILD_DATE" \
|
|
npm run build
|
|
|
|
# Stage 2: backend runtime, serving the built SPA
|
|
FROM python:3.12-slim
|
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
PYTHONUNBUFFERED=1 \
|
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
|
|
|
WORKDIR /app
|
|
|
|
# Create the app user BEFORE the copies so files land already-owned via COPY --chown (a trailing
|
|
# `chown -R /app` would rewrite every file into a duplicate layer). /downloads must be appuser-owned
|
|
# so a fresh named volume mounted there inherits that ownership (Docker copies the image dir's
|
|
# ownership into a new empty volume) — the worker/API can then write without a manual chown. A bind
|
|
# mount to a host path instead needs that path writable by this uid (see docs/self-hosting.md).
|
|
RUN adduser --disabled-password --gecos "" appuser \
|
|
&& mkdir -p /downloads \
|
|
&& chown appuser /app /downloads
|
|
|
|
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1).
|
|
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
|
|
# no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264.
|
|
# Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp
|
|
# uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO
|
|
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
|
ARG DENO_VERSION=v2.9.1
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
|
|
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
|
|
&& unzip -o /tmp/deno.zip -d /usr/local/bin \
|
|
&& chmod +x /usr/local/bin/deno \
|
|
&& rm /tmp/deno.zip \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Intel NEO OpenCL runtime (intel-opencl-icd) — needed by the Plex transcode's HDR/10-bit HW path, which
|
|
# tone-maps HDR→SDR on the GPU via `tonemap_opencl` (Gen 9.5 / UHD 630 can't tonemap in the VAAPI VPP, so
|
|
# OpenCL is the only HW route; without this the 10-bit branch drops to CPU libx264). NEO was REMOVED from
|
|
# Debian trixie (this base = python:3.12-slim = trixie), so it's pulled from **bookworm** — the 22.43 there
|
|
# still supports Gen 9.5 and its .deb runs on trixie (verified live on the prod UHD 630). The bookworm apt
|
|
# source is added only for this one install and removed after, so it can't drift the rest of the image.
|
|
# Harmless on hosts with no /dev/dri or no OpenCL use (the runtime just sits unused).
|
|
RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.pgp\n' > /etc/apt/sources.list.d/bookworm.sources \
|
|
&& apt-get update \
|
|
&& apt-get install -y --no-install-recommends -t bookworm intel-opencl-icd \
|
|
&& rm /etc/apt/sources.list.d/bookworm.sources \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
|
|
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
|
|
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
|
|
COPY backend/requirements.lock .
|
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
|
pip install --upgrade pip && pip install -r requirements.lock
|
|
|
|
COPY --chown=appuser backend/ .
|
|
# Read at runtime by config.py as the app_version (a committed single source of truth).
|
|
COPY --chown=appuser VERSION ./VERSION
|
|
COPY --chown=appuser --from=frontend /fe/dist ./app/static_spa
|
|
|
|
# Version stamps LAST — keep them below everything expensive (see the header note).
|
|
ARG GIT_SHA
|
|
ARG BUILD_DATE
|
|
ENV GIT_SHA=$GIT_SHA \
|
|
BUILD_DATE=$BUILD_DATE
|
|
|
|
USER appuser
|
|
EXPOSE 8000
|
|
CMD ["sh", "entrypoint.sh"]
|