xpeditis2.0/docs/deployment/hetzner/11-cicd-github-actions.md
2026-03-26 18:08:28 +01:00

490 lines
15 KiB
Markdown

# 11 — CI/CD avec GitHub Actions
Pipeline complet : commit → build Docker → push GHCR → déploiement k3s → vérification.
---
## Architecture du pipeline
```
Push sur main
├── Job: test
│ ├── npm run backend:lint
│ ├── npm run backend:test
│ └── npm run frontend:lint
├── Job: build (si tests OK)
│ ├── docker buildx build backend → ghcr.io/<org>/xpeditis-backend:sha + :latest
│ └── docker buildx build frontend → ghcr.io/<org>/xpeditis-frontend:sha + :latest
└── Job: deploy (si build OK)
├── kubectl set image deployment/xpeditis-backend ...
├── kubectl set image deployment/xpeditis-frontend ...
├── kubectl rollout status ...
└── Health check final
```
---
## Secrets GitHub à configurer
Dans votre repo GitHub → Settings → Secrets and variables → Actions → New repository secret :
| Secret | Valeur | Usage |
|---|---|---|
| `HETZNER_KUBECONFIG` | Contenu de `~/.kube/kubeconfig-xpeditis-prod` (base64) | Accès kubectl |
| `GHCR_TOKEN` | Personal Access Token GitHub (scope: `write:packages`) | Push images |
| `SLACK_WEBHOOK_URL` | URL webhook Slack (optionnel) | Notifications |
```bash
# Encoder le kubeconfig en base64 pour GitHub Secrets
cat ~/.kube/kubeconfig-xpeditis-prod | base64 -w 0
# Copier le résultat dans HETZNER_KUBECONFIG
# Créer le Personal Access Token GitHub
# https://github.com/settings/tokens/new
# Scopes : write:packages, read:packages, delete:packages
```
---
## Workflow principal — `.github/workflows/deploy.yml`
```yaml
# .github/workflows/deploy.yml
name: Build & Deploy to Hetzner
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
REGISTRY: ghcr.io
IMAGE_BACKEND: ghcr.io/${{ github.repository_owner }}/xpeditis-backend
IMAGE_FRONTEND: ghcr.io/${{ github.repository_owner }}/xpeditis-frontend
jobs:
# ============================================================
# JOB 1 : Tests & Lint
# ============================================================
test:
name: Tests & Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm run install:all
- name: Lint backend
run: npm run backend:lint
- name: Lint frontend
run: npm run frontend:lint
- name: Test backend (unit)
run: npm run backend:test -- --passWithNoTests
- name: TypeScript check frontend
run: |
cd apps/frontend
npm run type-check
# ============================================================
# JOB 2 : Build & Push Docker Images
# ============================================================
build:
name: Build Docker Images
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
outputs:
backend_tag: ${{ steps.meta-backend.outputs.version }}
frontend_tag: ${{ steps.meta-frontend.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# ── Backend ──
- name: Extract metadata (backend)
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_BACKEND }}
tags: |
type=sha,prefix=sha-,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value={{date 'YYYY-MM-DD'}},enable={{is_default_branch}}
- name: Build & Push backend
uses: docker/build-push-action@v5
with:
context: .
file: apps/backend/Dockerfile
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend
platforms: linux/amd64 # Changer en linux/amd64,linux/arm64 si vous utilisez des CAX
# ── Frontend ──
- name: Extract metadata (frontend)
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_FRONTEND }}
tags: |
type=sha,prefix=sha-,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value={{date 'YYYY-MM-DD'}},enable={{is_default_branch}}
- name: Build & Push frontend
uses: docker/build-push-action@v5
with:
context: .
file: apps/frontend/Dockerfile
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
platforms: linux/amd64
build-args: |
NEXT_PUBLIC_API_URL=https://api.xpeditis.com
NEXT_PUBLIC_APP_URL=https://app.xpeditis.com
NEXT_PUBLIC_API_PREFIX=api/v1
# ============================================================
# JOB 3 : Deploy vers k3s Hetzner
# ============================================================
deploy:
name: Deploy to Hetzner k3s
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment:
name: production
url: https://app.xpeditis.com
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure kubectl
run: |
mkdir -p ~/.kube
echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
kubectl cluster-info
- name: Deploy Backend
run: |
BACKEND_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"
kubectl set image deployment/xpeditis-backend \
backend=${{ env.IMAGE_BACKEND }}:${BACKEND_TAG} \
-n xpeditis-prod
kubectl rollout status deployment/xpeditis-backend \
-n xpeditis-prod \
--timeout=300s
echo "✅ Backend deployed: ${BACKEND_TAG}"
- name: Deploy Frontend
run: |
FRONTEND_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"
kubectl set image deployment/xpeditis-frontend \
frontend=${{ env.IMAGE_FRONTEND }}:${FRONTEND_TAG} \
-n xpeditis-prod
kubectl rollout status deployment/xpeditis-frontend \
-n xpeditis-prod \
--timeout=300s
echo "✅ Frontend deployed: ${FRONTEND_TAG}"
- name: Health Check
run: |
sleep 15 # Laisser le temps au LB de propager
# Test API backend
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
https://api.xpeditis.com/api/v1/health)
if [ "$STATUS" != "200" ]; then
echo "❌ Backend health check failed (HTTP $STATUS)"
exit 1
fi
echo "✅ Backend healthy (HTTP $STATUS)"
# Test frontend
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
https://app.xpeditis.com/)
if [ "$STATUS" != "200" ]; then
echo "❌ Frontend health check failed (HTTP $STATUS)"
exit 1
fi
echo "✅ Frontend healthy (HTTP $STATUS)"
- name: Notify Slack (success)
if: success()
run: |
if [ -n "${{ secrets.SLACK_WEBHOOK_URL }}" ]; then
curl -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H 'Content-type: application/json' \
--data '{
"text": "✅ Xpeditis déployé en production",
"attachments": [{
"color": "good",
"fields": [
{"title": "Commit", "value": "${{ github.sha }}", "short": true},
{"title": "Auteur", "value": "${{ github.actor }}", "short": true},
{"title": "Message", "value": "${{ github.event.head_commit.message }}", "short": false}
]
}]
}'
fi
- name: Notify Slack (failure)
if: failure()
run: |
if [ -n "${{ secrets.SLACK_WEBHOOK_URL }}" ]; then
curl -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H 'Content-type: application/json' \
--data '{
"text": "❌ Échec du déploiement Xpeditis",
"attachments": [{
"color": "danger",
"fields": [
{"title": "Commit", "value": "${{ github.sha }}", "short": true},
{"title": "Job", "value": "${{ github.workflow }}", "short": true}
]
}]
}'
fi
- name: Rollback on failure
if: failure()
run: |
echo "⏮️ Rollback en cours..."
kubectl rollout undo deployment/xpeditis-backend -n xpeditis-prod
kubectl rollout undo deployment/xpeditis-frontend -n xpeditis-prod
kubectl rollout status deployment/xpeditis-backend -n xpeditis-prod --timeout=120s
echo "✅ Rollback terminé"
```
---
## Workflow de staging (PR preview) — `.github/workflows/staging.yml`
```yaml
# .github/workflows/staging.yml
name: Deploy to Staging
on:
pull_request:
branches:
- main
jobs:
build-staging:
name: Build Staging
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & Push (staging tag)
uses: docker/build-push-action@v5
with:
context: .
file: apps/backend/Dockerfile
push: true
tags: ghcr.io/${{ github.repository_owner }}/xpeditis-backend:pr-${{ github.event.pull_request.number }}
build-args: NODE_ENV=staging
- name: Comment PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '🐳 Image Docker staging buildée : `pr-${{ github.event.pull_request.number }}`'
})
```
---
## Mise à jour des manifests Kubernetes
Alternativement, vous pouvez mettre à jour les fichiers YAML dans Git et les appliquer :
```bash
# Dans le workflow CI, mettre à jour le tag d'image dans les manifests
- name: Update image in manifests
run: |
IMAGE_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"
# Mettre à jour les fichiers YAML
sed -i "s|image: ghcr.io/.*/xpeditis-backend:.*|image: ${{ env.IMAGE_BACKEND }}:${IMAGE_TAG}|g" \
k8s/03-backend-deployment.yaml
sed -i "s|image: ghcr.io/.*/xpeditis-frontend:.*|image: ${{ env.IMAGE_FRONTEND }}:${IMAGE_TAG}|g" \
k8s/05-frontend-deployment.yaml
# Committer les changements (GitOps)
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add k8s/
git commit -m "chore: update image tags to ${IMAGE_TAG} [skip ci]"
git push
```
---
## Dockerfile final — Backend
```dockerfile
# apps/backend/Dockerfile
FROM node:20-alpine AS deps
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
COPY apps/backend/package*.json apps/backend/
RUN npm ci --workspace=apps/backend
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/backend/node_modules ./apps/backend/node_modules
COPY . .
RUN cd apps/backend && npm run build
FROM node:20-alpine AS runner
RUN apk add --no-cache dumb-init
RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001
WORKDIR /app
COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/package.json ./
USER nestjs
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:4000/api/v1/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]
```
## Dockerfile final — Frontend
```dockerfile
# apps/frontend/Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
COPY apps/frontend/package*.json apps/frontend/
RUN npm ci --workspace=apps/frontend
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/frontend/node_modules ./apps/frontend/node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_API_PREFIX=api/v1
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \
NEXT_PUBLIC_API_PREFIX=$NEXT_PUBLIC_API_PREFIX \
NEXT_TELEMETRY_DISABLED=1
RUN cd apps/frontend && npm run build
FROM node:20-alpine AS runner
RUN apk add --no-cache dumb-init
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
WORKDIR /app
COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/public ./public
USER nextjs
EXPOSE 3000
ENV NODE_ENV=production PORT=3000 HOSTNAME="0.0.0.0" NEXT_TELEMETRY_DISABLED=1
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]
```
---
## Test du pipeline en local
```bash
# Simuler le build Docker localement
docker build \
-f apps/backend/Dockerfile \
-t xpeditis-backend:local \
.
docker build \
-f apps/frontend/Dockerfile \
-t xpeditis-frontend:local \
--build-arg NEXT_PUBLIC_API_URL=http://localhost:4000 \
--build-arg NEXT_PUBLIC_APP_URL=http://localhost:3000 \
.
# Tester l'image backend
docker run --rm -p 4000:4000 \
-e NODE_ENV=production \
-e DATABASE_HOST=<votre_db_host> \
-e DATABASE_USER=xpeditis \
-e DATABASE_PASSWORD=<password> \
-e DATABASE_NAME=xpeditis \
-e REDIS_HOST=<votre_redis_host> \
-e REDIS_PASSWORD=<password> \
-e JWT_SECRET=test-secret \
-e SMTP_HOST=localhost \
-e SMTP_PORT=25 \
-e SMTP_USER=test \
-e SMTP_PASS=test \
xpeditis-backend:local
curl http://localhost:4000/api/v1/health
```