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.

moon decides, the CI orchestrates and records: the same tasks run locally CI platform a thin, versioned YAML 1. clone, full history 2. tools pinned by proto 4. matrix: one job per image 5. required check keeps the runners, the secrets, the logs and the statuses moon decides what to run graph declared in the moon.yml files 3. moon ci base, head tasks app-contracts changed order stock shipping billing affected dependents: their tasks run Vue.js front not affected, skipped Developer machine: the same tasks, the same versions moon run order:test moon run :lint --affected --status=staged

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~1 ignores 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: 0 in the checkout step, 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.