The problem
Take an online shop in a monorepo. The Symfony services
order, stock,
shipping and billing share the
app-contracts package, and Vue.js fronts
consume them. The whole is orchestrated by moon: each
project declares its tasks (lint,
test, docker-build...) in its
moon.yml.
The CI of such a repository often drifts towards one of these symptoms.
One pipeline per service, copied then modified.
The stock pipeline runs PHPStan, the
shipping one lost it during a copy. Each
pipeline filters its triggers by path, and nobody added
app-contracts to the
billing filter. A modified contract goes
through without billing being tested.
Everything, on every pull request. To forget nothing, a single pipeline runs all the tests of all the projects. Its duration follows the size of the repository, and the team learns to stop waiting for its verdict.
Logic that only exists in CI. The YAML
works out the touched projects itself, with
git diff HEAD~1 and a few shell scripts. Nobody
can replay that computation on their machine, and a CI
failure becomes hard to reproduce.
The options
One pipeline per project, filtered by path
- What it solves: each service has its pipeline, its logs and its status.
- What it costs: duplicated YAML, and a dependency graph copied by hand into each filter. On GitHub, a workflow skipped by its path filter leaves its required checks pending: the merge stays blocked.
Running everything on every pull request
- What it solves: nothing is forgotten, and the YAML stays short.
- What it costs: a duration that grows with the repository and runners busy with untouched projects. Each pull request waits longer for its verdict.
The platform's ready-made tasks
- What they solve: each platform offers ready-to-use actions or templates for PHP, Node.js or Docker.
- What they cost: they do not know the project graph and do not run on a developer machine. They also tie the chain to one platform.
A thin pipeline, driven by moon
- What it solves: the project graph and the tasks live in the repository, next to the code. moon works out what is affected, and the same commands run locally.
- What it costs: one more tool to learn and maintain. The graph must be declared with care, and the CI must fetch the full git history.
Our recommendation
moon decides what to build and test; the CI platform orchestrates and records.
The YAML comes down to three moves: fetch the full history, install the toolchain pinned by proto, run moon. The platform keeps what it does well: runners, secrets, logs, statuses and merge rules.
What moon decides
moon ci compares the current revision with a
base, and lists the changed files. It derives the affected
tasks from their inputs (inputs), then adds
their dependencies and their direct dependents. The tasks of
untouched projects do not run.
The same tasks locally
moon run order:test runs on a developer machine
the exact task the CI runs, with the moon and Node.js
versions pinned in .prototools.
moon run :lint lints every project, and the
pre-commit hook narrows it to the staged files:
moon run :lint --affected --status=staged. A CI
failure is reproduced with one command, without reading the
YAML.
A quality gate enforced by the platform
A task is only mandatory if the merge depends on it. Branch protection rules (or GitHub rulesets) require a green check on the latest commit of the pull request. We make a single job required, which aggregates all the others.
The right comparison base, not HEAD~1
HEAD~1 is the first parent of the current
commit: git diff HEAD~1 HEAD shows the changes
of a single commit. That scope is only right if each run
matches one commit, and each run completes. Three common
cases contradict it.
- A pull request with several commits. If the job checks out the last pushed commit, rather than the test merge commit, it only sees that one. Yet protection rules only look at the checks of the latest commit.
-
A push of several commits. A rebase merge
or a direct push adds several commits for a single run.
HEAD~1ignores all of them but the last. - A cancelled or failed run. In a concurrency group, a pending run is cancelled by default in favour of the next one. That one, like the one after a failure, only looks at its own commit. The cancelled or faulty commit is no longer checked.
Take a pull request with two commits: A breaks the tests of
order, B only touches the documentation. The
run for B tests nothing, turns green and allows the merge.
With a squash merge, A and B become a single commit on
main, error included.
On a pull request, moon reads the target from the platform's
variables, then compares from the common ancestor. On the
main branch, however, it compares with
HEAD~1 by default. So we give it an explicit
base: the last commit whose pipeline succeeded, or the last
deployed commit.
The trade-offs we accept
One more tool at the centre of the chain.
moon becomes the single entry point for every task, locally
and in CI. We pin it in .prototools, and every
upgrade goes through a pull request.
A graph to keep accurate. A dependency
missing from a moon.yml is a project that does
not get tested. We declare dependencies and task inputs, and
a scheduled job reruns every task on main each
night, with no filter (moon run :lint :analyse :audit :test).
A full history to fetch. On a shallow
clone, moon cannot find the common base. It says so with a
warning, and its task selection is no longer reliable. A
partial clone without file contents (filter: blob:none) keeps the history and reduces the download.
Grouped logs. A single job runs the checks of all affected projects, and its logs mix them. We keep the matrix for long tasks, such as building images.
The signal that should trigger a review of this
choice: a detection that selects almost everything, or a CI
slowing down despite the detection. moon's remote cache, or
splitting moon ci across several jobs
(--job, --job-total), are then
worth evaluating.
Implementation
The examples use moon 2.5 and GitHub Actions; GitLab CI and Azure Pipelines come at the end of the section.
1. Pin the toolchain
proto reads the .prototools file at the root of
the repository. Developer machines and the CI thus install
the same versions of moon and Node.js.
# .prototools
moon = "2.5.6"
node = "24.21.0"
PHP is not one of proto's built-in tools: in CI, a dedicated action installs PHP and Composer.
moon takes master as its default branch. So we
declare the repository's main branch:
# .moon/workspace.yml
vcs:
defaultBranch: 'main'
2. Declare tasks and dependencies
# moon.yml (order service)
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 places order among the
dependents of app-contracts. The
project://app-contracts input makes
test affected by any change to the package. The
lint, analyse,
audit and docker-build tasks
follow the same model.
3. Write a thin workflow
# .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 # full history: moon computes the common base
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: Comparison base on 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
The checks job carries the whole decision. On a
pull request, moon ci reads the target branch
from GitHub's variables. On main, the
"Comparison base" step exports into
MOON_BASE the commit of the last successful
run.
A cancelled or failed run does not count: its commits stay
in the next diff. Failing that, the base is the commit
before the push; on the first push, moon compares with
HEAD~1. gh, available on hosted
runners, reads the runs through GH_TOKEN and
actions: read.
4. A matrix with explicit targets
moon query projects --affected lists the
affected projects that have a
docker-build task.
--downstream deep adds their dependents: a
change to app-contracts rebuilds the images of
order, stock,
shipping and billing.
Each matrix job runs moon run with an explicit
target. The decision is made once, in checks,
with the right base. A moon ci in the matrix
would recompute what is affected with its own base, and
could skip the task without failing.
5. A single required check
quality-gate is the only check required by the
protection rule of main. A job skipped by its
condition counts as successful: with nothing affected,
images blocks nothing.
if: always() makes
quality-gate run even when a job it depends on
fails, so that it fails in turn.
6. On GitLab CI and Azure Pipelines
The principle does not change: fetch the history, install
proto and moon, run moon ci. Outside GitHub,
proto is installed with its official script, then
proto install reads .prototools.
- GitLab CI: a recent project clones with a depth of 20, which the "Git shallow clone" setting at 0 removes. moon reads the base of merge request pipelines from their variables. A dynamic matrix goes through a generated child pipeline (dynamic child pipeline), and "Pipelines must succeed" plays the role of the required check.
-
Azure Pipelines:
fetchDepth: 0in thecheckoutstep, because some organisations clone with a depth of 1 by default. A matrix can be read from the output of a previous job. A "Build validation" branch policy set to Required makes the merge depend on the pipeline.
Frequently asked questions
Doesn't the CI platform already have tasks for
everything?
It can run PHPStan or build an image, but does not know
which project depends on app-contracts. We keep
its actions to fetch the code, install a runtime and publish
a status.
Isn't a pipeline configured in the UI simpler? Simpler to create, yes. But it is neither versioned nor reviewed, and a pull request cannot change the code and the pipeline at the same time.
Why does the CI need Internet access? To install dependencies: Composer and npm packages, base images, tools pinned by proto. A developer machine already has that access. Runtime environments can stay isolated: the CI does not need to reach production.
Why Docker in the CI? For two uses: starting the services the tests need, such as PostgreSQL or RabbitMQ, and building the services' images. On GitHub Actions, the test job declares them as service containers.
Wouldn't one pipeline per service be easier to read? The matrix brings that readability: one job per affected project, named after it, with its own logs. The YAML, for its part, is written once, and a new service changes nothing in it.
Why not deploy from the CI? We stop the CI at the image. Deployment starts from inside each environment, which pulls that image from the registry: the CI needs no access to the clusters.
Checklist
-
The versions of moon and of the runtimes pinned in
.prototools. -
Dependencies between projects declared
(
dependsOn,project://inputs). -
A clone with the full history (
fetch-depth: 0, or its equivalent). -
An explicit base on the main branch, never
HEAD~1. -
Explicit targets (
moon run) in matrix jobs. - A single required check, which fails if a job fails or is cancelled.
- The same moon commands locally, in pre-commit and in CI.
- A scheduled full run on the main branch.
Sources
Versions checked on 2 October 2026. moon 2.5.6, proto 0.62.3 and Node.js 24.21.0 (LTS); actions/checkout 7.0.1, moonrepo/setup-toolchain 0.6.4 and shivammathur/setup-php 2.37.2.
-
moon,
CI guide: base and head, full history,
--joband--job-total. - moon, the ci, run and query projects commands, affected tasks.
-
moon,
query changed-files: comparison with
HEAD~1on the default branch. -
moon,
project configuration:
dependsOn,inputsandproject://. - moon, remote cache and releases.
- proto, configuration and installation.
- moonrepo/setup-toolchain, actions/checkout and shivammathur/setup-php.
- GitHub, About protected branches and Troubleshooting required status checks.
- GitHub, matrices, concurrency, GitHub CLI in a workflow and gh run list.
-
GitHub,
push event
(
before), merge methods and service containers. -
Git,
gitrevisions
(
HEAD~1), git merge-base and partial clone. - GitLab, shallow cloning, Git shallow clone, dynamic child pipelines and Pipelines must succeed.
-
Azure Pipelines,
checkout step
(
fetchDepth), jobs and matrices and Build validation.