Skip to content

Guides

Flutter monorepo CI: only test what changed ​

Short answer. In CI, find the files that changed against the base branch, map each file to its package, then add every package that depends on a changed one. Run analysis and tests for that list only. With Melos you can do this today with --diff and --include-dependents. You must also decide what happens when a file outside any package changes, because the diff will not find it.

This guide assumes a Dart workspace with Melos. If you do not have one, start with Flutter monorepo with Melos.

The problem ​

A pull request that fixes a typo in one feature runs analysis and tests for every package. With five packages that is fine. With fifty, every change pays for the whole repo, queues wait, and people stop trusting or waiting for CI.

The fix is to run what the change can break, and skip the rest.

The idea in three steps ​

  1. Changed files. Ask Git which files differ from the base branch.
  2. Changed packages. For each file, find the nearest folder up the tree that has a pubspec.yaml. That is its package.
  3. Dependents. Add every package that depends on a changed package, and the packages that depend on those, until nothing new appears.

Step 3 is the one people forget. Say feature_a depends on core. You change core. feature_a has no changed files, but its tests can now fail. If you only test core, you find out after merge.

flowchart LR
  core["core (changed)"] --> a["feature_a (depends on core)"]
  core --> app["app (depends on feature_a)"]
  b["feature_b (no link)"]

Test core, feature_a and app. feature_b does not depend on core, so skip it.

Do it with Melos ​

Melos has both filters built in. We checked these commands in a small workspace with three packages: core, feature_a (depends on core) and feature_b (independent).

bash
# Packages with changes between main and HEAD
dart run melos list --diff=origin/main...HEAD
# -> core

# Plus the packages that depend on them
dart run melos list --diff=origin/main...HEAD --include-dependents
# -> core, feature_a

# Run a command in those packages only
dart run melos exec --diff=origin/main...HEAD --include-dependents -- flutter test

feature_b stayed out. Use three dots (origin/main...HEAD) so Git compares against the point where your branch left main, not against the latest main. Without the three dots, changes that landed on main after you branched look like yours.

Add --json to melos list for a machine-readable list. Each entry has a name:

bash
dart run melos list --diff=origin/main...HEAD --include-dependents --json | jq -c '[.[].name]'
# ["core","feature_a"]

Handle files outside any package ​

When we changed only analysis_options.yaml at the repo root in the same test workspace, melos list --diff returned an empty list ([]). No package contains that file, so no package counts as changed. But a lint change can break every package.

Decide which root files mean "run everything", and test for them yourself:

bash
base=origin/main
changed=$(git diff --name-only "$base"...HEAD)

if echo "$changed" | grep -qE '^(pubspec\.(yaml|lock)|analysis_options\.yaml|\.github/)'; then
  packages=$(dart run melos list --json | jq -c '[.[].name]')
else
  packages=$(dart run melos list --diff="$base"...HEAD --include-dependents --json | jq -c '[.[].name]')
fi

echo "$packages"

The first branch is a full run. Edit the pattern for your repo: the workspace pubspec.yaml, the lock file, shared lint rules and the CI files themselves are the usual candidates.

Use it in GitHub Actions ​

One job finds the packages. A matrix job runs each package on its own runner. A last job reports one result.

yaml
name: CI
on:
  pull_request:

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.affected.outputs.packages }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true
      - run: flutter pub get
      - id: affected
        run: |
          base="origin/${{ github.base_ref }}"
          changed=$(git diff --name-only "$base"...HEAD)
          if echo "$changed" | grep -qE '^(pubspec\.(yaml|lock)|analysis_options\.yaml|\.github/)'; then
            packages=$(dart run melos list --json | jq -c '[.[].name]')
          else
            packages=$(dart run melos list --diff="$base"...HEAD --include-dependents --json | jq -c '[.[].name]')
          fi
          echo "packages=$packages" >> "$GITHUB_OUTPUT"

  test:
    needs: detect
    if: ${{ needs.detect.outputs.packages != '[]' }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        package: ${{ fromJSON(needs.detect.outputs.packages) }}
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true
      - run: flutter pub get
      - run: dart run melos exec --scope="${{ matrix.package }}" -- flutter analyze
      - run: dart run melos exec --scope="${{ matrix.package }}" --dir-exists=test -- flutter test

  ci-ok:
    needs: [detect, test]
    if: ${{ always() }}
    runs-on: ubuntu-latest
    steps:
      - run: |
          [ "${{ needs.detect.result }}" = "success" ] &&
          { [ "${{ needs.test.result }}" = "success" ] || [ "${{ needs.test.result }}" = "skipped" ]; }

Points that matter:

  • fetch-depth: 0 in the detect job. A shallow clone has no history to compare against. The git diff fails or shows everything.
  • fail-fast: false. One failing package should not hide the results of the others.
  • The ci-ok job. If the list is empty the matrix job is skipped. A skipped job can leave a required status check pending forever. Make ci-ok the one required check. It passes when detection worked and the tests passed or had nothing to run.
  • One runner per package costs setup time. Each job installs Flutter and gets dependencies. For a handful of small packages, one job that runs melos exec --diff=... --include-dependents can be faster than a matrix.

What this does not catch ​

Run a full CI on main or on a schedule as well. Selective runs are only as good as the dependency graph:

  • Changes that are not Dart dependencies, such as native code, asset files or generated files that live outside the package folder.
  • Integration and end-to-end tests that exercise the whole app.
  • A package that reads a file from another package's folder without declaring a dependency.

If the selective run is green and main is red, one of these is the reason. Fix the cause and keep the full run as the safety net.

How Archipelago does this ​

The affected command in the generated devtools/ toolkit does the three steps. It is part of the Enterprise plan. The Melos method above needs no plan.

  • It runs git diff --name-only <base>...HEAD, finds the nearest pubspec.yaml for each file, and adds the transitive dependents (packages whose dependencies or dev dependencies include an affected package).
  • Commands work on the affected packages: affected list, analyze, test (with --coverage), format, build (apps only), golden, license and a generic run.
  • An affected.yaml at the repo root sets the base branch, packages to always include (for example the app), and ignore patterns. By default it ignores changes in devtools/**, .github/** and .claude/**.
  • affected list --json prints the list in the shape of a GitHub Actions matrix.
  • The brick ships ci-affected-matrix.yml.example, a workflow that uses a package-level matrix and can also split one package's tests into shards.
  • It is not wired into the generated workflows by default. You copy the example into .github/workflows/ when you want it.

References: Affected Packages, Run CI only on changed Flutter packages, and the affected brick.

FAQ ​

Does this work without Dart workspaces? ​

Melos 7 needs them. The idea works in any layout: changed files, owning packages, dependents. You would write the dependency walk yourself, or use a tool that reads the pubspec.yaml files.

What if my change is only in a test file? ​

The file belongs to its package, so the package is tested. Packages that depend on it are tested too, which is more than needed for a test-only change. If that matters, filter test-only changes yourself in the detect step.

Should the app package always run? ​

Often yes, because it assembles everything. In Melos, the app is a dependent of any feature it uses, so --include-dependents already adds it. Add it explicitly only if it uses something the graph cannot see.

How do I speed up the packages that do run? ​

Cache Flutter and pub dependencies (the setup actions above do it), run packages in parallel, and shard large test suites. Measure on your own repo before and after, and keep the numbers.

Try Archipelago

Make a free account, install the CLI, and generate a project:

dart pub global activate archipelago_cli
archipelago auth login --interactive
archipelago create --output ./my_app

The Free plan includes 3 project scaffolds per month and the Free bricks. See Getting Started for each step, and pricing for Pro and Enterprise.