Le problème

Prenons une boutique en ligne en monorepo. Les services Symfony order, stock, shipping et billing partagent le package app-contracts, et des fronts Vue.js les consomment. L'ensemble est orchestré par moon : chaque projet déclare ses tâches (lint, test, docker-build...) dans son moon.yml.

La CI d'un tel dépôt dérive souvent vers l'un de ces symptômes.

Un pipeline par service, copié puis modifié. Le pipeline de stock lance PHPStan, celui de shipping l'a perdu lors d'une copie. Chaque pipeline filtre ses déclenchements par chemins, et personne n'a ajouté app-contracts au filtre de billing. Un contrat modifié passe sans que billing soit testé.

Tout, à chaque pull request. Pour ne rien oublier, un pipeline unique lance tous les tests de tous les projets. Sa durée suit la taille du dépôt, et l'équipe apprend à ne plus attendre son verdict.

Une logique qui n'existe qu'en CI. Le YAML calcule lui-même les projets touchés, avec git diff HEAD~1 et quelques scripts shell. Personne ne peut rejouer ce calcul sur son poste, et un échec en CI devient difficile à reproduire.

Les options

Un pipeline par projet, filtré par chemins

  • Ce qu'il règle : chaque service a son pipeline, ses journaux et son statut.
  • Ce qu'il coûte : du YAML dupliqué, et un graphe de dépendances recopié à la main dans chaque filtre. Sur GitHub, un workflow écarté par son filtre de chemins laisse ses checks obligatoires en attente : la fusion reste bloquée.

Tout lancer à chaque pull request

  • Ce qu'il règle : rien n'est oublié, et le YAML reste court.
  • Ce qu'il coûte : une durée qui croît avec le dépôt et des runners occupés pour des projets intacts. Chaque pull request attend plus longtemps son verdict.

Les tâches toutes faites de la plateforme

  • Ce qu'elles règlent : chaque plateforme propose des actions ou des templates prêts à l'emploi pour PHP, Node.js ou Docker.
  • Ce qu'elles coûtent : elles ne connaissent pas le graphe des projets et ne tournent pas sur un poste de développement. Elles lient aussi la chaîne à une plateforme.

Un pipeline mince, piloté par moon

  • Ce qu'il règle : le graphe des projets et les tâches vivent dans le dépôt, à côté du code. moon calcule ce qui est affecté, et les mêmes commandes tournent en local.
  • Ce qu'il coûte : un outil de plus à apprendre et à maintenir. Le graphe doit être déclaré avec soin, et la CI doit récupérer l'historique git complet.

Notre recommandation

C'est moon qui décide quoi construire et tester ; la plateforme de CI orchestre et trace.

Le YAML se réduit à trois gestes : récupérer l'historique complet, installer la chaîne d'outils épinglée par proto, lancer moon. La plateforme garde ce qu'elle fait bien : les runners, les secrets, les journaux, les statuts et les règles de fusion.

moon décide, la CI orchestre et trace : les mêmes tâches tournent en local Plateforme de CI un YAML mince, versionné 1. clone, historique complet 2. outils épinglés par proto 4. matrice : un job par image 5. check obligatoire garde les runners, les secrets, les journaux et les statuts moon décide quoi lancer graphe déclaré dans les moon.yml 3. moon ci base, head tâches app-contracts modifié order stock shipping billing dépendants affectés : leurs tâches tournent front Vue.js non affecté, ignoré Poste de développement : les mêmes tâches, les mêmes versions moon run order:test moon run :lint --affected --status=staged

Ce que moon décide

moon ci compare la révision courante à une base, et liste les fichiers modifiés. Il en déduit les tâches affectées à partir de leurs entrées (inputs), puis ajoute leurs dépendances et leurs dépendants directs. Les tâches des projets intacts ne tournent pas.

Les mêmes tâches en local

moon run order:test lance sur un poste la tâche exacte que lance la CI, avec les versions de moon et de Node.js épinglées dans .prototools. moon run :lint lance le lint de tous les projets, et le hook de pre-commit le limite aux fichiers indexés : moon run :lint --affected --status=staged. Un échec en CI se reproduit avec une commande, sans relire le YAML.

Une quality gate imposée par la plateforme

Une tâche n'est obligatoire que si la fusion en dépend. Les règles de protection de branche (ou les rulesets de GitHub) exigent un check au vert sur le dernier commit de la pull request. Nous rendons obligatoire un seul job, qui agrège tous les autres.

La bonne base de comparaison, pas HEAD~1

HEAD~1 désigne le premier parent du commit courant : git diff HEAD~1 HEAD montre les changements d'un seul commit. Ce périmètre n'est juste que si chaque exécution correspond à un commit, et que chaque exécution va au bout. Trois cas courants le contredisent.

  • Une pull request de plusieurs commits. Si le job extrait le dernier commit poussé, et non le commit de fusion de test, il ne voit que lui. Or les règles de protection ne regardent que les checks du dernier commit.
  • Un push de plusieurs commits. Une fusion par rebase ou un push direct ajoutent plusieurs commits pour une seule exécution. HEAD~1 ignore tous ces commits sauf le dernier.
  • Une exécution annulée ou en échec. Dans un groupe de concurrence, une exécution en attente est annulée par défaut au profit de la suivante. Celle-ci, comme celle qui suit un échec, ne regarde que son propre commit. Le commit annulé ou fautif n'est plus vérifié.

Prenons une pull request de deux commits : A casse les tests de order, B ne touche que la documentation. L'exécution de B ne teste rien, passe au vert et autorise la fusion. En squash, A et B deviennent un seul commit sur main, erreur comprise.

Sur une pull request, moon lit la cible dans les variables de la plateforme, puis compare depuis l'ancêtre commun. Sur la branche principale, en revanche, il compare par défaut à HEAD~1. Nous lui donnons donc une base explicite : le dernier commit dont la chaîne a réussi, ou le dernier commit déployé.

Les compromis assumés

Un outil de plus au centre de la chaîne. moon devient le point de passage de chaque tâche, en local comme en CI. Nous l'épinglons dans .prototools, et chaque montée de version passe par une pull request.

Un graphe à tenir juste. Une dépendance oubliée dans un moon.yml est un projet qui n'est pas testé. Nous déclarons les dépendances et les entrées des tâches, et un job planifié relance chaque nuit toutes les tâches sur main, sans filtre (moon run :lint :analyse :audit :test).

Un historique complet à récupérer. Sur un clone superficiel, moon ne trouve pas la base commune. Il l'indique par un avertissement, et sa sélection des tâches n'est plus fiable. Un clone partiel sans les contenus de fichiers (filter: blob:none) garde l'historique et réduit le téléchargement.

Des journaux regroupés. Un seul job lance les vérifications de tous les projets affectés, et ses journaux les mélangent. Nous réservons la matrice aux tâches longues, comme la construction des images.

Le signal qui doit faire réexaminer ce choix : une détection qui retient presque tout, ou une CI qui ralentit malgré la détection. Le cache distant de moon, ou le découpage de moon ci en plusieurs jobs (--job, --job-total), sont alors à évaluer.

Mise en œuvre

Les exemples utilisent moon 2.5 et GitHub Actions ; GitLab CI et Azure Pipelines suivent en fin de section.

1. Épingler la chaîne d'outils

proto lit le fichier .prototools à la racine du dépôt. Les postes et la CI installent ainsi les mêmes versions de moon et de Node.js.

# .prototools
moon = "2.5.6"
node = "24.21.0"

PHP ne fait pas partie des outils intégrés à proto : en CI, une action dédiée installe PHP et Composer.

moon prend master comme branche par défaut. Nous lui déclarons la branche principale du dépôt :

# .moon/workspace.yml
vcs:
  defaultBranch: 'main'

2. Déclarer les tâches et les dépendances

# moon.yml (service order)
language: php
layer: application
dependsOn:
  - app-contracts

tasks:
  install:
    command: composer install --no-interaction --no-progress
    inputs:
      - composer.json
      - composer.lock
    outputs:
      - vendor
  test:
    command: vendor/bin/phpunit
    deps:
      - install
    inputs:
      - src/**/*
      - tests/**/*
      - config/**/*
      - phpunit.dist.xml
      - project://app-contracts

dependsOn place order parmi les dépendants de app-contracts. L'entrée project://app-contracts rend test affectée par toute modification du package. Les tâches lint, analyse, audit et docker-build suivent le même modèle.

3. Écrire un workflow mince

# .github/workflows/ci.yml
name: ci

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  actions: read

jobs:
  checks:
    runs-on: ubuntu-latest
    outputs:
      images: ${{ steps.images.outputs.projects }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0 # historique complet : moon calcule la base commune
          filter: blob:none
      - uses: moonrepo/setup-toolchain@261c62cb5b0f580c7be7c8cd0f023a2e96756095 # v0.6.4
        with:
          auto-install: true
      - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
        with:
          php-version: '8.4'
      - name: Base de comparaison sur main
        if: github.event_name == 'push'
        env:
          GH_TOKEN: ${{ github.token }}
          BEFORE: ${{ github.event.before }}
        run: |
          base=$(gh run list --workflow ci.yml --branch main --event push \
            --status success --limit 1 --json headSha --jq '.[0].headSha // empty')
          git merge-base --is-ancestor "$base" HEAD 2>/dev/null || base=$BEFORE
          git cat-file -e "$base^{commit}" 2>/dev/null || base=''
          echo "MOON_BASE=$base" >> "$GITHUB_ENV"
      - run: moon ci :lint :analyse :audit :test
      - id: images
        shell: bash
        run: |
          projects=$(moon query projects --affected --downstream deep --tasks docker-build | jq -c '[.projects[].id]')
          echo "projects=$projects" >> "$GITHUB_OUTPUT"

  images:
    needs: checks
    if: needs.checks.outputs.images != '[]'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        project: ${{ fromJSON(needs.checks.outputs.images) }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: moonrepo/setup-toolchain@261c62cb5b0f580c7be7c8cd0f023a2e96756095 # v0.6.4
      - run: moon run ${{ matrix.project }}:docker-build

  quality-gate:
    needs: [checks, images]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
        run: exit 1

Le job checks porte toute la décision. Sur une pull request, moon ci lit la branche cible dans les variables de GitHub. Sur main, l'étape « Base de comparaison » exporte dans MOON_BASE le commit de la dernière exécution réussie.

Une exécution annulée ou en échec ne compte pas : ses commits restent dans le diff suivant. À défaut, la base est le commit d'avant le push ; au premier push, moon compare à HEAD~1. gh, présent sur les runners hébergés, lit les exécutions grâce à GH_TOKEN et à actions: read.

4. Une matrice avec des cibles explicites

moon query projects --affected liste les projets affectés qui possèdent une tâche docker-build. --downstream deep y ajoute leurs dépendants : une modification de app-contracts reconstruit les images de order, stock, shipping et billing.

Chaque job de la matrice lance moon run avec une cible explicite. La décision est prise une seule fois, dans checks, avec la bonne base. Un moon ci dans la matrice recalculerait l'affectation avec sa propre base, et pourrait sauter la tâche sans échouer.

5. Un seul check obligatoire

quality-gate est le seul check exigé par la règle de protection de main. Un job sauté par sa condition compte comme réussi : sans affectation, images ne bloque donc rien. if: always() fait tourner quality-gate même quand un job dont il dépend échoue, pour qu'il échoue à son tour.

6. Sur GitLab CI et Azure Pipelines

Le principe ne change pas : récupérer l'historique, installer proto et moon, lancer moon ci. Hors GitHub, proto s'installe par son script officiel, puis proto install lit .prototools.

  • GitLab CI : un projet récent clone avec une profondeur de 20, que le réglage « Git shallow clone » à 0 supprime. moon lit la base des pipelines de merge request dans leurs variables. Une matrice dynamique passe par un pipeline enfant généré (dynamic child pipeline), et « Pipelines must succeed » joue le rôle du check obligatoire.
  • Azure Pipelines : fetchDepth: 0 dans l'étape checkout, car certaines organisations clonent avec une profondeur de 1 par défaut. Une matrice peut se lire dans la sortie d'un job précédent. Une stratégie de branche « Build validation » réglée sur Required conditionne la fusion au pipeline.

Questions fréquentes

La plateforme de CI n'a-t-elle pas déjà des tâches pour tout ? Elle sait lancer PHPStan ou construire une image, mais ignore quel projet dépend de app-contracts. Nous gardons ses actions pour récupérer le code, installer un runtime et publier un statut.

Un pipeline configuré dans l'interface n'est-il pas plus simple ? Plus simple à créer, oui. Mais il n'est ni versionné ni relu, et une pull request ne peut pas modifier en même temps le code et le pipeline.

Pourquoi la CI a-t-elle besoin d'Internet ? Pour installer les dépendances : paquets Composer et npm, images de base, outils épinglés par proto. C'est l'accès dont dispose déjà un poste de développement. Les environnements d'exécution peuvent rester isolés : la CI n'a pas besoin d'accéder à la production.

Pourquoi Docker dans la CI ? Pour deux usages : démarrer les services dont les tests ont besoin, comme PostgreSQL ou RabbitMQ, et construire les images des services. Sur GitHub Actions, le job de tests les déclare comme service containers.

Un pipeline par service ne serait-il pas plus lisible ? La matrice apporte cette lisibilité : un job par projet affecté, nommé d'après lui, avec ses propres journaux. Le YAML, lui, n'est écrit qu'une fois, et un nouveau service n'y change rien.

Pourquoi ne pas déployer depuis la CI ? Nous arrêtons la CI à l'image. Le déploiement part de l'intérieur de chaque environnement, qui tire cette image du registre : la CI n'a besoin d'aucun accès aux clusters.

Checklist

  • Les versions de moon et des runtimes épinglées dans .prototools.
  • Les dépendances entre projets déclarées (dependsOn, entrées project://).
  • Un clone avec l'historique complet (fetch-depth: 0, ou son équivalent).
  • Une base explicite sur la branche principale, jamais HEAD~1.
  • Des cibles explicites (moon run) dans les jobs de matrice.
  • Un seul check obligatoire, qui échoue si un job échoue ou est annulé.
  • Les mêmes commandes moon en local, en pre-commit et en CI.
  • Un passage complet planifié sur la branche principale.

Sources

Versions relevées le 2 octobre 2026. moon 2.5.6, proto 0.62.3 et Node.js 24.21.0 (LTS) ; actions/checkout 7.0.1, moonrepo/setup-toolchain 0.6.4 et shivammathur/setup-php 2.37.2.