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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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:
|
||||
|
||||
Reference in New Issue
Block a user