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
|
openapi
|
||||||
src
|
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
|
FROM python:3.12-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
@@ -16,6 +28,9 @@ COPY docker ./docker
|
|||||||
COPY README.md ./
|
COPY README.md ./
|
||||||
RUN mkdir -p /app/data
|
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
|
EXPOSE 8000
|
||||||
|
|
||||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||||
|
|||||||
@@ -65,16 +65,25 @@ def test_image_defaults_to_uvicorn_only() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_dockerfile_copy_sources_exist() -> None:
|
def test_dockerfile_copy_sources_exist() -> None:
|
||||||
"""Every path the Dockerfile COPYs from the build context must exist in the
|
"""Every path the Dockerfile COPYs *from the build context* must exist in
|
||||||
repo, so the image build cannot break on a stale COPY of a removed path
|
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)."""
|
(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()
|
dockerfile = (PROJECT_ROOT / "Dockerfile").read_text()
|
||||||
for raw_line in dockerfile.splitlines():
|
for raw_line in dockerfile.splitlines():
|
||||||
line = raw_line.strip()
|
line = raw_line.strip()
|
||||||
if not line.startswith("COPY "):
|
if not line.startswith("COPY "):
|
||||||
continue
|
continue
|
||||||
# Drop the "COPY" keyword and any flags (e.g. --from=, --chown=).
|
tokens = line.split()[1:]
|
||||||
tokens = [t for t in line.split()[1:] if not t.startswith("--")]
|
# 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.
|
# COPY <src...> <dest>: the last token is the destination.
|
||||||
for src in tokens[:-1]:
|
for src in tokens[:-1]:
|
||||||
assert (PROJECT_ROOT / src).exists(), (
|
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(
|
def test_migration_runner_initializes_and_is_idempotent(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user