Skip to content

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 : testcontracttagdetectpublishdocs.

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 (stage detect) — émet en dotenv VERSION, CHANNEL (production / staging) et RELEASE_TYPE pour les jobs aval ;
  • publish-docker (stage publish, tags: [linux]) — ne tague rien (le tag existe déjà) ; il consomme le dotenv via needs: [release-detect], construit docker/php/Dockerfile.prod avec --build-arg VERSION_TAG=$VERSION --build-arg BUILD_DATE=…, puis pousse :$VERSION et un tag flottant choisi selon $CHANNEL (latest en prod, latest-staging en 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'artefact api.json dans le contexte de build, que le COPY . . du Dockerfile embarque dans l'image (servi par GET /api/contract, cf. Contrat d'API) ;
    • Variante staging : $CHANNEL pilote aussi le build-arg COMPOSER_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 — notamment fakerphp/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 helper fake()) de tourner sur staging. Le helper fake() de Laravel n'est défini que si \Faker\Factory existe ; sans faker (cas prod), l'appeler lève Call 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, donc GET /api/contract peut y régénérer le document à la volée si le fichier embarqué manquait.
Tag git$CHANNELTags d'image poussés (registry.internal.spektrum-suisse.ch/roadtrip-backend)Dépendances de dev (faker)
X.Y.Z-staging.Nstaging:X.Y.Z-staging.N + :latest-stagingincluses (seed possible)
X.Y.Zproduction:X.Y.Z + :latestexclues (--no-dev)

Runner : daemon Docker pré-authentifié au registre (tags: [linux], pas de docker login). Les tags flottants latest / latest-staging sont des espaces disjoints (cf. Déploiement).

Déclenchement des pipelines (workflow:)

Un bloc workflow: restreint les pipelines à trois sources :

yaml
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 :

bash
# 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 prod

La 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 squashrelease-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:.

Jobneeds:Pourquoi
test, contractbuild-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-tagcontractne 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-dockerrelease-detect, contractle 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]>, variable CI_PHP_IMAGE_TAG). Le job build-ci-image recalcule 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-docker pousse l'image de production. Il n'exige pas d'authentification : ni docker login au push, ni DOCKER_AUTH_CONFIG pour que le runner docker tire l'image — ce qui tombe bien, aucun n'est défini sur le projet ni sur ses groupes parents.
  • Aucun DOCKER_CONFIG propre au job, délibérément : build-ci-image tourne sur le runner linux (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 perturber publish-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) plus pcov. opcache n'y figure pas : php:8.5-cli l'embarque déjà compilé en dur, et docker-php-ext-install opcache échoue alors sur cp: cannot stat 'modules/*'.

Job test (stage test)

Un unique job exécute la suite complète, en parallèle :

bash
php artisan test --parallel --processes="$TEST_PROCESSES" $TEST_COVERAGE --log-junit junit.xml
  • --parallel et --coverage ne 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=80 ne 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 de bcmath/intl/zip/pcntl — le déclencheur exact reste à identifier (ce n'est ni pcov.directory, ni cacheDirectory : 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 release 1.0.0-staging.39 est 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 base testing_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 le before_script trace pourquoi : sur le runner docker, nproc annonce 16 cœurs alors que cpu.max vaut 200000 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é par rules:variables, il n'a pas fonctionné : les pipelines 11262 (MR release/*) 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éfaut TEST_COVERAGE: "" du niveau job n'y a rien changé. La cause exacte côté GitLab n'a pas été élucidée. Le case shell du script: est déterministe et échoue de façon visible, et la ligne Couverture : … 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'API test_report, sans parcourir le log.
  • Base de données : service PostgreSQL jetable (postgis/postgis:17-3.5, alias db-test), durabilité désactivée comme dans docker-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 de composer.lock. contract le consomme en policy: pull, puisque test le 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.

pipelinecommanderôle
MR ordinaire--parallelle feedback le plus court
MR release/*--parallel --coverage --min=80la porte qualité
trunk--parallel --coveragealimente le badge, hors chemin critique
tagle job ne tourne pasmê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 coverage et contract tournent sur l'image postgis/postgis:17-3.5 (comme docker-compose.yml et docker-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 $REGISTRY partagent la fonction push_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, test la tire quelques secondes après le push. Si cela se manifeste, la parade est un needs déjà en place plus une attente côté build-ci-image, pas un retrait du parallélisme.
  • Les extensions PHP de docker/php/Dockerfile.ci doivent rester en phase avec celles de docker/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 à jour CI_PHP_IMAGE_TAGbuild-ci-image échoue sinon.
  • pull_policy: if-not-present sur l'image de CI ferait gagner 2 à 4 s par job, mais un runner dont allowed_pull_policies ne l'autorise pas fait échouer le job ; ce réglage vit dans le config.toml du runner et n'est pas lisible via l'API. À tenter seulement en connaissance de la configuration des runners.

Contributors

No contributors

Changelog

No recent changes