Skip to content

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 (default development, 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 production and 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 release or --mode profile swaps it to the noop twins for every flavor, internal flavors included. An alpha release build keeps the debugger but has no Marionette.
  • --mode debug restores it.
  • Without --mode the list is left as it is, so older invocations behave as before. The release and debug aliases set both lists.

A swap has two passes:

  1. pubspec.yaml swap — replaces debug package names with noop package names (or back) in every pubspec.yaml under target_paths
  2. Dart import rewrite — replaces package: import URIs in .dart files under lib/ and test/ inside each target_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:

build-prepareA production build swaps the package, not your code

Step 1 of 3, debug: In development and the other internal flavors (staging, alpha, beta) the app depends on app_debugger. App code names only AppDebuggerPackageModule and the AppDebuggerSDK contract, and both packages define exactly those names.

apps/template_app/pubspec.yaml
dependencies:  # …  app_debugger:    path: ../../features/app_debugger/app_debugger
apps/template_app/lib/di/injector.dart
import 'package:app_debugger/app_debugger.dart';// …  externalPackageModulesBefore: [    // …    ExternalModule(AppDebuggerPackageModule),
apps/template_app/lib/app.dart
features/app_debugger/app_debugger/lib/src/…_sdk_impl.dart
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 ​

bash
# 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
FlagAbbrDefaultDescription
--flavor-fThe build flavor. Without a mode, picks the direction from internal_flavors. Also filters mappings that declare flavors
--mode-mdebug, 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-leaksfalseAfter 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-rfalseRevert the last swap using .build_prepare.state.json
--config-cbuild_prepare.yamlPath 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:

yaml
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_app

Configuration fields ​

FieldRequiredDescription
internal_flavorsNoFlavors that keep the debug packages. Default [development, staging, alpha, beta]. Every other flavor gets the noop twins
mappings[].debugYesThe debug package (e.g. app_debugger)
mappings[].releaseYesIts noop twin (e.g. app_debugger_noop). The key keeps its old name
mappings[].flavorsNoLimits the mapping to these flavors when --flavor is passed. Omitted = always applies
mappings[].debug_version / release_versionNoOptional version constraints for the two packages
debug_onlyNoPackages 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_onlyNoSwapped 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_pathsNoApp 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):

yaml
dependencies:
  app_debugger:
    path: ../../features/app_debugger/app_debugger

Production flavor (pubspec.yaml after build-prepare --flavor production):

yaml
dependencies:
  app_debugger_noop:
    path: ../../features/app_debugger/app_debugger_noop

Pass 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:

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 in pubspec.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 ​

bash
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:

ruby
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")
end

CI integration ​

yaml
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 --restore

The 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 _api contract 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.