Monorepo Toolkit
Build Prepare
The build-prepare command swaps debug tooling for its noop twins at build time. The build flavor decides, not the build mode:
- Flavors in
internal_flavors(defaultdevelopment,staging,alpha,beta) keep the debugger and the Blackbox recorder, also in release mode. QA and beta testers get the tools. - Every other flavor, including
productionand any flavor nobody listed, gets the noop twins. A new store flavor is safe by default.
A second list, debug_build_only, follows the build mode instead. It holds tooling that must exist only in debug builds, such as Marionette:
--mode releaseor--mode profileswaps it to the noop twins for every flavor, internal flavors included. An alpha release build keeps the debugger but has no Marionette.--mode debugrestores it.- Without
--modethe list is left as it is, so older invocations behave as before. Thereleaseanddebugaliases set both lists.
A swap has two passes:
- pubspec.yaml swap — replaces debug package names with noop package names (or back) in every
pubspec.yamlundertarget_paths - Dart import rewrite — replaces
package:import URIs in.dartfiles underlib/andtest/inside eachtarget_path, so host code stays valid after the rename
Here is a production swap and its reverse on the real template_base files. Step through it, or let it play:
dependencies: # … app_debugger: path: ../../features/app_debugger/app_debuggerimport 'package:app_debugger/app_debugger.dart';// … externalPackageModulesBefore: [ // … ExternalModule(AppDebuggerPackageModule),class AppDebuggerSDKImpl(/* … */) implements AppDebuggerSDK { @override bool get isEnabled => true; @override Widget overlay({required Widget child}) => DebuggerOverlay(child: child); @override Future<void> openMenu() async { // Opens the debugger panel. }}Usage
# Before a build: pass the build's own flavor and mode
dart run monorepo_toolkit build-prepare --flavor production --mode release # every noop twin
dart run monorepo_toolkit build-prepare --flavor alpha --mode release # keeps the debugger, drops Marionette
# After the build: fail if a debug-only package leaked
dart run monorepo_toolkit build-prepare --check-leaks --flavor production --mode release
# Revert the most recent swap from .build_prepare.state.json
dart run monorepo_toolkit build-prepare --restore
# Aliases for both lists. An alias wins over --flavor.
dart run monorepo_toolkit build-prepare release # noop twins
dart run monorepo_toolkit build-prepare debug # debug packages
# Use a custom config path
dart run monorepo_toolkit build-prepare --flavor production --config path/to/config.yaml| Flag | Abbr | Default | Description |
|---|---|---|---|
--flavor | -f | The build flavor. Without a mode, picks the direction from internal_flavors. Also filters mappings that declare flavors | |
--mode | -m | debug, profile or release. release/profile swap debug_build_only to the noop twins for every flavor, debug restores it. Omitted: the list is left as it is | |
--check-leaks | false | After a build: exit 1 if a mapped debug or debug_only package resolves for the app (skipped for internal flavors), or a debug_build_only package does (skipped for --mode debug; --mode defaults to release here) | |
--restore | -r | false | Revert the last swap using .build_prepare.state.json |
--config | -c | build_prepare.yaml | Path to the config file |
Every swap writes a .build_prepare.state.json snapshot of the files it touched. --restore, or a later swap back to the debug packages, restores them exactly from that snapshot.
Configuration
build_prepare.yaml at the workspace root:
build_prepare:
internal_flavors: [development, staging, alpha, beta]
mappings:
- debug: app_debugger
release: app_debugger_noop
- debug: app_blackbox
release: app_blackbox_noop
debug_only:
- app_blackbox_metrics
- app_blackbox_filesystem
- app_blackbox_recording
debug_build_only:
- debug: app_marionette
release: app_marionette_noop
- marionette_flutter
- marionette_logging
- marionette_mcp
target_paths:
- apps/template_appConfiguration fields
| Field | Required | Description |
|---|---|---|
internal_flavors | No | Flavors that keep the debug packages. Default [development, staging, alpha, beta]. Every other flavor gets the noop twins |
mappings[].debug | Yes | The debug package (e.g. app_debugger) |
mappings[].release | Yes | Its noop twin (e.g. app_debugger_noop). The key keeps its old name |
mappings[].flavors | No | Limits the mapping to these flavors when --flavor is passed. Omitted = always applies |
mappings[].debug_version / release_version | No | Optional version constraints for the two packages |
debug_only | No | Packages with no noop twin that --check-leaks must never find in a production build, because only a mapped package may reach them (the Blackbox capability plugins) |
debug_build_only | No | Swapped by --mode, not by the flavor. {debug, release} entries are mappings (no flavors filter); bare names have no twin and are only checked by --check-leaks in the app's dependency graph and plugin list (not in pubspec.lock, which covers the whole workspace) |
target_paths | No | App directories to swap and check. If empty, scans all workspace packages (pubspec swap only; no import rewrite, no graph check). flutter_modular_monorepo lists the first app, and app_scaffold adds each later app |
How it works
Pass 1 — pubspec.yaml swap
The debug package entry is replaced with the release (noop) entry in pubspec.yaml, and the reverse for internal flavors. This covers both the dependency key and the path: reference.
Internal flavor (pubspec.yaml after build-prepare --flavor alpha):
dependencies:
app_debugger:
path: ../../features/app_debugger/app_debuggerProduction flavor (pubspec.yaml after build-prepare --flavor production):
dependencies:
app_debugger_noop:
path: ../../features/app_debugger/app_debugger_noopPass 2 — Dart import rewrite
After the pubspec swap, build-prepare walks every .dart file under lib/ and test/ inside each target_path and rewrites two patterns:
package:<debug_pkg>/→package:<noop_pkg>/(all import paths)/<debug_pkg>.dart→/<noop_pkg>.dart(barrel file references)
Example — an import in apps/template_app/lib/di/injector.dart:
// Internal flavor:
import 'package:app_debugger/app_debugger.dart';
// After build-prepare --flavor production:
import 'package:app_debugger_noop/app_debugger_noop.dart';--restore puts the original imports back exactly.
Why import rewrite is required
build_prepare never rewrites class names — only import paths. So the debug package and its noop twin must export classes with the same names. When app_debugger is swapped for app_debugger_noop, AppDebuggerSDKImpl is still the class name in both packages. Only the import path changes, and build-prepare handles that.
This is the unified-class-name Impl/Noop pattern — see the architecture guide for when to use it.
The rule covers every public library, not only the main one. test/ imports are rewritten too, so a test that imports package:app_debugger/testing.dart reads package:app_debugger_noop/testing.dart while the workspace is swapped. app_debugger_noop ships a testing.dart with the same AppDebuggerTestPrerequisite (a no-op), so the app's test files still analyze clean in the production swap (app_debugger 2.0.0 and later). app_blackbox_noop does the same for AppBlackboxTestPrerequisite (app_blackbox 2.10.0 and later), and app_debugger_network_noop for AppDebuggerNetworkTestPrerequisite (app_debugger_network 1.2.0 and later). Every noop twin of a package that declares test_prerequisites must ship the same testing.dart. Both testing.dart files also export const appDebuggerOverlayMounted (true in the impl, false in the noop). app_debugger_overlay_test.dart expects the FAB only when it is true, so the app tests pass both unswapped and swapped (app_debugger 2.0.0 and later). Give any other test that asserts a debug-only widget the same kind of flag.
Missing noop library guard
After the rewrite, build-prepare checks every rewritten package:<noop_pkg>/<path> import. It resolves the noop package from the path: entry in the swapped app pubspec.yaml and checks that lib/<path> exists. If a library is missing, the command prints the file and the missing library, then exits with code 1. The state file is still written, so --restore reverts the swap.
This catches a noop that lacks a mirror, such as testing.dart, at swap time. flutter build does not compile test/, so without this check only a later flutter analyze or flutter test would fail. --check-leaks does not need the guard: it reads the resolved package graph after the build, and it does not read imports.
Leak guard
--check-leaks --flavor <flavor> --mode <mode> runs after flutter build. For a release or profile build (the default) it fails when a debug_build_only package appears in the app's dependency closure or plugin list, whatever the flavor. For a non-internal flavor it also fails when a mapped debug package or a debug_only package appears in:
- the app's dependency closure in
.dart_tool/package_graph.json(workspace packages never appear inpubspec.lock), - the root
pubspec.lock, - the app's
.flutter-plugins-dependencies(native plugins; dev-only plugins are ignored).
It also fails when none of those files exist, so a step in the wrong place cannot pass silently.
No runtime flavor check
The debugger does not look at Flavor.isProduction at runtime. The swap decides: a flavor in internal_flavors keeps the real debugger and shows its entry point, and any other flavor gets the noop twin. If you list production in internal_flavors, the production build ships the debugger on purpose. --check-leaks is the guard for a store build. app_marionette installs its binding only when kDebugMode.
Melos shortcuts
melos run build-prepare:production # --flavor production --mode release
melos run build-prepare:release # Alias of build-prepare:production
melos run build-prepare:debug # Back to the debug packages (debug --mode debug)Never run the production swap before an internal build: the alpha or beta build would lose its debugger.
Fastlane integration
The template's Fastfile passes the build's own flavor and --mode release (every lane builds in release mode) on every lane, checks for leaks after the build, and restores afterwards, also when the lane fails:
before_all do |lane, options|
sh("cd ../.. && dart run monorepo_toolkit build-prepare --flavor #{options[:flavor] || "development"} --mode release")
end
after_all do |lane, options|
sh("cd ../.. && dart run monorepo_toolkit build-prepare --restore")
end
error do |lane, exception, options|
sh("cd ../.. && dart run monorepo_toolkit build-prepare --restore")
end
private_lane :flutter_build do |options|
# ... flutter build ...
sh("cd ../.. && dart run monorepo_toolkit build-prepare --check-leaks --flavor #{flavor} --mode release")
endCI integration
steps:
- name: Prepare dependencies for the flavor
run: dart run monorepo_toolkit build-prepare --flavor "$FLAVOR" --mode release
- name: Build
run: flutter build appbundle --flavor "$FLAVOR"
- name: Check debug tooling leak
run: dart run monorepo_toolkit build-prepare --check-leaks --flavor "$FLAVOR" --mode release
- name: Restore
if: always()
run: dart run monorepo_toolkit build-prepare --restoreThe generated GitHub Actions and GitLab CI templates already run the leak check.
When to use this
Use build-prepare for packages that:
- Have a debug implementation with vendor SDKs or profiling overhead
- Must be completely absent from production-flavor builds (not just disabled at runtime)
- Follow the Impl/Noop pattern with unified class names (no
_apicontract needed)
The monitoring_impl / monitoring_noop pair shows the API/Impl/Noop shape (both implement AppMonitoring from monitoring_api), but it is not mapped by default: monitoring ships in every flavor, gated by consent. The app_debugger / app_debugger_noop pair is the canonical debug-only example: both export class AppDebuggerSDKImpl with identical signatures. The app launch tracker is not mapped either: it ships in every flavor and its spans pass through the same consent gate.