Aller au contenu

Pipeline CI/CD

Pourquoi un pipeline CI/CD ?

Sans automatisation, chaque déploiement repose sur des étapes manuelles : lancer les tests à la main, construire les images, se connecter au serveur, tirer les nouvelles images, redémarrer les services. Cette approche est lente, sujette aux erreurs et dépend du setup local de la personne qui déploie.

Le pipeline CI/CD de WonderWork automatise l'intégralité de cette chaîne et garantit trois propriétés fondamentales :

  • Qualité continue — aucun code cassé ne peut atterrir sur main. Lint, tests et scans de sécurité bloquent au niveau de la PR, avant fusion.
  • Reproductibilité — l'image Docker déployée en staging est byte-for-byte identique à celle qui a passé tous les tests. Pas de rebuild local, pas d'image construite à la main.
  • Traçabilité — chaque version déployée correspond à un tag Git sémantique et une entrée dans CHANGELOG.md. On sait à tout moment ce qui tourne sur le serveur et ce que ça contient.

Le pipeline est découpé en deux workflows distincts avec des responsabilités séparées.


Vue d'ensemble

flowchart TD
    A["Push / PR\nsur dev ou main"] --> CI

    subgraph CI["ci.yml — Qualité"]
        L["Lint\n(ECS + ESLint)"]
        T["Tests\n(Behat + Vitest)"]
        S["SAST\n(Trivy fs + Semgrep)"]
        L & T & S --> GATE{main push?}
        GATE -->|oui| B["Build Docker\n(ci-run_id)"]
        B --> IMG["Security Image\n(Trivy image scan)"]
        GATE -->|non| END_CI["✓ Qualité validée"]
    end

    CI -->|"workflow_run sur main"| REL

    subgraph REL["release.yml — Release & Deploy"]
        SR["Semantic Release\n(analyse commits)"]
        SR --> NEW{Nouvelle version?}
        NEW -->|oui| PROMO["Promote images\nci-id → vX.Y.Z + :latest\n(imagetools create)"]
        PROMO --> DEPLOY["Deploy Staging\n(Ansible)"]
        NEW -->|toujours| CLEAN["Cleanup\nci-{id} images"]
        DEPLOY --> CLEAN
    end

ci.yml — Workflow de qualité

Triggers : push sur dev et main, toutes les pull requests vers dev et main.

Objectif : vérifier qu'un commit ne casse rien, le plus tôt possible dans le cycle de développement. Plus un bug est détecté tôt, moins il coûte cher à corriger.

Jobs lint

Ces jobs s'exécutent en parallèle sur tous les triggers, y compris les PRs de branches de feature. Ils appliquent le formatage automatiquement et commitent les corrections avec le suffixe [skip ci] pour éviter de déclencher une boucle infinie.

Pourquoi l'auto-fix en CI plutôt que local uniquement ? Les développeurs n'ont pas besoin de configurer les outils de formatage dans leur environnement pour contribuer. Le style du code est uniforme indépendamment de l'éditeur ou du setup de chacun. Si une PR oublie de formatter, CI corrige silencieusement.

Job Outil Ce qu'il fait
lint-api ECS (PHP CS Fixer @Symfony) Formate le code PHP selon le standard Symfony — indentation, espaces, imports, ordre des méthodes
lint-candidate ESLint Détecte et corrige les erreurs TypeScript/React dans l'app candidat
lint-recruiter ESLint Idem pour l'app recruteur

Jobs test

Tests fonctionnels et unitaires. Bloquants — un test rouge bloque la PR et empêche le merge.

Job Outil Base de données Ce qu'il vérifie
test-api Behat + PHPUnit PostgreSQL 17 (service CI) Scénarios BDD mappés aux critères d'acceptance Notion
test-candidate Vitest Composants et hooks React de l'app candidat
test-recruiter Vitest Composants et hooks React de l'app recruteur

Pourquoi Behat ? Les tests API sont écrits en Gherkin (langage naturel lisible) et correspondent 1:1 aux critères d'acceptance des User Stories Notion. Un développeur qui rejoind le projet peut lire les scénarios et comprendre immédiatement le comportement attendu de chaque endpoint, sans avoir à naviguer dans le code source.

Pourquoi une vraie base PostgreSQL en CI et pas des mocks ? Les mocks de base de données donnent confiance à tort. Ils ne testent pas les contraintes FK, les index, les requêtes complexes, ni les migrations Doctrine. Un test qui passe sur un mock peut échouer silencieusement en production à cause d'une contrainte d'unicité ou d'une colonne nullable. Le service PostgreSQL éphémère de CI est configuré identiquement à la production.

Jobs SAST — Static Application Security Testing

Scans de sécurité sur le code source et les dépendances — sans build Docker, donc très rapides. Tournent en parallèle des tests dès la première PR.

Job Outil Ce qu'il cherche
sast-api Trivy fs + Semgrep Vulnérabilités dans composer.lock + failles dans api/src (ruleset PHP)
sast-candidate Trivy fs + Semgrep Vulnérabilités dans package-lock.json + failles dans candidate/src (JS/TS)
sast-recruiter Trivy fs + Semgrep Idem pour recruiter/src

Politique :

  • Vulnérabilité CRITICAL → job en échec, PR bloquée
  • Vulnérabilité HIGH → rapport dans les logs, non bloquant (évite les faux positifs sur des dépendances transitives difficiles à contrôler)
  • Semgrep → continue-on-error: true — informatif, ne bloque pas (trop de règles varient selon le contexte)

Pourquoi séparer SAST filesystem et scan d'image Docker ? Le SAST filesystem tourne sur tous les triggers y compris les PRs — il détecte les problèmes de dépendances au plus tôt, avant même de construire une image. Le scan d'image (après docker build) ne tourne que sur main car il nécessite un build complet et peut détecter des vulnérabilités dans les layers de base du système d'exploitation.

Jobs build (main push uniquement)

Ces jobs ne tournent que lorsqu'un commit atterrit sur main via un merge — pas sur les PRs, pas sur dev. On construit une image Docker uniquement si le code a déjà été validé et fusionné.

Pourquoi ne pas builder à chaque PR ? Construire une image Docker prend du temps et consomme des minutes CI. On ne veut pas payer ce coût pour chaque branche de feature — le lint et les tests suffisent pour valider une PR.

Les images sont taguées ci-{run_id}, un identifiant temporaire lié au run CI qui les a produites :

Job Image produite
build-api ghcr.io/wonderwork-io/wonderwork-api:ci-{run_id}
build-candidate ghcr.io/wonderwork-io/wonderwork-candidate:ci-{run_id}
build-recruiter ghcr.io/wonderwork-io/wonderwork-recruiter:ci-{run_id}

Pourquoi ci-{run_id} et pas :latest directement ? Parce que release.yml doit retrouver précisément l'image construite par ce run CI et pas une autre. L'identifiant de run est le fil conducteur entre les deux workflows. :latest serait écrasé à chaque push et ne permettrait pas cette traçabilité.

Le cache GitHub Actions (type=gha, scope main-{app}) est partagé entre les builds successifs — les layers Docker inchangées sont réutilisées.

Jobs security-image (main push uniquement)

Scan Trivy sur les images Docker construites. Nécessite les jobs build-* terminés. Détecte les vulnérabilités dans les layers de base (OS, packages système) que le SAST filesystem ne peut pas voir puisqu'il ne regarde que les dépendances applicatives.

Même politique que le SAST : CRITICAL bloque, HIGH est rapporté.


release.yml — Release et déploiement

Trigger : workflow_run sur CI / branche main / statut completed.

Pourquoi un workflow séparé et pas des jobs supplémentaires dans ci.yml ?

Deux raisons importantes :

  1. Isolation des permissionsrelease.yml accède aux secrets de l'environnement staging (clé SSH VPS, vault Ansible). ci.yml n'y a pas accès — ce qui est correct car une PR d'un contributeur externe ne doit jamais pouvoir déclencher un déploiement ou lire les secrets de production.

  2. Séparation des responsabilitésci.yml répond à "est-ce que le code est valide ?". release.yml répond à "est-ce qu'on publie une nouvelle version et on déploie ?". Ces deux questions ont des rythmes et des audiences différents.

Job release — Publication sémantique

Ne s'exécute que si ci.yml a réussi (conclusion == 'success'). Si CI a échoué, rien n'est publié ni déployé.

Utilise Semantic Release pour analyser automatiquement les commits fusionnés depuis la dernière version et décider si une nouvelle version doit être publiée — et de quel type.

Comment Semantic Release décide du numéro de version :

Le préfixe du message de commit (Conventional Commits) détermine le type de bump :

Prefix Type de bump Exemple
fix: patch v0.1.0v0.1.1
feat: minor v0.1.1v0.2.0
BREAKING CHANGE dans le footer major v0.2.0v1.0.0
chore:, refactor:, docs:, test: aucune release

Cela signifie qu'une série de commits de refactoring et de docs ne déclenchera aucun déploiement — uniquement les bugs corrigés (fix:) et les nouvelles fonctionnalités (feat:) en déclenchent un.

En plus du tag Git, Semantic Release crée automatiquement un commit chore(release): X.Y.Z [skip ci] qui met à jour CHANGELOG.md avec les notes de version générées.

Outputs du job : new_release_published (true ou false) et new_release_version (ex: 1.2.3), utilisés par les jobs suivants pour conditionner leur exécution.

Jobs promote-* — Promotion des images

Si new_release_published == 'true', ces trois jobs s'exécutent en parallèle pour re-tagger les images temporaires ci-{run_id} avec le numéro de version sémantique :

ghcr.io/.../wonderwork-api:ci-{run_id}
    → ghcr.io/.../wonderwork-api:v1.2.3
    → ghcr.io/.../wonderwork-api:latest

Mécanisme : docker buildx imagetools create copie uniquement les manifestes — pas les layers elles-mêmes. L'opération est quasi-instantanée et ne consomme pas de bande passante. L'image n'est pas reconstruite.

Pourquoi cette stratégie "build once, promote" ? C'est la garantie que ce qui est déployé en staging est exactement ce qui a passé les tests et les scans de sécurité. Un rebuild à partir du même Dockerfile pourrait produire une image différente : nouvelle version d'un package système dans le layer de base, dépendance résolue différemment, etc. Avec la promotion de manifeste, le SHA de l'image est identique du début à la fin du pipeline.

Job deploy-staging — Déploiement Ansible

Lance le playbook Ansible sur le VPS staging. Les images nouvellement taguées sont déployées via Docker Swarm.

ansible-playbook deploy.yml \
  -i inventory/staging.yml \
  --private-key ~/.ssh/deploy_key \
  -e "ansible_host=$VPS_IP" \
  -e "image_tag=v{version}"

Le détail du déploiement Ansible (rôles, séquence, health checks) est documenté dans la section Infrastructure (IaC).

Job cleanup — Suppression des images temporaires

Exécuté if: always() — même si le déploiement échoue. Supprime les images ci-{run_id} via l'API GitHub Packages.

Pourquoi ce cleanup ? Sans lui, GHCR accumulerait des centaines de tags temporaires (un par push sur main). Ces images ne servent plus après la promotion — les garder indéfiniment occupe de l'espace de stockage inutilement et rend difficile la navigation dans le registre.


Secrets GitHub

Tous les secrets sont définis dans l'environnement staging (Settings → Environments → staging) et non comme repository secrets globaux.

Pourquoi des environment secrets plutôt que des repository secrets ? L'environnement GitHub permet de restreindre quels workflows et quelles branches peuvent y accéder, d'auditer chaque utilisation, et d'ajouter des règles de protection (approbation manuelle, délai). Un repository secret global serait accessible par n'importe quel workflow, y compris ceux déclenchés par des PRs de contributeurs externes.

Secret Workflow qui l'utilise Usage
ANSIBLE_SSH_PRIVATE_KEY release.yml, docs.yml Clé SSH privée pour connexion VPS
ANSIBLE_VAULT_PASSWORD release.yml Déchiffrement des secrets Ansible (AES-256)
VPS_IP release.yml, docs.yml IP du VPS staging
OVH_S3_ACCESS_KEY terraform-staging.yml Accès au backend S3 OVH pour le state Terraform
OVH_S3_SECRET_KEY terraform-staging.yml Idem
OVH_APPLICATION_KEY terraform-staging.yml OVH API — gestion DNS et firewall
OVH_APPLICATION_SECRET terraform-staging.yml OVH API
OVH_CONSUMER_KEY terraform-staging.yml OVH API

GITHUB_TOKEN est automatiquement disponible dans tous les workflows — fourni par GitHub Actions, aucune configuration requise.


Stratégie de versionnage

Semantic versioning (vMAJOR.MINOR.PATCH) : le numéro de version exprime l'impact des changements sur les consommateurs de la plateforme.

  • PATCH (fix:) — correction de bug, comportement identique. Mise à jour transparente.
  • MINOR (feat:) — nouvelle fonctionnalité, rétrocompatible. Les clients existants ne sont pas cassés.
  • MAJOR (BREAKING CHANGE) — changement cassant dans l'API ou le comportement. Signale aux équipes intégrées qu'une migration est nécessaire.

WonderWork démarre à v0.x.x pendant la phase de développement initial — convention qui signale "l'API n'est pas encore stabilisée". Le passage à v1.0.0 sera explicitement déclenché par un commit avec BREAKING CHANGE et signalera la première version de production stable.

Tag initial obligatoire : Semantic Release a besoin d'un tag existant pour savoir depuis quand calculer les commits. Avant le premier merge sur main :

git tag v0.1.0 <sha-ancêtre-de-main>
git push origin v0.1.0

Sans ce tag, Semantic Release ne trouve aucun point de référence et publie v1.0.0 par défaut.