Merge pull request #15 from kyuno053/ci/single-image-and-release-pipeline

CI + Docker : image unique, pipelines séparés, release Portainer
This commit is contained in:
kyuno053 2026-08-17 23:18:27 +02:00 committed by GitHub
commit 6fec167e86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 198 additions and 50 deletions

View file

@ -6,11 +6,11 @@ POSTGRES_PASSWORD=changeme
POSTGRES_DB=batchcooking POSTGRES_DB=batchcooking
POSTGRES_PORT=5432 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 # `pnpm dev:api` workflow reads apps/api/.env instead, set both when using
# both workflows). Required, no default on purpose — generate your own. # both workflows). Required, no default on purpose — generate your own.
JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — host ports for the Docker review stack (docker-compose.yml) # Optional — host port for the Docker review stack's single app container
# API_PORT=3000 # (docker-compose.yml), serving both the API and the built frontend.
# WEB_PORT=8080 # APP_PORT=3000

View file

@ -1,10 +1,15 @@
name: CI 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. 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: on:
push: push:
branches: [main]
pull_request:
branches: [main]
env: env:
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public" DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
@ -12,7 +17,25 @@ env:
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+" JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
jobs: 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 runs-on: ubuntu-latest
services: services:
postgres: postgres:
@ -39,15 +62,27 @@ jobs:
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm --filter api exec prisma migrate deploy - run: pnpm --filter api exec prisma migrate deploy
- run: pnpm --filter api test - run: pnpm --filter api test
- run: pnpm --filter api test:bdd - 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 - run: pnpm build
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: lint-and-test
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

55
.github/workflows/release.yml vendored Normal file
View 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
View file

@ -147,3 +147,6 @@ Projet batch cooking.pdf
# Throwaway HTML mockups used to review a design before implementing it # Throwaway HTML mockups used to review a design before implementing it
tmp-mockups/ tmp-mockups/
# IA
.claude/

View file

@ -105,7 +105,7 @@ pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev auto
pnpm build # build de tous les workspaces 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) ### Cucumber (apps/api)
@ -132,6 +132,46 @@ qui n'existent pas encore).
> explicitement CommonJS ; les steps/world restent en `.ts` ESM classique et sont > 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`). > 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) ## Auth (apps/api)
Inscription (création de profil + foyer) et connexion, JWT dans un cookie httpOnly. 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. > 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 > `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 > 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 > le serveur de dev (`pnpm dev:web` + `pnpm dev:api` en local, pas le conteneur
> Docker dont le `CORS_ORIGIN` cible `localhost:8080`, pas `localhost:5173`). > Docker — voir [Déploiement](#déploiement) — qui sert le frontend buildé, pas le
> serveur de dev Vite).
## Gestion des erreurs (API ↔ web) ## Gestion des erreurs (API ↔ web)

View file

@ -10,26 +10,35 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -
RUN corepack enable RUN corepack enable
WORKDIR /repo 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 FROM base AS build
COPY . . COPY . .
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN pnpm --filter api build RUN pnpm --filter api build
RUN pnpm --filter web build
# Copies the monorepo structure as-is (not a flattened single package) so # 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) # pnpm's symlinked node_modules (root node_modules/.pnpm <- apps/api/node_modules)
# stay valid — paths must match exactly between build and runtime stages. # stay valid — paths must match exactly between build and runtime stages.
FROM base AS runtime FROM base AS runtime
ENV NODE_ENV=production 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/node_modules ./node_modules
COPY --from=build /repo/package.json ./package.json COPY --from=build /repo/package.json ./package.json
COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml COPY --from=build /repo/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=build /repo/packages/shared ./packages/shared COPY --from=build /repo/packages/shared ./packages/shared
COPY --from=build /repo/packages/error-tools ./packages/error-tools 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/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/node_modules ./apps/api/node_modules
COPY --from=build /repo/apps/api/dist ./apps/api/dist 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/prisma ./apps/api/prisma
COPY --from=build /repo/apps/api/package.json ./apps/api/package.json 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 WORKDIR /repo/apps/api
EXPOSE 3000 EXPOSE 3000

View file

@ -33,6 +33,15 @@ export function createServer(): ExpressServer {
server.mountRouter("/profile", profileRouter); server.mountRouter("/profile", profileRouter);
server.mountRouter("/reference", referenceRouter); 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 // No route matched — same shape as every other error response, via the
// shared ErrorCode contract, so clients never special-case 404s. // shared ErrorCode contract, so clients never special-case 404s.
server.addMiddleware((_req: Request, res: Response) => { server.addMiddleware((_req: Request, res: Response) => {

View file

@ -24,6 +24,14 @@ const envSchema = z.object({
AUTH_COOKIE_NAME: z.string().default("session"), AUTH_COOKIE_NAME: z.string().default("session"),
/** Origin allowed by CORS — must match wherever apps/web is served from. */ /** Origin allowed by CORS — must match wherever apps/web is served from. */
CORS_ORIGIN: z.string().default("http://localhost:5173"), 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. */ /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

@ -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

View file

@ -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;
}
}

View file

@ -19,7 +19,10 @@ services:
timeout: 5s timeout: 5s
retries: 5 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: build:
context: . context: .
dockerfile: apps/api/Dockerfile dockerfile: apps/api/Dockerfile
@ -35,19 +38,8 @@ services:
# always targets Postgres's internal port (5432). # 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" 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} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
CORS_ORIGIN: "http://localhost:${WEB_PORT:-8080}"
ports: ports:
- "${API_PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
web:
build:
context: .
dockerfile: apps/web/Dockerfile
restart: unless-stopped
depends_on:
- api
ports:
- "${WEB_PORT:-8080}:80"
volumes: volumes:
postgres_data: postgres_data:

View file

@ -1,3 +1,4 @@
import path from "node:path";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import cors from "cors"; import cors from "cors";
import express, { import express, {
@ -74,6 +75,25 @@ export class ExpressServer {
this.app.use(basePath, router); 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 * Registers a single route with its handler(s). Warns and skips instead
* of registering if the same method+path was already added catches a * of registering if the same method+path was already added catches a