🔧 Jenkins · Avancé

Pipeline CI/CD pour une app React

⏱ 40 minutes🔧 Jenkins 2.440+⚛️ React 18🐳 Docker

Ce tutoriel construit un pipeline Jenkins complet avec Jenkinsfile déclaratif : checkout Git, installation des dépendances, tests unitaires, build de l'image Docker, push sur le registry et déploiement automatique — le tout automatisé à chaque push.

📖 Termes clés de la CI/CD

CI/CD : Continuous Integration / Continuous Deployment. Automatisation de la compilation, tests et déploiement chaque fois que vous poussez du code. Versus déploiement manuel et lent.

Pipeline : Séquence d'étapes automatisées pour traiter votre code (compiler → tester → packager → déployer). Chaque étape est un "stage".

Stage : Une étape du pipeline (ex: "Build", "Test", "Deploy"). Les stages s'exécutent séquentiellement par défaut, mais peuvent être parallèles.

Jenkins : Serveur open-source qui exécute des pipelines. Il peut être déclenché par un webhook Git (code poussé = pipeline lancé automatiquement).

Jenkinsfile : Fichier qui décrit le pipeline en code (versionnéavec votre code source). Deux syntaxes : déclarative (simple) ou scripted (flexible).

Webhook : URL HTTP que Git appelle chaque fois qu'on pousse du code. Jenkins écoute et démarre le pipeline automatiquement.

Credential : Données sensibles stockées dans Jenkins de manière sécurisée (tokens, clés SSH, passwords). Jamais en clair dans le Jenkinsfile.

Artifact : Résultat d'une étape du pipeline (ex: fichiers compilés, images Docker, reports de tests). Jenkins peut les archiver et les servir.

Architecture du pipeline

Flux CI/CD
Git Push → Jenkins Webhook
    └── Stage 1 : Checkout (clone le code)
    └── Stage 2 : Install (npm ci)
    └── Stage 3 : Lint & Test (ESLint + Jest) [parallèles]
    └── Stage 4 : Build (npm run build → dist/)
    └── Stage 5 : Docker Build & Push (image :latest + :git-sha)
    └── Stage 6 : Deploy (docker pull + restart sur serveur SSH)
    └── Stage 7 : Notify (Slack / Email)
Ce pipeline automatise tout : dès que vous pushez du code sur la branche principale, Jenkins clone votre repo, compile, teste, crée une image Docker, la pousse sur le registry, puis l'exécute sur votre serveur de production. Aucune action manuelle requise. La plupart des étapes s'exécutent séquentiellement, sauf le Lint et les Tests qui tournent en parallèle (gain de temps).

1. L'application React

Créons une application React basique avec des tests unitaires. Jenkins exécutera ces tests automatiquement.

Terminal — créer l'app
npx create-react-app mon-dashboard
cd mon-dashboard

# Installer les dépendances de test
npm install --save-dev @testing-library/react @testing-library/jest-dom
create-react-app : Générateur qui crée un projet React avec webpack, Babel et Jest pré-configurés.
@testing-library/react : Bibliothèque pour tester les composants React en les rendant et interrogeant le DOM.
jest-dom : Matchers Jest supplémentaires (ex: toBeInTheDocument()).

Ajoutons un test unitaire simple :

src/App.test.js
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from './App';

test('renders application title', () => {
  render(<App />);
  const title = screen.getByText(/Mon Dashboard/i);
  expect(title).toBeInTheDocument();
});

test('navigation is visible', () => {
  render(<App />);
  expect(screen.getByRole('navigation')).toBeInTheDocument();
});
test(description, callback) : Définit un test unitaire.
render(<App />) : Render le composant React dans le DOM de test.
screen.getByText / getByRole : Interroge le DOM pour vérifier que le composant affiche le contenu attendu.
expect().toBeInTheDocument() : Vérifie que l'élément existe. Jenkins exécutera ces tests — si un échoue, le pipeline s'arrête.

2. Dockerfile pour l'application React

Utilisons une build multi-stage : compiler avec Node en stage 1, puis servir les fichiers statiques avec Nginx en stage 2. Avantage : l'image finale ne contient que Nginx + fichiers compilés, sans Node.js.

Dockerfile
# ── Stage 1 : Build ──
FROM node:20-alpine AS builder
WORKDIR /app

# Installer les dépendances (cache optimisé)
COPY package*.json ./
RUN npm ci

# Copier le code et compiler
COPY . .
RUN npm run build

# ── Stage 2 : Serve ──
FROM nginx:alpine AS runner

# Config Nginx pour React Router (SPA)
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Copier le build depuis le stage précédent
COPY --from=builder /app/build /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Stage 1 (builder) : Installe npm et compile le code React. Génère un dossier build/ avec l'HTML minifié et le JavaScript optimisé.
Stage 2 (runner) : Image Nginx légère qui sert les fichiers du stage 1. Le dossier Node.js entier est jeté. Résultat : image ~50 MB au lieu de 500 MB.
COPY --from=builder : Copie les fichiers compilés du stage précédent dans l'image Nginx.
nginx.conf
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Nécessaire pour React Router (toutes les routes → index.html)
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache pour les assets (JS, CSS, images)
    location ~* \.(js|css|png|jpg|svg|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Health check pour les orchestrateurs
    location /health {
        return 200 '{"status":"healthy"}';
        add_header Content-Type application/json;
    }
}
try_files $uri $uri/ /index.html : Crucial pour React SPA. Chaque route non trouvée route vers index.html, qui load React Router et affiche la bonne page.
expires 1y : Cache les assets statiques pendant 1 an (fichiers JS/CSS ne changent que si le hash change dans le nom).
/health endpoint : Utilisé par les orchestrateurs (Docker Compose, Kubernetes) pour vérifier que Nginx est sain.

3. Jenkinsfile — le pipeline déclaratif

Le Jenkinsfile décrit le pipeline en code Groovy. Syntaxe déclarative : lisible, structurée, avec pipeline { agent ... environment ... stages ... }.

Jenkinsfile (à la racine du projet)
pipeline {
    agent any

    // ── Variables d'environnement du pipeline ──
    environment {
        REGISTRY = 'registry.example.com'
        IMAGE_NAME = 'mon-dashboard'
        IMAGE_TAG = "${REGISTRY}/${IMAGE_NAME}:${GIT_COMMIT[0..7]}"
        IMAGE_LATEST = "${REGISTRY}/${IMAGE_NAME}:latest"
        DEPLOY_SERVER = 'user@mon-serveur.com'
        NODE_VERSION = '20'
    }

    // ── Options du pipeline ──
    options {
        buildDiscarder(logRotator(numToKeepStr: '10'))
        timeout(time: 20, unit: 'MINUTES')
        disableConcurrentBuilds()  // Pas de builds parallèles sur la même branche
    }

    stages {

        // ── Stage 1 : Checkout ──
        stage('📥 Checkout') {
            steps {
                checkout scm
                sh 'git log --oneline -5'  // Afficher les derniers commits
                script {
                    env.GIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
                    env.BRANCH = env.GIT_BRANCH.replace('origin/', '')
                }
                echo "Branch: ${env.BRANCH} | Commit: ${env.GIT_SHORT}"
            }
        }

        // ── Stage 2 : Installation des dépendances ──
        stage('📦 Install') {
            steps {
                sh '''
                    node --version
                    npm --version
                    npm ci --prefer-offline
                '''
            }
        }

        // ── Stage 3 : Tests (parallèles) ──
        stage('🧪 Lint & Test') {
            parallel {
                stage('ESLint') {
                    steps {
                        sh 'npm run lint -- --format checkstyle --output-file reports/eslint.xml || true'
                    }
                    post {
                        always {
                            // Publier les résultats ESLint dans Jenkins
                            recordIssues tools: [esLint(pattern: 'reports/eslint.xml')]
                        }
                    }
                }
                stage('Unit Tests') {
                    steps {
                        sh '''
                            npm test -- \
                              --watchAll=false \
                              --coverage \
                              --coverageReporters=cobertura \
                              --coverageDirectory=reports/coverage \
                              --reporters=jest-junit \
                              --testResultsProcessor=jest-junit
                        '''
                    }
                    post {
                        always {
                            junit 'reports/junit.xml'
                            cobertura coberturaReportFile: 'reports/coverage/cobertura-coverage.xml'
                        }
                    }
                }
            }
        }

        // ── Stage 4 : Build ──
        stage('🔨 Build') {
            steps {
                sh '''
                    GENERATE_SOURCEMAP=false npm run build
                    echo "Build terminé — taille du bundle :"
                    du -sh build/
                '''
                archiveArtifacts artifacts: 'build/**', fingerprint: true
            }
        }

        // ── Stage 5 : Docker Build & Push ──
        stage('🐳 Docker') {
            when {
                anyOf {
                    branch 'main'
                    branch 'staging'
                }
            }
            steps {
                script {
                    withCredentials([usernamePassword(
                        credentialsId: 'docker-registry-credentials',
                        usernameVariable: 'DOCKER_USER',
                        passwordVariable: 'DOCKER_PASS'
                    )]) {
                        sh """
                            echo "$DOCKER_PASS" | docker login ${REGISTRY} -u "$DOCKER_USER" --password-stdin
                            docker build -t ${IMAGE_TAG} -t ${IMAGE_LATEST} .
                            docker push ${IMAGE_TAG}
                            docker push ${IMAGE_LATEST}
                            docker logout ${REGISTRY}
                        """
                    }
                }
            }
        }

        // ── Stage 6 : Déploiement ──
        stage('🚀 Deploy') {
            when { branch 'main' }
            steps {
                script {
                    withCredentials([sshUserPrivateKey(
                        credentialsId: 'deploy-ssh-key',
                        keyFileVariable: 'SSH_KEY'
                    )]) {
                        sh """
                            ssh -i $SSH_KEY -o StrictHostKeyChecking=no ${DEPLOY_SERVER} \\
                              "docker pull ${IMAGE_TAG} && \\
                               docker stop mon-dashboard || true && \\
                               docker rm mon-dashboard || true && \\
                               docker run -d --name mon-dashboard \\
                                 -p 80:80 \\
                                 --restart unless-stopped \\
                                 ${IMAGE_TAG}"
                        """
                    }
                }
            }
        }

    }

    // ── Notifications post-build ──
    post {
        success {
            echo "✅ Build ${env.BUILD_NUMBER} réussi — ${env.BRANCH}:${env.GIT_SHORT}"
            // slackSend channel: '#deploys', color: 'good',
            //   message: "✅ ${env.IMAGE_NAME} déployé — ${env.GIT_SHORT}"
        }
        failure {
            echo "❌ Build ${env.BUILD_NUMBER} échoué"
            // emailext to: 'team@example.com', subject: "❌ Build failed", body: "..."
        }
        always {
            // Nettoyage du workspace
            cleanWs()
        }
    }
}
pipeline { ... } : Bloc racine de la syntaxe déclarative.
agent any : Exécute le pipeline sur n'importe quel nœud/worker disponible.
environment { ... } : Variables d'environnement injectées dans tous les steps. ${REGISTRY}/${IMAGE_NAME}:${GIT_COMMIT[0..7]} génère un tag comme registry.example.com/mon-dashboard:a1b2c3d4.
options { buildDiscarder ... } : Garde les 10 derniers builds et annule si > 20 min. Évite que Jenkins remplisse le disque.
timeout : Arrête le build s'il prend > 20 minutes.

Détail de chaque stage

Stage 1 : Checkout
stage('📥 Checkout') {
    steps {
        checkout scm                              // Clone le repo Git
        sh 'git log --oneline -5'                 // Affiche derniers commits
        script {
            env.GIT_SHORT = sh(script: ...).trim() // Hash court (a1b2c3d)
            env.BRANCH = env.GIT_BRANCH.replace() // Nom branche sans 'origin/'
        }
    }
}
checkout scm : Clone votre repo Git (configuré dans la job Jenkins). scm = "Source Code Management".
git rev-parse --short HEAD : Récupère le hash court du commit (7 premiers caractères). Utilisé pour tagger l'image Docker de manière unique.
Stage 2 : Install
stage('📦 Install') {
    steps {
        sh '''
            node --version                        // Vérifie que Node est dispo
            npm ci --prefer-offline               // Clean install (vs npm install)
        '''
    }
}
npm ci (clean install) : Installe exactement les versions dans package-lock.json (reproductible, vs npm install qui peut varier). --prefer-offline : Utilise le cache npm local si possible.
Stage 3 : Lint & Test (parallèles)
stage('🧪 Lint & Test') {
    parallel {                                    // Deux stages en parallèle = plus rapide
        stage('ESLint') {
            steps {
                sh 'npm run lint -- --format checkstyle ...'
            }
            post {
                always {
                    recordIssues tools: [esLint(...)]  // Jenkins affiche les warnings
                }
            }
        }
        stage('Unit Tests') {
            steps {
                sh 'npm test -- --watchAll=false --coverage ...'
            }
            post {
                always {
                    junit 'reports/junit.xml'  // Publier résultats tests
                    cobertura ...              // Publier couverture de code
                }
            }
        }
    }
}
parallel { ... } : Lance ESLint et Tests en même temps. Si vous avez 2 cores, cela gagne du temps (20s eslint + 20s tests = 20s total au lieu de 40s).
post { always { ... } } : Blocs toujours exécutés même si le step échoue. Utile pour collecter les logs et rapports.
recordIssues / junit / cobertura : Publient les résultats dans l'interface Jenkins pour qu'on puisse voir les graphes et tendances au fil du temps.
Stage 4 : Build
stage('🔨 Build') {
    steps {
        sh '''
            GENERATE_SOURCEMAP=false npm run build  // Compile React (optimisé, sourcemaps désactivés)
            du -sh build/                           // Affiche taille du build
        '''
        archiveArtifacts artifacts: 'build/**'      // Archiver les fichiers pour téléchargement
    }
}
GENERATE_SOURCEMAP=false : Réduit la taille du build (no source maps = plus rapide à zipper).
npm run build : Minifie le code React, génère le dossier build/.
archiveArtifacts : Jenkins sauvegarde les fichiers compilés pour qu'on puisse les télécharger ou les inspecter plus tard.
Stage 5 : Docker Build & Push
stage('🐳 Docker') {
    when { anyOf { branch 'main'; branch 'staging' } }  // Only on main/staging
    steps {
        script {
            withCredentials([usernamePassword(         // Credentials chiffré
                credentialsId: 'docker-registry-credentials',
                usernameVariable: 'DOCKER_USER',
                passwordVariable: 'DOCKER_PASS'
            )]) {
                sh """
                    echo "$DOCKER_PASS" | docker login ...  // Login sécurisé (pas en clair)
                    docker build -t ${IMAGE_TAG} -t ${IMAGE_LATEST} .
                    docker push ${IMAGE_TAG}                // :a1b2c3d4 (hash commit)
                    docker push ${IMAGE_LATEST}             // :latest
                    docker logout ${REGISTRY}               // Cleanup
                """
            }
        }
    }
}
when { anyOf { branch 'main'; branch 'staging' } } : Ce stage s'exécute seulement si le code est sur main ou staging (pas sur chaque branche de feature).
withCredentials : Jenkins récupère le username/password du registre Docker de manière sécurisée (chiffré en base). Le mot de passe ne s'affiche jamais dans les logs.
docker build -t ${IMAGE_TAG} -t ${IMAGE_LATEST} : Compile le Dockerfile et crée deux tags (ex: registry.example.com/mon-dashboard:a1b2c3d4 et :latest).
docker push : Envoie l'image vers le registre pour que votre serveur de déploiement puisse la télécharger.
Pourquoi deux tags ? Le tag :latest permet de toujours référencer "la dernière version". Le tag par hash (ex: :a1b2c3d4) permet de tracer exactement quelle version du code est en prod (utile si un déploiement échoue, vous pouvez rollback au hash précédent).
Stage 6 : Deploy (SSH)
stage('🚀 Deploy') {
    when { branch 'main' }                          // Only main deploys to prod
    steps {
        script {
            withCredentials([sshUserPrivateKey(
                credentialsId: 'deploy-ssh-key',
                keyFileVariable: 'SSH_KEY'
            )]) {
                sh """
                    ssh -i $SSH_KEY -o StrictHostKeyChecking=no ${DEPLOY_SERVER} \\
                      "docker pull ${IMAGE_TAG} && \\
                       docker stop mon-dashboard || true && \\
                       docker rm mon-dashboard || true && \\
                       docker run -d --name mon-dashboard \\
                         -p 80:80 \\
                         --restart unless-stopped \\
                         ${IMAGE_TAG}"
                """
            }
        }
    }
}
withCredentials([sshUserPrivateKey]) : Récupère votre clé SSH privée stockée dans Jenkins de manière sécurisée.
ssh -i $SSH_KEY ... ${DEPLOY_SERVER} : Se connecte à votre serveur (ex: ubuntu@prod.example.com) avec la clé SSH.
docker pull ${IMAGE_TAG} : Télécharge l'image Docker depuis le registre sur le serveur de prod.
docker stop && docker rm || true : Arrête et supprime l'ancienne instance si elle existe. || true évite une erreur si le conteneur n'existe pas.
docker run -d --name mon-dashboard -p 80:80 --restart unless-stopped : Lance un nouveau conteneur. -p 80:80 expose le port 80 (HTTP). --restart unless-stopped redémarre automatiquement le conteneur si l'instance crash (mais pas si vous l'arrêtez manuellement).
Pourquoi SSH au lieu de Kubernetes ? SSH est simple et universel (marche sur n'importe quel serveur Linux). Kubernetes serait plus robuste pour du vrai multi-instance, mais plus complexe à administrer. SSH convient pour petits déploiements.

Sécurité du déploiement SSH

🔒 Considérations de sécurité

Clés SSH : Jenkins utilise une clé SSH privée pour se connecter au serveur sans password. Cette clé doit être protégée (permissions 600) et stockée sécurisée dans Jenkins.

StrictHostKeyChecking=no : Désactive la vérification du host key (pratique en CI, mais moins sûr — en prod mieux utiliser yes et pré-ajouter la clé).

Credentials chiffré : Jenkins chiffre tous les secrets (passwords, clés) et les injecte que lors de l'exécution (jamais dans les logs).

4. Configurer Jenkins

Terminal — lancer Jenkins avec Docker
# Lancer Jenkins en local
docker run -d \
  --name jenkins \
  -p 8080:8080 \
  -p 50000:50000 \
  -v jenkins_home:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \  # Accès au daemon Docker de l'hôte
  jenkins/jenkins:lts-jdk17

# Récupérer le mot de passe initial
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
-p 8080:8080 : Interface web Jenkins sur http://localhost:8080.
-p 50000:50000 : Port pour les workers Jenkins (si vous en ajoutez plus tard).
-v jenkins_home:/var/jenkins_home : Persiste les données Jenkins (jobs, builds, credentials) même si vous redémarrez le conteneur.
-v /var/run/docker.sock:/var/run/docker.sock : Permet à Jenkins d'accéder au daemon Docker de l'hôte (Jenkins peut ainsi lancer des commandes docker build et docker push). C'est du "Docker-in-Docker" via socket.
Dans Jenkins, ajoutez les credentials nécessaires via Manage Jenkins → Credentials :

Créer une Pipeline Job dans Jenkins

📖 Webhook GitHub

Configurez un webhook dans votre repo GitHub pour déclencher le pipeline Jenkins automatiquement : Settings → Webhooks → Ajouter URL http://jenkins.example.com/github-webhook/ et sélectionner "Push events". À chaque git push, GitHub notifie Jenkins qui lance le pipeline immédiatement.