Fusionner web+api en une image, séparer la CI, ajouter la release
- express-tools: ExpressServer.serveStaticFrontend() sert le build du frontend (assets + fallback SPA), monté après les routes API et avant le 404 JSON. Opt-in via FRONTEND_DIST_DIR (uniquement défini dans l'image Docker) — le dev natif (dev:api/dev:web) est inchangé. - apps/api/Dockerfile: build aussi apps/web, embarque son dist dans le runtime ; corrige au passage l'oubli de packages/date-tools. Supprime apps/web/Dockerfile et nginx.conf (plus de conteneur nginx séparé). - docker-compose.yml: un seul service "app" (postgres + app), un seul port APP_PORT, plus de WEB_PORT/CORS_ORIGIN à coordonner entre deux origines. Garde `build:` (pas de registre — Portainer build depuis le repo Git). - ci.yml: éclate le job unique lint-and-test+e2e en 4 jobs indépendants (lint/test/build/e2e), sans chaînage, déclenchés sur chaque push (toute branche) + PR vers main. - release.yml (nouveau): sur tag vX.Y.Z, sanity-build de l'image Docker, GitHub Release avec changelog auto-généré, puis notification best-effort du webhook Portainer (secret PORTAINER_WEBHOOK_URL). - README: documente le conteneur unique et le pipeline de release.
This commit is contained in:
parent
f63f9af544
commit
c2cc773c7b
12 changed files with 194 additions and 48 deletions
|
|
@ -6,11 +6,11 @@ POSTGRES_PASSWORD=changeme
|
|||
POSTGRES_DB=batchcooking
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Used by docker-compose.yml's "api" service (Docker-only — the native
|
||||
# Used by docker-compose.yml's "app" service (Docker-only — the native
|
||||
# `pnpm dev:api` workflow reads apps/api/.env instead, set both when using
|
||||
# both workflows). Required, no default on purpose — generate your own.
|
||||
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||
|
||||
# Optional — host ports for the Docker review stack (docker-compose.yml)
|
||||
# API_PORT=3000
|
||||
# WEB_PORT=8080
|
||||
# Optional — host port for the Docker review stack's single app container
|
||||
# (docker-compose.yml), serving both the API and the built frontend.
|
||||
# APP_PORT=3000
|
||||
|
|
|
|||
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
|
|
@ -1,8 +1,11 @@
|
|||
name: CI
|
||||
|
||||
# Every push, on every branch — not just main — so build breakage shows up
|
||||
# on the first commit of a branch, not only once a PR targets main.
|
||||
# pull_request is kept alongside push for fork PRs, where push events never
|
||||
# reach this repo.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
|
|
@ -12,7 +15,25 @@ env:
|
|||
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
# Four independent jobs, no needs: between them — each starts in parallel
|
||||
# and reports as its own check, instead of the previous single chained
|
||||
# "lint-and-test then e2e" pipeline.
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
|
|
@ -39,15 +60,27 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm --filter api exec prisma migrate deploy
|
||||
- run: pnpm --filter api test
|
||||
- run: pnpm --filter api test:bdd
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
|
|||
55
.github/workflows/release.yml
vendored
Normal file
55
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
name: Release
|
||||
|
||||
# No image registry involved: Portainer is wired to this repo's Git remote
|
||||
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
|
||||
# that the image actually builds at the tagged commit, publishes a GitHub
|
||||
# Release, then (best-effort) pings Portainer's deploy webhook so it doesn't
|
||||
# have to wait for its own polling interval.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*.*.*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sanity-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Build only, no push, no registry — just confirms Portainer will be
|
||||
# able to build this same Dockerfile successfully at this tag before
|
||||
# anyone points it at this ref.
|
||||
- run: docker build -f apps/api/Dockerfile .
|
||||
|
||||
github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: sanity-build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Auto-generated from merged PRs since the previous tag — no
|
||||
# changelog tooling to maintain, consistent with there being no
|
||||
# versioning tooling elsewhere in the repo yet.
|
||||
generate_release_notes: true
|
||||
|
||||
notify-portainer:
|
||||
runs-on: ubuntu-latest
|
||||
needs: github-release
|
||||
steps:
|
||||
- name: Trigger Portainer redeploy
|
||||
# Best-effort: does nothing (and doesn't fail the workflow) until
|
||||
# the PORTAINER_WEBHOOK_URL repo secret is set — grab that URL from
|
||||
# the stack's webhook setting in Portainer and add it as a secret
|
||||
# named PORTAINER_WEBHOOK_URL (see README).
|
||||
env:
|
||||
PORTAINER_WEBHOOK_URL: ${{ secrets.PORTAINER_WEBHOOK_URL }}
|
||||
run: |
|
||||
if [ -z "$PORTAINER_WEBHOOK_URL" ]; then
|
||||
echo "PORTAINER_WEBHOOK_URL not set — skipping, Portainer will pick this up on its next poll."
|
||||
exit 0
|
||||
fi
|
||||
curl -fsS -X POST "$PORTAINER_WEBHOOK_URL"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -147,3 +147,6 @@ Projet batch cooking.pdf
|
|||
|
||||
# Throwaway HTML mockups used to review a design before implementing it
|
||||
tmp-mockups/
|
||||
|
||||
# IA
|
||||
.claude/
|
||||
47
README.md
47
README.md
|
|
@ -105,7 +105,7 @@ pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev auto
|
|||
pnpm build # build de tous les workspaces
|
||||
```
|
||||
|
||||
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute lint + tests + build sur chaque push/PR vers `main`, puis les tests e2e Cypress.
|
||||
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute quatre jobs indépendants (`lint`, `test`, `build`, `e2e`) en parallèle, sur chaque push (toutes branches) et sur chaque PR vers `main` — pas de chaînage entre eux, chacun apparaît comme son propre check. Voir aussi [Déploiement](#déploiement) pour le pipeline de release (`.github/workflows/release.yml`).
|
||||
|
||||
### Cucumber (apps/api)
|
||||
|
||||
|
|
@ -132,6 +132,46 @@ qui n'existent pas encore).
|
|||
> explicitement CommonJS ; les steps/world restent en `.ts` ESM classique et sont
|
||||
> chargés via `tsx` (`NODE_OPTIONS=--import=tsx`, voir le script `test:bdd`).
|
||||
|
||||
## Déploiement
|
||||
|
||||
Une seule image Docker (`apps/api/Dockerfile`) sert à la fois l'API et le frontend
|
||||
buildé — plus de conteneur nginx séparé pour `apps/web`. Le stage `build` compile
|
||||
`apps/api` **et** `apps/web` (`pnpm --filter web build`), le stage `runtime` copie
|
||||
le résultat (`apps/web/dist`) à côté de l'API ; au démarrage, `apps/api/src/app.ts`
|
||||
sert ce dossier statique (fallback SPA compris, pour le routing react-router côté
|
||||
client) via `FRONTEND_DIST_DIR` — voir `packages/express-tools/src/express-server.ts`
|
||||
(`serveStaticFrontend`). Cette variable n'est renseignée que dans l'image Docker :
|
||||
en dev natif (`pnpm dev:api`), elle reste vide et `pnpm dev:web` continue de servir
|
||||
le frontend via son propre serveur Vite (HMR), sur un port séparé, comme avant.
|
||||
|
||||
`docker-compose.yml` ne définit donc que deux services : `postgres` et `app` (un
|
||||
seul port, `APP_PORT`, défaut `3000` — plus de `WEB_PORT`/`CORS_ORIGIN` à
|
||||
coordonner entre deux origines, le frontend et l'API sont désormais servis depuis
|
||||
la même origine).
|
||||
|
||||
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
|
||||
production est reliée directement au dépôt Git et reconstruit elle-même
|
||||
`docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
|
||||
donc aucune image nulle part.
|
||||
|
||||
### Release (`.github/workflows/release.yml`)
|
||||
|
||||
Déclenchée par un tag `vX.Y.Z` :
|
||||
|
||||
```bash
|
||||
git tag vX.Y.Z
|
||||
git push --tags
|
||||
```
|
||||
|
||||
Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
|
||||
push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
|
||||
redéployer dessus), `github-release` (crée une Release GitHub avec changelog
|
||||
auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
|
||||
au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
|
||||
est configuré (sinon Portainer se resynchronise simplement à son prochain
|
||||
polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
|
||||
stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
|
||||
|
||||
## Auth (apps/api)
|
||||
|
||||
Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpOnly.
|
||||
|
|
@ -336,8 +376,9 @@ vraie base).
|
|||
> branche de feature — pas un problème introduit par une modification du code.
|
||||
> `pnpm --filter web e2e` fonctionne normalement en CI (GitHub Actions) et sur une
|
||||
> machine de dev classique ; dans cet environnement précis, vérifier manuellement via
|
||||
> le serveur de dev (`pnpm dev:web` + `pnpm dev:api` en local, pas les conteneurs
|
||||
> Docker dont le `CORS_ORIGIN` cible `localhost:8080`, pas `localhost:5173`).
|
||||
> le serveur de dev (`pnpm dev:web` + `pnpm dev:api` en local, pas le conteneur
|
||||
> Docker — voir [Déploiement](#déploiement) — qui sert le frontend buildé, pas le
|
||||
> serveur de dev Vite).
|
||||
|
||||
## Gestion des erreurs (API ↔ web)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,26 +10,35 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -
|
|||
RUN corepack enable
|
||||
WORKDIR /repo
|
||||
|
||||
# Single image serving both the API and the built frontend (apps/web) — one
|
||||
# process, one container, no separate nginx/static host. Builds both so the
|
||||
# runtime stage below can copy each app's build output independently.
|
||||
FROM base AS build
|
||||
COPY . .
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter api build
|
||||
RUN pnpm --filter web build
|
||||
|
||||
# Copies the monorepo structure as-is (not a flattened single package) so
|
||||
# pnpm's symlinked node_modules (root node_modules/.pnpm <- apps/api/node_modules)
|
||||
# stay valid — paths must match exactly between build and runtime stages.
|
||||
FROM base AS runtime
|
||||
ENV NODE_ENV=production
|
||||
# Tells the API where to find the built frontend — see FRONTEND_DIST_DIR's
|
||||
# doc comment in apps/api/src/config/env.ts.
|
||||
ENV FRONTEND_DIST_DIR=/repo/apps/web/dist
|
||||
COPY --from=build /repo/node_modules ./node_modules
|
||||
COPY --from=build /repo/package.json ./package.json
|
||||
COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=build /repo/packages/shared ./packages/shared
|
||||
COPY --from=build /repo/packages/error-tools ./packages/error-tools
|
||||
COPY --from=build /repo/packages/express-tools ./packages/express-tools
|
||||
COPY --from=build /repo/packages/date-tools ./packages/date-tools
|
||||
COPY --from=build /repo/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY --from=build /repo/apps/api/dist ./apps/api/dist
|
||||
COPY --from=build /repo/apps/api/prisma ./apps/api/prisma
|
||||
COPY --from=build /repo/apps/api/package.json ./apps/api/package.json
|
||||
COPY --from=build /repo/apps/web/dist ./apps/web/dist
|
||||
|
||||
WORKDIR /repo/apps/api
|
||||
EXPOSE 3000
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ export function createServer(): ExpressServer {
|
|||
server.mountRouter("/profile", profileRouter);
|
||||
server.mountRouter("/reference", referenceRouter);
|
||||
|
||||
// Serves the built frontend (production Docker image only — see
|
||||
// FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after
|
||||
// every API route above (so they always win) and before the catch-all
|
||||
// 404 below (so unmatched GETs fall through to the SPA's index.html
|
||||
// instead of a JSON 404).
|
||||
if (env.FRONTEND_DIST_DIR) {
|
||||
server.serveStaticFrontend(env.FRONTEND_DIST_DIR);
|
||||
}
|
||||
|
||||
// No route matched — same shape as every other error response, via the
|
||||
// shared ErrorCode contract, so clients never special-case 404s.
|
||||
server.addMiddleware((_req: Request, res: Response) => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ const envSchema = z.object({
|
|||
AUTH_COOKIE_NAME: z.string().default("session"),
|
||||
/** Origin allowed by CORS — must match wherever apps/web is served from. */
|
||||
CORS_ORIGIN: z.string().default("http://localhost:5173"),
|
||||
/**
|
||||
* Absolute path to the built frontend (`apps/web/dist`), to serve
|
||||
* alongside the API. Optional, no default — only set inside the
|
||||
* production Docker image (see Dockerfile); left unset in native dev
|
||||
* (`pnpm dev:api`), where `pnpm dev:web`'s own Vite dev server serves
|
||||
* the frontend instead.
|
||||
*/
|
||||
FRONTEND_DIST_DIR: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
FROM node:22-slim AS base
|
||||
RUN corepack enable
|
||||
WORKDIR /repo
|
||||
|
||||
FROM base AS build
|
||||
COPY . .
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter web build
|
||||
|
||||
FROM nginx:alpine AS runtime
|
||||
COPY --from=build /repo/apps/web/dist /usr/share/nginx/html
|
||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback — needed once client-side routing (react-router) lands.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,10 @@ services:
|
|||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
# Single service serving both the API and the built frontend (see
|
||||
# apps/api/Dockerfile) — no separate nginx/web container, no cross-origin
|
||||
# CORS_ORIGIN to keep in sync between two ports.
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
|
|
@ -35,19 +38,8 @@ services:
|
|||
# always targets Postgres's internal port (5432).
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/${POSTGRES_DB:?set POSTGRES_DB in .env}?schema=public"
|
||||
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
|
||||
CORS_ORIGIN: "http://localhost:${WEB_PORT:-8080}"
|
||||
ports:
|
||||
- "${API_PORT:-3000}:3000"
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/web/Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "${WEB_PORT:-8080}:80"
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import path from "node:path";
|
||||
import cookieParser from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import express, {
|
||||
|
|
@ -74,6 +75,25 @@ export class ExpressServer {
|
|||
this.app.use(basePath, router);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves a built single-page app (static assets + SPA fallback) from
|
||||
* `distDir`. Call this **after** every `mountRouter`/`addRoute`, so API
|
||||
* routes always win, and **before** {@link addMiddleware}'s catch-all
|
||||
* 404 — any GET request that doesn't match an API route or a file in
|
||||
* `distDir` falls through to `index.html`, letting the client-side
|
||||
* router (e.g. react-router) handle it instead of a 404.
|
||||
*
|
||||
* Only meant for the production Docker image, where the frontend build
|
||||
* is copied alongside the API — native dev (`pnpm dev:api`) never calls
|
||||
* this, so `pnpm dev:web`'s own Vite dev server is unaffected.
|
||||
*/
|
||||
public serveStaticFrontend(distDir: string): void {
|
||||
this.app.use(express.static(distDir));
|
||||
this.app.get("*", (_req, res) => {
|
||||
res.sendFile(path.join(distDir, "index.html"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a single route with its handler(s). Warns and skips instead
|
||||
* of registering if the same method+path was already added — catches a
|
||||
|
|
|
|||
Loading…
Reference in a new issue