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.
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~1ignore 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: 0dans l'étapecheckout, 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éesproject://). -
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.
-
moon,
guide CI
: base et head, historique complet,
--jobet--job-total. - moon, commandes ci, run et query projects, tâches affectées.
-
moon,
query changed-files
: comparaison à
HEAD~1sur la branche par défaut. -
moon,
configuration de projet
:
dependsOn,inputsetproject://. - moon, cache distant et versions publiées.
- proto, configuration et installation.
- moonrepo/setup-toolchain, actions/checkout et shivammathur/setup-php.
- GitHub, About protected branches et Troubleshooting required status checks.
- GitHub, matrices, concurrence, GitHub CLI dans un workflow et gh run list.
-
GitHub,
événement push
(
before), méthodes de fusion et service containers. -
Git,
gitrevisions
(
HEAD~1), git merge-base et partial clone. - GitLab, shallow cloning, Git shallow clone, dynamic child pipelines et Pipelines must succeed.
-
Azure Pipelines,
étape checkout
(
fetchDepth), jobs et matrices et Build validation.