Un pipeline CI/CD de SaaS B2B doit prouver que le code est correct, que ses dépendances et ses images ne portent pas de vulnérabilité connue, que l’artefact livré est bien celui qui a été construit, et qu’on peut revenir en arrière sans rien reconstruire. Dans saas-core, cette chaîne tient en dix workflows GitHub Actions, certains bloquants, d’autres informatifs. Cet article les décrit avec leurs déclencheurs réels et renvoie aux articles qui détaillent chaque outil.

Quels workflows composent la chaîne ?

Dix fichiers sous .github/workflows/, chacun avec un rôle unique :

WorkflowDéclencheurRôleBloquant ?
cipush sur main, pull request, manueltypecheck, lint, frontières, tests sur vrai PostgreSQL, build, contrat à jour, secrets, dépendances ; job imagesoui pour la livraison
e2epush sur main, pull request, manuelparcours navigateur Playwright contre la vraie API et le workeroui pour la livraison
sastpush sur main, pull request, manuelanalyse statique Semgreprouge sur tout résultat ; hors porte de livraison
sbompush sur main, manuelSBOM CycloneDX de l’application et des images d’infrastructurenon, inventaire archivé
image-scanlundi 05:00 UTC, manuelreconstruction et scan des images de mainrouge sur une critique corrigée en amont ; hors porte de livraison
scorecardlundi 06:00 UTC, manuelOpenSSF Scorecard sur le dépôtnon, informatif
loadétiquette v*, manuelscénario k6 sur l’API compiléenon, avertissement si seuil franchi
release-imagesétiquette v*, manuelimages arm64, signature Cosign, provenance SLSA, OpenVEX, livraisonla livraison s’arrête si le journal des modifications manque ou si ci et e2e ne sont pas verts
deploymanuel uniquementrelivrer une étiquette existante : le retour arrièresans objet
doc-impactpull requestpages de documentation à relire, référence générée à jourrouge si la référence diffère ; non exigé à la fusion

« Bloquant » vise la mise en ligne d’une version, gardée par une porte décrite plus bas. La protection de branche de main est aujourd’hui absente : un ci rouge n’empêche pas une fusion.

Que vérifie le workflow ci ?

Il vérifie, dans cet ordre, tout ce qui peut se prouver sans navigateur. Le job principal démarre un service PostgreSQL 17 épinglé par empreinte, puis enchaîne :

  1. pnpm typecheck, pnpm lint (ESLint) et pnpm depcruise : dependency-cruiser interdit les imports qui franchissent une frontière, par exemple un écran qui importerait directement le fournisseur d’authentification, ou une dépendance circulaire ;
  2. pnpm check:css et pnpm check:size, puis les tests des scripts d’outillage ;
  3. pnpm test --continue : les suites de tous les paquets contre le vrai PostgreSQL, méta-tests compris (isolation RLS, journal d’audit) ; --continue empêche un paquet rouge de masquer les suivants. Le détail est dans l’article sur Testcontainers et les tests sur un vrai PostgreSQL ;
  4. pnpm build, puis la vérification que le contrat OpenAPI et le SDK généré correspondent au code ;
  5. gitleaks sur tout l’historique, pnpm audit --audit-level=high et OSV-Scanner.

L’étape du contrat illustre la philosophie du fichier : ce qui se génère se régénère et se compare (extrait abrégé).

- name: Le contrat et le SDK sont a jour
  run: |
    pnpm --filter @saas-core/api generate:openapi
    pnpm --filter @saas-core/sdk generate
    if ! git diff --quiet -- apps/api/openapi.json packages/sdk/src/schema.d.ts; then
      echo "::error::openapi.json ou schema.d.ts ne correspond plus au code."
      exit 1
    fi

Que fait le job images ?

Il construit les trois images livrées (API, worker, admin) à chaque PR, sans les pousser, puis démarre l’image de l’API et interroge /health. Il vérifie ensuite que les images d’exécution n’embarquent aucun outil de construction (npm, npx, corepack, yarn), et scanne les images avec syft (SBOM) et grype (vulnérabilités) via scripts/sbom/scan-images.mjs. Une vulnérabilité critique déjà corrigée en amont fait échouer le job, sauf exception motivée et datée dans image-scan-exceptions.json. Ce job appartient au workflow ci : son échec rend le run ci rouge.

Pourquoi séparer e2e de ci ?

Pour distinguer d’un coup d’œil une panne d’API d’une panne d’interface, et pour que la suite navigateur, plus lente, ne retarde pas le retour rapide des tests. Le workflow e2e démarre PostgreSQL, Redis et Mailpit en services, crée le rôle applicatif NOBYPASSRLS, applique les migrations, teste le worker d’un produit sur Redis réel, puis lance Playwright : un parcours d’invitation n’est prouvé que si l’e-mail est réellement relayé et reçu.

Comment la chaîne couvre-t-elle la sécurité des dépendances et du code ?

Par plusieurs outils complémentaires, chacun regardant une surface différente :

  • Secrets : gitleaks parcourt tout l’historique git (fetch-depth: 0), binaire officiel vérifié par somme SHA-256 ; voir l’article sur gitleaks.
  • Dépendances : pnpm audit au niveau high, puis OSV-Scanner, épinglé par version et empreinte, avec les cas qualifiés « non affecté » consignés dans osv-scanner.toml ; voir OSV-Scanner, pnpm audit et VEX.
  • Code écrit : Semgrep, avec les jeux de règles p/typescript, p/nodejs et p/secrets et l’option --error ; voir l’article sur Semgrep.
  • Inventaire : le workflow sbom produit un SBOM CycloneDX de l’application avec cdxgen et des images PostgreSQL et Redis avec syft ; voir SBOM CycloneDX avec cdxgen et syft. L’analyse continue de ces SBOM se fait dans Dependency-Track, hors CI, avec docker-compose.dtrack.yml ; voir Dependency-Track.
  • Images : grype dans le job images à chaque PR, et chaque lundi dans image-scan, parce qu’une vulnérabilité publiée après la construction rend une image vulnérable sans qu’aucune ligne ne change.
  • Posture du dépôt : OpenSSF Scorecard chaque semaine, résultat archivé en artefact, sans effet sur la chaîne.

Chaque outil est épinglé par version et empreinte plutôt que latest : sinon le code du contrôle change entre deux exécutions, et un rouge devient indiscernable d’une régression réelle.

Comment une étiquette devient-elle une mise en ligne ?

Par le workflow release-images, qui enchaîne quatre étapes après une étiquette vX.Y.Z :

  1. Journal des modifications : scripts/changelog-check.mjs refuse une étiquette absente de CHANGELOG.md et CHANGELOG.en.md.
  2. Construction : les trois images sont construites nativement sur un runner auto-hébergé arm64, une par cible, puis réunies en un manifeste unique et poussées vers GHCR.
  3. Signature et attestations : signature Cosign keyless grâce au jeton OIDC du runner, provenance SLSA attachée par cosign attest, document OpenVEX attaché comme attestation signée, puis cosign verify sur ce qui vient d’être signé. Le principe est détaillé dans l’article sur Cosign et SLSA.
  4. Livraison : le job livrer interroge l’API GitHub sur la conclusion des runs ci et e2e du commit exact de l’étiquette.

La quatrième étape répond à un défaut constaté : ci ne se déclenche pas sur une étiquette, et une étiquette posée sur un commit rouge produisait une image signée, attestée et déployée. La signature atteste l’origine, jamais la qualité. La porte applique quatre verdicts (extrait simplifié) :

case "$verdict" in
  success) break ;;
  failure|timed_out)
    echo "::error::livraison refusée — ${flux} sur ${GITHUB_SHA} : ${verdict}"
    exit 1 ;;
  cancelled)
    # relance du run annulé, deux fois au plus
    ;;
  *) sleep 30 ;;
esac

Un run en cours est attendu jusqu’à 30 minutes ; un run annulé par la concurrence est relancé deux fois au plus ; l’absence de run au bout du délai est refusée, car « aucun test n’a tourné » mérite le même refus que « les tests ont échoué ». La commande de livraison n’est pas dans le dépôt : le workflow appelle celle que déclare la variable DEPLOY_COMMAND, sans connaître la machine ni l’hébergeur. L’organisation côté serveur (Docker Compose, reverse proxy, outil comme Coolify) est discutée dans l’article sur la livraison avec Coolify, Traefik et Docker Compose.

Comment revenir en arrière ?

En relivrant une étiquette précédente avec le workflow deploy, déclenché à la main avec l’étiquette en paramètre. Il ne reconstruit rien : les images signées existent déjà. Il n’a volontairement aucun déclencheur sur étiquette : lancé par le même push que la construction, il chercherait des images pas encore construites. Les livraisons sont sérialisées sans annulation.

Que reste-t-il d’informatif, et pourquoi ?

Trois workflows signalent sans bloquer, chacun pour une raison écrite dans son fichier :

  • load tourne sur chaque étiquette, en parallèle de la release. Ses seuils de latence se mesurent sur un runner partagé qui construit souvent des images au même moment ; un franchissement produit un avertissement et un résumé en artefact, pas un échec.
  • scorecard est un outil d’hygiène hebdomadaire, sans déclencheur sur push.
  • doc-impact commente chaque PR avec les pages de documentation à relire et échoue si la référence générée ne correspond plus au code, mais il n’est pas exigé à la fusion : relire une page reste un geste humain.

Quels pièges d’exploitation éviter ?

Trois réglages du dépôt viennent d’incidents et valent pour toute chaîne similaire :

  • Jeton en lecture seule : ci, e2e et sast, qui exécutent le code d’une pull request, déclarent permissions: contents: read.
  • Concurrence partagée : ci et e2e partagent le groupe db-service-shared sur un runner où le démon Docker est commun. GitHub ne garde qu’un run en attente par groupe ; un nouveau run annule celui qui attendait. Un run cancelled n’est donc pas un verdict : il se relance, et la porte de livraison le fait elle-même.
  • Binaires vérifiés : gitleaks, k6, Scorecard et crane sont téléchargés depuis leur release officielle et vérifiés par une somme SHA-256 écrite dans le workflow, pas lue à côté de l’archive.

Comment saas-core l’intègre

  • Les dix workflows sont dans .github/workflows/ : ci.yml, e2e.yml, sast.yml, sbom.yml, image-scan.yml, scorecard.yml, load.yml, release-images.yml, deploy.yml, doc-impact.yml.
  • La porte locale pnpm run ci (package.json) rejoue typecheck, lint, frontières (.dependency-cruiser.cjs), check:css, check:size et tests ; la CI y ajoute build, contrat, gitleaks, audit et OSV.
  • Le scan des images est commun à ci et à image-scan (scripts/sbom/scan-images.mjs, exceptions dans image-scan-exceptions.json) ; le document OpenVEX attaché aux images est security/vex/saas-core.openvex.json.
  • Bloquant pour la livraison : ci (job images compris) et e2e verts sur le commit exact, et l’entrée du journal des modifications. Rouge sans bloquer la livraison : sast, image-scan, doc-impact. Informatif : sbom, scorecard, load.
  • Absent : la protection de branche de main. Aucune fusion n’est empêchée par la CI aujourd’hui ; c’est la livraison qui est gardée.

Pour aller plus loin

Ces workflows sont livrés avec le socle ; la livraison s’y appuie sur des variables de dépôt (runner, commande et environnement de livraison) plutôt que sur une infrastructure écrite en dur. Le contenu de chaque formule est présenté sur la page tarifs.