M2-T12: multi-stage Dockerfile (node build -> python runtime) + frontend CI

- Dockerfile: node:22-slim stage runs npm ci + npm run build; python runtime
  stage COPY --from copies dist to /app/frontend/dist (matches SPA_DIST_DIR);
  runtime image has no node
- .dockerignore: exclude frontend/node_modules and frontend/dist from context
- .github/workflows/frontend.yml: npm ci + codegen-sync + lint/typecheck/test/build
- tests/test_deployment.py: skip COPY --from sources in the context-existence
  check; assert the multi-stage frontend build wiring
- verified with a real docker build (image serves SPA, no node at runtime)
This commit is contained in:
2026-06-13 11:48:32 +02:00
parent f8b1e5fc71
commit 51f712f602
4 changed files with 132 additions and 5 deletions
+3
View File
@@ -8,3 +8,6 @@ data
openapi
src
# Frontend host build artifacts — built inside the node stage, not needed from context
frontend/node_modules
frontend/dist
+49
View File
@@ -0,0 +1,49 @@
name: frontend
on:
push:
branches:
- "**"
pull_request:
workflow_dispatch:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Check codegen is in sync
working-directory: frontend
run: |
npm run codegen
git diff --exit-code src/api/schema.d.ts
- name: Lint
working-directory: frontend
run: npm run lint
- name: Type-check
working-directory: frontend
run: npm run typecheck
- name: Test
working-directory: frontend
run: npm run test
- name: Build
working-directory: frontend
run: npm run build
+15
View File
@@ -1,3 +1,15 @@
# Stage 1: build the React SPA
FROM node:22-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: python runtime (no node)
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -16,6 +28,9 @@ COPY docker ./docker
COPY README.md ./
RUN mkdir -p /app/data
# Copy the built SPA dist from the frontend-build stage
COPY --from=frontend-build /frontend/dist ./frontend/dist
EXPOSE 8000
ENTRYPOINT ["/app/docker/entrypoint.sh"]
+65 -5
View File
@@ -65,16 +65,25 @@ def test_image_defaults_to_uvicorn_only() -> None:
def test_dockerfile_copy_sources_exist() -> None:
"""Every path the Dockerfile COPYs from the build context must exist in the
repo, so the image build cannot break on a stale COPY of a removed path
(e.g. the retired alembic_location / alembic_poo chains)."""
"""Every path the Dockerfile COPYs *from the build context* must exist in
the repo, so the image build cannot break on a stale COPY of a removed path
(e.g. the retired alembic_location / alembic_poo chains).
COPY instructions that use --from=<stage> copy from a build stage, not from
the host build context, so their source paths are intentionally skipped here
(they would not correspond to repo paths)."""
dockerfile = (PROJECT_ROOT / "Dockerfile").read_text()
for raw_line in dockerfile.splitlines():
line = raw_line.strip()
if not line.startswith("COPY "):
continue
# Drop the "COPY" keyword and any flags (e.g. --from=, --chown=).
tokens = [t for t in line.split()[1:] if not t.startswith("--")]
tokens = line.split()[1:]
# Skip inter-stage copies: --from=<stage> means the source is inside
# a build stage, not the host build context.
if any(t.startswith("--from=") for t in tokens):
continue
# Drop remaining flags (e.g. --chown=, --chmod=).
tokens = [t for t in tokens if not t.startswith("--")]
# COPY <src...> <dest>: the last token is the destination.
for src in tokens[:-1]:
assert (PROJECT_ROOT / src).exists(), (
@@ -82,6 +91,57 @@ def test_dockerfile_copy_sources_exist() -> None:
)
def test_dockerfile_multistage_frontend_build() -> None:
"""The Dockerfile must have a node frontend-build stage that builds the SPA,
and the runtime (python) stage must copy the dist from that stage.
The runtime stage must not include a node base image."""
dockerfile = (PROJECT_ROOT / "Dockerfile").read_text()
# 1. A named frontend-build stage using a node base image must exist.
assert "AS frontend-build" in dockerfile, (
"Dockerfile must have a 'AS frontend-build' node build stage"
)
node_stage_lines = [
ln.strip() for ln in dockerfile.splitlines()
if ln.strip().startswith("FROM") and "frontend-build" in ln
]
assert node_stage_lines, "No FROM line found that declares the frontend-build stage"
assert any("node" in ln.lower() for ln in node_stage_lines), (
"The frontend-build stage must use a node base image"
)
# 2. The frontend-build stage must run `npm run build`.
assert "npm run build" in dockerfile, (
"Dockerfile must run 'npm run build' in the frontend-build stage"
)
# 3. The runtime stage must COPY the dist from frontend-build into frontend/dist.
copy_from_lines = [
ln.strip() for ln in dockerfile.splitlines()
if ln.strip().startswith("COPY") and "--from=frontend-build" in ln
]
assert copy_from_lines, (
"Dockerfile must have a 'COPY --from=frontend-build' instruction in the runtime stage"
)
# The destination must land at (or under) frontend/dist so it matches SPA_DIST_DIR default.
assert any("frontend/dist" in ln for ln in copy_from_lines), (
"The COPY --from=frontend-build must target ./frontend/dist"
)
# 4. The runtime stage base image must be python, not node.
from_lines = [ln.strip() for ln in dockerfile.splitlines() if ln.strip().startswith("FROM")]
# All FROM lines except the frontend-build stage must use python.
runtime_from_lines = [ln for ln in from_lines if "frontend-build" not in ln]
assert runtime_from_lines, "No runtime FROM line found"
for ln in runtime_from_lines:
assert "python" in ln.lower(), (
f"Runtime stage base image must be python, got: {ln}"
)
assert "node" not in ln.lower(), (
f"Runtime stage must not use a node base image, got: {ln}"
)
def test_migration_runner_initializes_and_is_idempotent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: