Intégration continue
La pipeline .gitlab-ci.yml garantit que chaque commit est testé avant les étapes de release : la règle de projet impose que toute modification de code soit accompagnée de ses tests, et ils doivent rester verts en CI, pas seulement en local.
Composants & stages
La pipeline inclut quatre composants partagés spektrum/ci-templates : release-tag, release-badges, release-detect, sync-claude-docs. La construction et le push de l'image sont écrits à la main (job publish-docker ci-dessous) ; le tagging et les badges restent délégués aux composants (pas de publish-docker componentisé, qui ferait double emploi avec release-tag).
Stages : test → contract → tag → detect → publish → docs.
Le stage contract génère le contrat d'API (api.json), que publish-docker embarque dans l'image où GET /api/contract le sert — voir Contrat d'API & types partagés. Le déploiement de l'image elle-même est décrit dans Déploiement.
Tagging & badges — composants (release-tag, release-badges)
Sur le commit de release qui atterrit sur trunk (chore(release): X.Y.Z, ou le titre synthétisé Merge branch 'release/…' en cas de merge commit), release-tag (stage tag) crée et pousse le tag git annoté X.Y.Z / X.Y.Z-staging.N, et release-badges régénère les SVG de version. Pré-requis : RELEASE_TOKEN (Protected + Masked, write_repository) et squash désactivé sur les MR release/*.
Build + push de l'image — publish-docker (pipeline de tag)
Le push du tag par release-tag déclenche un nouveau pipeline ($CI_COMMIT_TAG). Y tournent :
release-detect(stagedetect) — émet en dotenvVERSION,CHANNEL(production/staging) etRELEASE_TYPEpour les jobs aval ;publish-docker(stagepublish,tags: [linux]) — ne tague rien (le tag existe déjà) ; il consomme le dotenv vianeeds: [release-detect], construitdocker/php/Dockerfile.prodavec--build-arg VERSION_TAG=$VERSION --build-arg BUILD_DATE=…, puis pousse:$VERSIONet un tag flottant choisi selon$CHANNEL(latesten prod,latest-stagingen staging). Il ne dépend plus d'un job de test : le tag porte exactement le SHA que le trunk vient de tester, et la MR de release l'avait déjà testé sous le seuil bloquant.needs: [contract]place l'artefactapi.jsondans le contexte de build, que leCOPY . .du Dockerfile embarque dans l'image (servi parGET /api/contract, cf. Contrat d'API) ;- Variante staging :
$CHANNELpilote aussi le build-argCOMPOSER_NO_DEV. En prod il vaut--no-dev(image slim, dépendances de dev exclues) ; en staging il est vide, si bien que les dépendances de dev — notammentfakerphp/faker— restent présentes. C'est la seule différence entre les deux images, et elle permet aux seeders basés sur les factories (qui appellent le helperfake()) de tourner sur staging. Le helperfake()de Laravel n'est défini que si\Faker\Factoryexiste ; sans faker (cas prod), l'appeler lèveCall to undefined function fake(). La prod ne seed jamais (cf. Déploiement), donc faker n'y est de toute façon pas nécessaire. Autre effet de la variante : staging conserve Scramble, doncGET /api/contractpeut y régénérer le document à la volée si le fichier embarqué manquait.
- Variante staging :
| Tag git | $CHANNEL | Tags d'image poussés (registry.internal.spektrum-suisse.ch/roadtrip-backend) | Dépendances de dev (faker) |
|---|---|---|---|
X.Y.Z-staging.N | staging | :X.Y.Z-staging.N + :latest-staging | incluses (seed possible) |
X.Y.Z | production | :X.Y.Z + :latest | exclues (--no-dev) |
Runner : daemon Docker pré-authentifié au registre (
tags: [linux], pas dedocker login). Les tags flottantslatest/latest-stagingsont des espaces disjoints (cf. Déploiement).
Déclenchement des pipelines (workflow:)
Un bloc workflow: restreint les pipelines à trois sources :
workflow:
auto_cancel:
on_new_commit: interruptible
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_TAG'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- if: '$CI_PIPELINE_SOURCE == "schedule"'auto_cancel ne touche que les jobs qui ont explicitement interruptible: true (ceux du template .php). Tout ce qui mute un état partagé — release-tag qui pousse un tag, publish-docker qui pousse une image, build-ci-image — reste non interruptible : une annulation en plein push corrompt une release. La source schedule sert au rafraîchissement périodique de l'image de CI.
On ne lance pas de pipeline de branche pour les pushes hors trunk — en particulier pour release/*. La raison est une course : release-util fusionne la MR de release en fast-forward sur trunk puis GitLab supprime la branche source ; un pipeline de branche release/* encore en file d'attente se retrouve orphelin et échoue au clonage (fatal: couldn't find remote ref refs/heads/release/X.Y.Z), car la ref n'existe plus. En portant les contrôles sur l'événement merge request, ils tournent avant le merge (la branche existe encore) et, combinés au réglage projet « Pipelines must succeed », bloquent le merge tant qu'ils ne sont pas verts. Les tags et trunk conservent leurs pipelines (publish).
Workflow local de release
Le dépôt suit le flow release Spektrum : trunk-based et forward-only, piloté par la CLI @spektrum/release-util. Le scope @spektrum est déjà configuré dans .npmrc, donc npx résout l'outil sans configuration supplémentaire (Node ≥ 20 requis sur le poste). .release-it.json à la racine fixe trunkBranch: main et la convention de commit chore(release): ….
Depuis un main propre et à jour avec origin/main :
# Release staging (X.Y.Z-staging.N)
npx @spektrum/release-util@latest release --env staging
# Release production (X.Y.Z)
npx @spektrum/release-util@latest release --env prodLa CLI calcule la version suivante à partir des conventional commits depuis le dernier tag, ouvre une MR release/* portant le commit chore(release): …, et ne push jamais directement sur main. Une fois la MR mergée (merge commit ou fast-forward, jamais squash — release-tag cherche le sujet chore(release): X.Y.Z exactement), la CI prend le relais : release-tag push le tag annoté, dont le push déclenche le pipeline de tag où publish-docker construit et pousse l'image (contrat api.json embarqué). La distinction staging / production est portée par l'étape de cut (--env prod est l'action humaine délibérée) et propagée via $CHANNEL (release-detect).
Forward-only — règles absolues : jamais de retag, jamais de force-push de tags ou de main, jamais de --no-verify. Une release cassée se corrige en mergeant un fix et en cutant l'incrément suivant. Ne pas exécuter les étapes tag/publish à la main : la CLI et les composants CI en sont propriétaires. Le modèle complet (flux standard, features longues, hotfixes) est documenté sur le serveur MCP internal-docs (release-flow).
Graphe de dépendances (needs:)
Aucun des quatre composants spektrum/ci-templates ne déclare needs:. Par défaut un job attend donc tous les stages précédents, même sans lien avec eux : release-detect a été mesuré inactif 5 min 48 s derrière un job de test dont il ne consomme rien, et la somme des durées de jobs égalait exactement la durée du pipeline. Ce n'était pas une limite de capacité des runners.
Redéclarer un job de composant fait un deep-merge : image, tags, script et rules restent ceux du composant, on n'ajoute que needs:.
| Job | needs: | Pourquoi |
|---|---|---|
test, contract | build-ci-image (optional: true) | l'arête n'existe que dans les pipelines qui reconstruisent l'image ; ailleurs les deux démarrent à t=0 |
release-tag | contract | ne se déclenche que sur un commit chore(release): du trunk, fast-forward d'une MR déjà passée par le seuil bloquant : le contenu est gardé avant le merge |
release-detect, sync-claude-docs | [] | ne dépendent de rien |
publish-docker | release-detect, contract | le dotenv et api.json |
Image de CI préconstruite
Les jobs PHP tournent sur $CI_PHP_IMAGE, construite depuis docker/php/Dockerfile.ci et publiée dans le registre privé Spektrum ($REGISTRY/roadtrip-backend-ci). Auparavant chaque job reconstruisait sa toolchain depuis les sources à chaque pipeline — apt-get ~7,5 s, docker-php-ext-install pdo_pgsql pgsql ~21 s, pecl install pcov ~5,8 s, plus le téléchargement de l'installeur Composer. Le job contract en était l'illustration : 3,3 s de travail utile sur 64,4 s.
- Tag = hash du contenu du Dockerfile (
8.5-<sha256[:12]>, variableCI_PHP_IMAGE_TAG). Le jobbuild-ci-imagerecalcule le hash et échoue bruyamment si la variable n'a pas été mise à jour : une image périmée ne peut pas épingler silencieusement un vieux PHP, et construire depuis une branche non mergée est sans risque puisque le contenu est le tag. - Registre privé Spektrum (
$REGISTRY, variable globale), le même que celui oùpublish-dockerpousse l'image de production. Il n'exige pas d'authentification : nidocker loginau push, niDOCKER_AUTH_CONFIGpour que le runnerdockertire l'image — ce qui tombe bien, aucun n'est défini sur le projet ni sur ses groupes parents. - Aucun
DOCKER_CONFIGpropre au job, délibérément :build-ci-imagetourne sur le runnerlinux(shell), dont les jobs concurrents partagent~/.docker/config.json. Comme le job ne se connecte jamais, il n'écrit pas dans ce fichier et ne peut donc pas perturberpublish-docker; il hérite au passage des identifiants ambiants du runner, et fonctionne que le push exige des droits ou non. - Extensions : mêmes que l'image de production (
pdo_pgsql,pgsql,bcmath,intl,zip,pcntl) pluspcov.opcachen'y figure pas :php:8.5-clil'embarque déjà compilé en dur, etdocker-php-ext-install opcacheéchoue alors surcp: cannot stat 'modules/*'.
Job test (stage test)
Un unique job exécute la suite complète, en parallèle :
php artisan test --parallel --processes="$TEST_PROCESSES" $TEST_COVERAGE --log-junit junit.xml--parallelet--coveragene se composent pas dans l'image de CI actuelle : la suite passe, mais aucune couverture n'est collectée, aucune ligne « Total: » n'est produite, et--min=80ne garde donc rien. Constaté sur les pipelines 11262, 11267 et 11269, puis reproduit hors CI sur l'image publiée. En séquentiel la même image rend bien « Total: 99.3 % », et la même commande parallèle fonctionnait sur une image antérieure dépourvue debcmath/intl/zip/pcntl— le déclencheur exact reste à identifier (ce n'est nipcov.directory, nicacheDirectory: les deux ont été testés). En attendant, demander la couverture impose le mode séquentiel, et un filet de sécurité fait échouer le job si la couverture a été demandée sans qu'aucun total ne soit produit. La release1.0.0-staging.39est partie avec cette porte inerte : c'est précisément ce que le filet empêche de reproduire.- Parallélisme : ParaTest (
brianium/paratest), sur les pipelines sans couverture — c'est-à-dire les MR ordinaires, celles où le temps de retour compte le plus. Chaque worker migre sa propre basetesting_test_{token}; PostGIS suit sans traitement particulier, car l'extension est activée par une migration ordinaire. Mesuré en local sur la suite complète (1401 tests) : 77,5 s en séquentiel contre 30,8 s sur 4 processus. Le nombre de workers est explicite (TEST_PROCESSES) et jamais auto-détecté — et lebefore_scripttrace pourquoi : sur le runnerdocker,nprocannonce 16 cœurs alors quecpu.maxvaut200000 100000, soit 2 CPU réellement alloués. L'auto-détection lancerait 16 workers sur 2 CPU.TEST_PROCESSES: "4"est donc déjà au-dessus de l'allocation : c'est le seul chiffre à ajuster si le job de test devient le goulot, et l'augmenter sans augmenter le quota du runner ne rapportera rien. - Le choix des drapeaux de couverture est fait en shell, pas par
rules:variables— et c'est délibéré. Porté parrules:variables, il n'a pas fonctionné : les pipelines 11262 (MRrelease/*) puis 11267 (trunk) ont tous deux exécuté la suite sans--coverage, donc sans seuil, alors que la règle correspondante était bien celle qui créait le job ; retirer le défautTEST_COVERAGE: ""du niveau job n'y a rien changé. La cause exacte côté GitLab n'a pas été élucidée. Lecaseshell duscript:est déterministe et échoue de façon visible, et la ligneCouverture : …qu'il affiche rend le choix lisible dans le log. Un garde-fou qui se désactive en silence est pire que pas de garde-fou : vérifier cette ligne dans le job de la MR de release avant de merger. - Plancher : ParaTest répartit par fichier, donc la durée minimale est celle du fichier le plus long. Le rapport JUnit (
artifacts:reports:junit) donne les durées par test via l'APItest_report, sans parcourir le log. - Base de données : service PostgreSQL jetable (
postgis/postgis:17-3.5, aliasdb-test), durabilité désactivée comme dansdocker-compose.test.yml. - Couverture : PCOV, fourni par l'image. La clé
coverage:(regex) ne sert qu'à afficher le pourcentage dans l'UI et les badges ; le gate est--min. - Cache :
vendor/, clé dérivée decomposer.lock.contractle consomme enpolicy: pull, puisquetestle remplit déjà dans le même pipeline.
Couverture : mesurée là où elle sert
Mesurer la couverture coûte cher, et la suite tournait trois fois sur le même arbre (MR, trunk, tag) — 12 min 40 s des 21 minutes d'une release.
| pipeline | commande | rôle |
|---|---|---|
| MR ordinaire | --parallel | le feedback le plus court |
MR release/* | --parallel --coverage --min=80 | la porte qualité |
| trunk | --parallel --coverage | alimente le badge, hors chemin critique |
| tag | le job ne tourne pas | même SHA que le trunk |
Le seuil ne bloque que sur la MR de release : elle est fast-forwardée sur le trunk sous « Pipelines must succeed », donc le contenu est gardé avant le merge.
Le trunk mesure sans seuil plutôt que de ne pas mesurer : le badge du projet pointe badges/%{default_branch}/coverage.svg sans ?job=, donc il lit le dernier pipeline de la branche par défaut — si le trunk cesse d'émettre la couverture, le badge passe à « unknown ».
Le trunk garde son run de test parce que merge_pipelines_enabled est false : les pipelines de MR testent la tête de la branche source, pas le résultat du merge. Sur un merge de feature ordinaire, le pipeline main est le seul à tester l'arbre fusionné. Il est simplement sorti du chemin critique de la release par release-tag: needs: [contract].
L'échec des tests est bloquant partout où le job tourne. Le allow_failure: true historique servait à tolérer l'échec du seuil sur un dépôt encore squelettique ; le seuil ayant quitté les pipelines ordinaires, une suite rouge n'a plus de raison de passer.
Points de vigilance
- Les services PostgreSQL des jobs
coverageetcontracttournent sur l'imagepostgis/postgis:17-3.5(commedocker-compose.ymletdocker-compose.test.yml) : la recherche par proximité dépend de l'extension PostGIS, créée par migration (CREATE EXTENSION IF NOT EXISTS postgis) à chaque montée de la suite. Les runners doivent pouvoir tirer cette image depuis Docker Hub (miroir interne en secours). - Les deux jobs qui poussent dans
$REGISTRYpartagent la fonctionpush_with_retry(template.docker-push) : le registre est adossé à Swift, seulement cohérent à terme sur le listing des containers. Le pull reste en théorie sujet à la même latence — dans un pipeline qui reconstruit l'image de CI,testla tire quelques secondes après le push. Si cela se manifeste, la parade est unneedsdéjà en place plus une attente côtébuild-ci-image, pas un retrait du parallélisme. - Les extensions PHP de
docker/php/Dockerfile.cidoivent rester en phase avec celles dedocker/php/Dockerfile.prod: c'est ce qui garantit que la CI exerce le même PHP que la production. Toute modification du Dockerfile impose de mettre à jourCI_PHP_IMAGE_TAG—build-ci-imageéchoue sinon. pull_policy: if-not-presentsur l'image de CI ferait gagner 2 à 4 s par job, mais un runner dontallowed_pull_policiesne l'autorise pas fait échouer le job ; ce réglage vit dans leconfig.tomldu runner et n'est pas lisible via l'API. À tenter seulement en connaissance de la configuration des runners.

