Orchestrating Flutter CI Workflows with Monorepos and Melos
Introduction: The Scale Problem in Flutter Development
In our Aarhus office, managing a 150-package repository requires more than just good intentions; it requires a deep, almost clinical understanding of the dependency graph. While my primary expertise lies in the TypeScript/Turborepo ecosystem, the principles of cache-aware build systems are universal. When we transitioned our cross-platform mobile suite to a monorepo architecture, we initially relied on standard shell scripts. It worked for the first ten packages, but by the time we hit fifty, our CI pipeline had turned into a bottleneck that paralyzed developer velocity. We were rebuilding everything, everywhere, all at once.
In the Flutter ecosystem, Melos has emerged as the de-facto standard for managing multi-package workspaces. However, simply using Melos to run commands isn't enough to solve the CI latency issue. To truly achieve the 65% reduction in CI time we’ve seen in our web architecture, we must treat Flutter builds with the same precision we apply to Turborepo—minimizing work by ensuring that we never rebuild a package unless its inputs (source files, dependencies, or environment variables) have substantively changed. This article explores how to architect a cache-aware CI workflow using Melos, custom scripts, and a deep understanding of the task graph.
The Anatomy of a Monorepo Build Bottleneck
When you scale a monorepo, your CI pipeline naturally wants to treat the codebase as a single monolithic entity. You define a top-level build command, and your CI provider executes it. In a standard Flutter setup, this often translates to flutter build apk or flutter test at the root, which forces the CI to traverse every package. The problem here is the 'global invalidation' trap. If a single documentation file in a low-level utility package changes, the CI runner perceives this as a change in the entire graph, triggering a full rebuild of every dependent package.
To break this cycle, we must decompose the task graph. A task graph represents the dependencies between your packages—if feature_auth depends on shared_ui, any change to shared_ui mandates a rebuild of feature_auth, but a change in feature_analytics should be entirely ignored by the feature_auth build process. Melos provides the structural foundation for this by allowing us to filter packages based on scope and dependency, but the intelligent invalidation logic is something you must craft yourself or bridge via advanced CI caching.
Step-by-Step: Implementing Cache-Aware Workflows with Melos
To move from standard orchestration to an efficient, cache-hit-heavy pipeline, follow these steps to refine your CI execution:
-
Define Explicit Scopes: Organize your Melos workspace so that packages are strictly categorized. Use the
melos.yamlconfiguration to enforce package-level independence. Ensure that every package has a localizedpubspec.yamlthat accurately reflects its dependency tree. Any missing dependency link is a hole in your cache invalidation strategy. -
Isolate Build Artifacts: Flutter build outputs are often cached at the root
build/directory, which causes collisions in CI environments. Configure your scripts to direct output to package-specific build directories. This allows the CI provider to cache individual folder paths rather than attempting to upload/download a massive, volatile root directory. -
Implement Fingerprinting: Before running a build, generate a content-hash for each package. Use a script to hash the
lib/directory and thepubspec.yamlfile of every package. Compare this hash against the previous build's artifact metadata stored in your remote cache. If the hash hasn't changed, the CI job should exit immediately with a success signal. -
Task Graph Traversal: Utilize
melos execto perform scoped builds. By combining--since, you can instruct Melos to only execute tasks on packages changed since themainbranch. This is the first layer of efficiency. However, you must also consider the downstream impact—if a base package changes, you need a way to propagate that requirement to all dependent packages without rebuilding the entire graph. -
Remote Cache Integration: Use the CI provider’s caching mechanism (like GitHub Actions'
actions/cacheor GitLab’scachekey) to store thepubcache and the generated build artifacts. The key for these caches must include your package hash, ensuring that you are only pulling relevant binaries for the current state of the codebase.
Configuration and Technical Strategy
In our setup, we use a melos.yaml that handles cross-package orchestration while our custom shell scripts handle the cache-lookup logic. Below is a simplified representation of how we structure our task execution in melos.yaml to optimize for CI performance:
name: app_monorepo
packages:
- packages/*
- apps/*
scripts:
test:all:
run: melos exec -c 3 -- "flutter test"
description: Run all tests in parallel using Melos.
build:changed:
run: |
CHANGED_PACKAGES=$(melos list --since=origin/main --format=json)
if [ -z "$CHANGED_PACKAGES" ]; then
echo "No changes detected. Skipping builds."
else
melos exec --since=origin/main -- "flutter build --release"
fi
Beyond just scripts, the secret sauce lies in the orchestration of the cache key. When constructing your CI workflow, the cache key must be deterministic. We use the following pattern in our yaml configuration for CI providers:
- name: Cache Flutter Pub and Build
uses: actions/cache@v3
with:
path: |
~/.pub-cache
**/.dart_tool/build
key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-
Pro-Tips for CI/CD Optimization
Pro Tip 1: Avoid the 'Root' Cache Trap. Never cache the global pub directory alongside build artifacts if you are running multi-platform builds. The binary incompatibility between Linux and macOS CI runners will corrupt your cache, leading to subtle analyzer errors that take hours to debug. Separate your cache keys by runner.os strictly.
Pro Tip 2: Leverage the 'since' flag for everything. Never run a full suite of tests or builds on a feature branch. Use melos list --since=origin/main to generate a diff of modified packages. Feed this list into a matrix-based CI execution strategy to parallelize testing across multiple runners. This keeps your CI queue time low, even when someone triggers a massive refactor that touches twenty packages.
Pro Tip 3: Post-Build Validation. Implement a post-build script that validates the integrity of your cache. If a cache miss occurs despite no source code changes, it’s usually an issue with environment variables or Flutter SDK version drifting. Ensure your CI runner installs the exact Flutter version defined in a .fvmrc file or a static version file to maintain hash consistency.
Pro Tip 4: Monitor Cache Hit Rates. Treat your cache hit rate as a primary CI metric. If your hit rate is below 70%, your invalidation logic is too coarse. Investigate the dependencies that are causing frequent invalidations—often, these are shared 'utils' packages that everything depends on. By modularizing these 'hot' packages further, you can reduce the frequency of full-rebuild triggers.
Conclusion: Building for Velocity
Scaling a Flutter monorepo is not just about organizing folders; it is about building a system that respects the developer's time. When you orchestrate your CI workflow with Melos and cache-aware logic, you move from a reactive development cycle to a proactive one. The goal is simple: the CI should only ever do the absolute minimum amount of work required to verify the specific changes submitted by a developer.
By decomposing your task graph, implementing deterministic fingerprinting for your packages, and strictly managing remote cache keys, you can transform a sluggish CI pipeline into a competitive advantage. In Aarhus, we don't just write code; we architect the environment where the code lives. The time saved by not rebuilding the world on every pull request is time spent refining the user experience of our apps. As your monorepo grows, remember that the most valuable asset in your CI/CD pipeline is the cache. Protect it, optimize it, and let it work for you.