diff --git a/.dockerignore b/.dockerignore index 1b11b95..a28ffb8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..c38f58e --- /dev/null +++ b/.github/workflows/frontend.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index d06d5b1..ded6532 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 5d84fb1..5038f13 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -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= 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= 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 : 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: