From c2cc773c7b6eef0ae2fe5471da50012ede80dc6e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 23:11:26 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Fusionner=20web+api=20en=20une=20image,=20s?= =?UTF-8?q?=C3=A9parer=20la=20CI,=20ajouter=20la=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .env.example | 8 +-- .github/workflows/ci.yml | 41 +++++++++++++-- .github/workflows/release.yml | 55 ++++++++++++++++++++ .gitignore | 3 ++ README.md | 47 +++++++++++++++-- apps/api/Dockerfile | 9 ++++ apps/api/src/app.ts | 9 ++++ apps/api/src/config/env.ts | 8 +++ apps/web/Dockerfile | 13 ----- apps/web/nginx.conf | 11 ---- docker-compose.yml | 18 ++----- packages/express-tools/src/express-server.ts | 20 +++++++ 12 files changed, 194 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 apps/web/Dockerfile delete mode 100644 apps/web/nginx.conf diff --git a/.env.example b/.env.example index 7639ced..44674ad 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c298d4..8158311 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8e046fa --- /dev/null +++ b/.github/workflows/release.yml @@ -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" diff --git a/.gitignore b/.gitignore index df6e0ba..a49f9c9 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,6 @@ Projet batch cooking.pdf # Throwaway HTML mockups used to review a design before implementing it tmp-mockups/ + +# IA +.claude/ \ No newline at end of file diff --git a/README.md b/README.md index 6a2261d..80f253a 100644 --- a/README.md +++ b/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) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 1d5778d..08cffa3 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -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 diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b86dc5d..8a28c99 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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) => { diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 0ae287c..80ad249 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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. */ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile deleted file mode 100644 index 1e8278f..0000000 --- a/apps/web/Dockerfile +++ /dev/null @@ -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 diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf deleted file mode 100644 index 8103e43..0000000 --- a/apps/web/nginx.conf +++ /dev/null @@ -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; - } -} diff --git a/docker-compose.yml b/docker-compose.yml index d37f92a..b78bd44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/packages/express-tools/src/express-server.ts b/packages/express-tools/src/express-server.ts index 1e95541..63368cd 100644 --- a/packages/express-tools/src/express-server.ts +++ b/packages/express-tools/src/express-server.ts @@ -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 From c4ef989e1a0d764aa4b35a74c2ede842f6c6a7b0 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 17 Aug 2026 23:14:46 +0200 Subject: [PATCH 2/2] ci: drop pull_request trigger, push alone covers same-repo PRs push (all branches) already fires for a PR branch on this repo; keeping pull_request too just ran every job twice on the same commit. GitHub still attaches the push-triggered run to the PR by SHA regardless of which event fired it. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8158311..f6faf44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,15 @@ 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 the first commit of a branch, not only once a PR targets main. No +# separate pull_request trigger: for a PR from a branch on this same repo, +# push already fires on that branch, and GitHub attaches the run to the PR +# by commit SHA regardless of which event triggered it — adding +# pull_request too would just run every job twice per push. (Re-add it, +# scoped to forks, only if this repo starts accepting fork PRs — push +# events from a fork never reach here.) on: push: - pull_request: - branches: [main] env: DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"