This guide is a recipe for moving an existing Flutter app — built today with
flutter pub get scripts, build_runner watch, ad-hoc protoc invocations, and
CI images that install the SDK imperatively — onto hermetic Bazel builds with
rules_flutter.
It is structured as ordered phases. Each phase is independently useful, lands as one green PR, and leaves the existing scripts working until the phase that replaces them. Bazel and the legacy scripts coexist throughout the migration; nothing requires a flag-day cutover.
A typical pre-Bazel Flutter repo looks like this:
- A
setup.sh/Makefilethat runsflutter pub getin each package, plus a host Flutter install (or a version manager) that drifts between machines. build_runner watchrunning in developer terminals; generated files (*.g.dart,assets.gen.dart, intl output) either checked in or regenerated by convention before commits.- Protos generated by a shell script:
dart pub global activate protoc_plugin, a hostprotoc, and outputs committed to the source tree. - CI jobs that begin with 5–10 minutes of imperative SDK setup (clone Flutter, precache, accept Android licenses) before any real work happens.
- A release script that rewrites
version:inpubspec.yamlto inject build numbers.
The target end-state:
- One pinned Flutter SDK, downloaded with integrity verification and sealed read-only, resolved through a Bazel toolchain — no host Flutter install.
- pub.dev dependencies pinned in checked-in
pub_deps.jsonfiles and served from Bazel repositories; builds and tests run offline. - Codegen (build_runner, intl, protoc) runs inside build actions; generated
sources are gitignored, with a
bazel runrefresher for the IDE analyzer. - Tests, analysis, and format checks are ordinary
bazel testtargets. - Web/Android/iOS release jobs shrink to "checkout, secrets,
bazel build, upload artifacts".
Add rules_flutter and the toolchain to MODULE.bazel. Nothing else changes
yet — this PR just proves the SDK fetches and resolves on every machine and CI
image.
bazel_dep(name = "rules_flutter", version = "0.0.0")
flutter = use_extension("@rules_flutter//flutter:extensions.bzl", "flutter")
flutter.toolchain(
flutter_version = "3.29.0",
# Artifact groups that must exist in the SDK cache after fetch. Stable
# archives already ship these; `flutter precache` runs at fetch time only
# when one is missing.
precache = ["web", "android", "ios"],
)
use_repo(
flutter,
"flutter_sdk",
"flutter_toolchains",
)
register_toolchains("@flutter_toolchains//:all")The flutter_version must be one that rules_flutter ships integrity
metadata for. The SDK repository is immutable after fetch: bin/cache is
sealed read-only, and the launcher is patched so flutter invocations never
write into the repository. Do not run flutter precache or flutter config
against it from scripts — the sealed cache rejects writes loudly.
Gate: bazel query @flutter_sdk//... succeeds on a fresh checkout and in
CI with no host Flutter install.
rules_flutter manages hosted packages through a pub module extension that
scans every checked-in pub_deps.json in the root module and creates one
Bazel repository per hosted package (named pub_<package>).
Add the extension to MODULE.bazel:
pub = use_extension("@rules_flutter//flutter:extensions.bzl", "pub")
use_repo(pub) # bazel mod tidy fills this inThen, for each package directory with a pubspec.yaml, define a library (the
next phase covers the full target; a minimal one is enough to get the
.update helper):
load("@rules_flutter//flutter:defs.bzl", "flutter_library")
flutter_library(
name = "lib",
srcs = glob(["lib/**"]),
pubspec = "pubspec.yaml",
)Generate and maintain the dependency report:
bazel run //app:lib.update # writes app/pub_deps.json next to pubspec.yaml
bazel mod tidy # syncs the use_repo(pub, ...) listCheck pub_deps.json in. Rerun both commands whenever pubspec.yaml changes;
the .update helper is a no-op when the report is already current.
Common snags in this phase:
dependency_overridesare baked in at update time. The.updatehelper runsflutter pub deps --jsonagainst your realpubspec.yaml, so the report records the resolved (overridden) versions. Editing an override without rerunning.updateleaves builds on stale pins.- Cyclic pub dependencies are pruned automatically. The pub universe
contains real cycles (for example
dio <-> dio_web_adapter) that Bazel target graphs cannot express; the extension drops the back edge, and no action is needed on your side. - Only the root module is scanned.
pub_deps.jsonfiles anywhere under your repository are discovered; packages pinned by other modules defer to the root module's pins.
Gate: bazel mod tidy is clean, and bazel build //app:lib succeeds
offline (the prepared workspace and pub cache build without network).
Move code generation out of developer terminals and into the build. A full
flutter_library declares sources, SDK packages, pub dependencies, and its
generators:
flutter_library(
name = "lib",
srcs = glob(
["lib/**"],
exclude = ["lib/generated/**"], # generated in the build, gitignored
),
data = glob(["assets/**", "l10n/**"]),
pubspec = "pubspec.yaml",
# One-shot generators run via `dart run <package>:<script>` during
# dependency preparation.
generator_commands = ["intl_utils:generate"],
# `build` runs build_runner inside the Bazel action, fully offline.
build_runner_modes = ["build"],
deps = [
"@flutter_sdk//flutter/packages/flutter",
"@flutter_sdk//flutter/packages/flutter_test",
"@pub_build_runner//:build_runner",
"@pub_intl_utils//:intl_utils",
# ... one @pub_<package> entry per direct dependency
],
)With build_runner_modes = ["build"], build_runner build executes inside
the action with no network: the entrypoint is resolved from the prepared
package config (no implicit pub get), a pubspec.lock is synthesized from
pub_deps.json when the package does not already ship one, and
--delete-conflicting-outputs is applied by default.
Delete the generated files from git and add them to .gitignore — they never
need to be checked in again. See e2e/smoke/codegen_app for a working example
combining copy_with_extension_gen and flutter_gen_runner.
Two complementary patterns restore the analyzer's view of generated code:
-
build_runner run helpers.
flutter_libraryemits runnable helper targets that execute build_runner in your source tree with the hermetic SDK::lib.build_runner_build,:lib.build_runner_test,:lib.build_runner_watch, and:lib.build_runner_serve. All four exist whenbuild_runner_modesis omitted; setting it explicitly (as the example above does with["build"]) emits helpers only for the listed modes, so add"watch"to the list to get the watch target. Developers who usedbuild_runner watchswitch to:bazel run //app:lib.build_runner_watch
These run against the developer's own package resolution in the checkout, so they behave exactly like the old workflow (and compose with
rules_multirunfor watch+serve setups — see the README). -
A refresher target for action-generated output. The prepared workspace built by
flutter_libraryalready contains everything the generators produced. A small script target rsyncs it back into the source tree for the analyzer (sh_binarycomes from@rules_shell//shell:sh_binary.bzl, or Bazel's default rule autoloading):sh_binary( name = "refresh_generated", srcs = ["refresh_generated.sh"], data = [":lib"], )
#!/bin/bash set -euo pipefail # `bazel run` sets BUILD_WORKSPACE_DIRECTORY to the source checkout. The # prepared workspace tree is named <library>_prepared_flutter_workspace. RUNFILES="${RUNFILES_DIR:-$0.runfiles}" TREE="$(find -L "$RUNFILES" -type d -name 'lib_prepared_flutter_workspace' | head -n 1)" mkdir -p "$BUILD_WORKSPACE_DIRECTORY/app/lib/generated" rsync -a --delete --chmod=u+rwX \ "$TREE/lib/generated/" \ "$BUILD_WORKSPACE_DIRECTORY/app/lib/generated/"
bazel run //app:refresh_generatedafter a codegen change puts the exact build-produced sources where the IDE expects them.
Gate: with generated files deleted from the source tree, bazel build //app:lib still succeeds; git status stays clean after a build (nothing
writes into the checkout).
Replace the pub global activate protoc_plugin + shell script pipeline with
dart_proto_library. The rule wraps the Dart protoc plugin, which runs from
its own pinned pub repository — independent of whatever versions your app
resolves.
Collocate one dart_proto_library with each proto package:
# protos/api/v1/BUILD.bazel
load("@protobuf//bazel:proto_library.bzl", "proto_library")
load("@rules_flutter//flutter:defs.bzl", "dart_proto_library")
proto_library(
name = "api_v1_proto",
srcs = glob(["*.proto"]),
# Import path becomes api/v1/<file>.proto for repos that root proto
# imports below a protos/ directory.
strip_import_prefix = "/protos/",
visibility = ["//visibility:public"],
)
dart_proto_library(
name = "api_v1_proto_dart",
visibility = ["//visibility:public"],
deps = [":api_v1_proto"],
)Generation covers the whole transitive proto closure (including well-known types), and gRPC stubs are emitted automatically for protos that declare services.
Mount the generated Dart into the app package with generated_srcs — each
file lands at its proto-import-relative path under the destination directory:
flutter_library(
name = "lib",
srcs = glob(["lib/**"], exclude = ["lib/generated/**"]),
generated_srcs = {
"//protos/api/v1:api_v1_proto_dart": "lib/generated",
},
pubspec = "pubspec.yaml",
deps = [
"@pub_protobuf//:protobuf",
# ...
],
)Dart code then imports package:my_app/generated/api/v1/service.pb.dart, and
nothing generated is checked in. Delete the old protoc script and the
committed *.pb.dart files in the same PR.
Because generated_srcs mounts the protos into the same prepared workspace,
the Phase 3 refresher target already restores them for the IDE — no separate
proto refresh script is needed. Exclude lib/generated/** from format checks
(next phase), since those sources exist only in build outputs.
Gate: bazel build //app:lib compiles against the mounted protos;
git ls-files -- 'lib/generated' '*.pb.dart' returns nothing (no generated
files are committed).
Replace the lint/test scripts with hermetic test targets — flutter_test and
flutter_analyze_test reuse the same prepared workspace as your builds:
load(
"@rules_flutter//flutter:defs.bzl",
"dart_format_test",
"flutter_analyze_test",
"flutter_test",
)
flutter_test(
name = "lib_test",
srcs = glob(["test/**"]),
embed = [":lib"],
)
flutter_analyze_test(
name = "lib_analyze",
srcs = ["analysis_options.yaml"] + glob(["test/**"]),
embed = [":lib"],
# fatal_infos = True, fatal_warnings = False, extra_args = [...]
)
dart_format_test(
name = "lib_format",
srcs = glob(
["lib/**/*.dart", "test/**/*.dart"],
exclude = ["lib/generated/**"], # exists only in build outputs
),
)flutter_test and flutter_analyze_test run from a materialized copy of the
prepared workspace with the package config regenerated from pub_deps.json;
dart_format_test simply runs the toolchain's dart format --output=none --set-exit-if-changed over its srcs. Either way: no pub resolution, no
network. The CI lint job becomes:
bazel test //app:lib_test //app:lib_analyze //app:lib_formatGate: bazel test //app/... passes twice in a row, with the second run
served entirely from cache ((cached) PASSED for every target).
Define the web app with a dict platform spec. Per-environment configuration
flows through --dart-define pairs selected off a string_flag:
load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
load("@rules_flutter//flutter:defs.bzl", "flutter_app")
string_flag(
name = "env",
build_setting_default = "dev",
)
config_setting(
name = "prod",
flag_values = {":env": "prod"},
)
flutter_app(
name = "app",
embed = [":lib"],
# select() is supported; compose the COMPLETE dict per branch — Starlark
# cannot merge two selects.
dart_defines = select({
":prod": {"API_ENDPOINT": "api.example.com", "ENV_NAME": "prod"},
"//conditions:default": {"API_ENDPOINT": "api.dev.example.com", "ENV_NAME": "dev"},
}),
web = {
"srcs": glob(["web/**"]),
"build_args": ["--source-maps"],
"mode": "release",
},
)The CI deploy job shrinks to a build plus an upload of the artifacts tree:
bazel build //app:app.web --//app:env=prod
# Upload the whole tree (index.html, main.dart.js, assets/, ...):
# bazel-bin/app/app.web_build_artifacts/Two developer conveniences come along:
bazel run //app:app.web -- 8080serves the built bundle locally.bazel run //app:app.devrunsflutter run -d web-serverin your source checkout with the hermetic SDK and the webdart_defines— hot reload included, no host Flutter install.
Gate (byte parity): build the same commit with the legacy script
(flutter build web ... with matching --dart-defines and flags) and with
Bazel, then compare the trees:
diff -r build/web bazel-bin/app/app.web_build_artifactsInvestigate every difference before switching the deploy job. The output file
names are stable (not content-hashed), so compare file contents:
main.dart.js, the assets, and the content hashes recorded inside
flutter_service_worker.js should match when the SDK version and flags agree.
Android builds consume the host SDK through rules_android's @androidsdk
repository. In MODULE.bazel:
bazel_dep(name = "rules_android", version = "0.6.6")
android_sdk_repo = use_extension(
"@rules_android//rules/android_sdk_repository:rule.bzl",
"android_sdk_repository_extension",
)
use_repo(android_sdk_repo, "androidsdk")And in .bazelrc:
common --repo_env=ANDROID_HOME
# Optional: persistent Gradle cache so warm builds skip distribution/Maven downloads.
build --action_env=RULES_FLUTTER_GRADLE_USER_HOME=/path/to/gradle-cache
Host prerequisites:
ANDROID_HOMEpoints at a real SDK installation.- The NDK must be installed inside the SDK at
ndk/<version>(viasdkmanager). AGP 8 droppedndk.dirsupport and resolves the NDK throughsdk.dir, so a standalone NDK path is not enough. - Do not register rules_android_ndk toolchains. Registering them forces
the
@androidndkrepository to be fetched for every build, including web and test builds that never touch Android. The SDK-embedded NDK covers AGP's needs; the optionalandroid_ndkattribute exists for repos that genuinely need an external NDK tree.
The targets, with the release plumbing that replaces pubspec rewrites:
string_flag(
name = "android_build_number",
build_setting_default = "", # empty means "do not pass --build-number"
)
flutter_app(
name = "app",
embed = [":lib"],
apk = {
"srcs": glob(["android/**"]),
"android_sdk": "@androidsdk//:sdk_path",
"android_test": True, # also build the instrumentation APK
},
appbundle = {
"srcs": glob(["android/**"]),
"android_sdk": "@androidsdk//:sdk_path",
"mode": "release",
"build_name": "1.2.3", # --build-name
"build_number": ":android_build_number", # --build-number via flag
},
)The release wrapper shrinks to secrets, one build, one upload:
# (export signing secrets / keystore paths consumed by your Gradle config)
bazel build //app:app.appbundle --//app:android_build_number=421
# Upload bazel-bin/app/app.appbundle_build_artifacts/app-release.aabNo more sed on pubspec.yaml: the version name is a rule attribute and the
version code is an ordinary Bazel flag your wrapper computes (for example,
the next Play Store version code).
android_test = True (apk targets only) additionally runs Gradle's
app:assembleAndroidTest after the Flutter build and copies the
instrumentation APK under androidTest/ in the artifacts tree — the two-APK
layout Firebase Test Lab's instrumentation testing expects.
Android actions are declared non-hermetic (no-sandbox, requires-network,
no-remote-exec, mnemonic FlutterBuildAndroid): AGP requires the real host
SDK directory and Gradle downloads its distribution and Maven dependencies.
They are still ordinary cached actions — unchanged inputs mean a warm rebuild
is a no-op.
iOS builds require a macOS host with Xcode and CocoaPods installed — the same
prerequisites as any Bazel Apple build. The action (mnemonic
FlutterBuildIos; requires-darwin, requires-network, no-sandbox,
no-remote-exec) drives pod install through the Flutter tool and produces
an unsigned Runner.app (the build passes --no-codesign); keep signing
and IPA export in your release wrapper.
flutter_app(
name = "app",
embed = [":lib"],
ios = {
"srcs": glob(["ios/**"]),
"mode": "release",
},
)Recommended .bazelrc additions:
# Keep the real HOME in the iOS action so CocoaPods spec/pod caches persist
# across builds (under --incompatible_strict_action_env HOME is otherwise
# absent and the action falls back to a scratch HOME).
build --action_env=HOME
RULES_FLUTTER_CP_HOME can be set (via --action_env) to point CocoaPods'
CP_HOME_DIR somewhere specific. Under --incompatible_strict_action_env
the action also probes the common CocoaPods install directories
(/opt/homebrew/bin, /usr/local/bin, ...) before failing, so a minimal
action PATH does not break pod.
Podfile ordering gotcha. On recent Flutter releases the tool no longer
creates the ios/.symlinks plugin links during pub get on macOS — they are
created by flutter_install_all_ios_pods while CocoaPods evaluates the
Podfile. A Podfile that reads plugin files before that call — a common
pattern is loading a firebase_sdk_version.rb from
.symlinks/plugins/firebase_core/... near the top of the file — works on a
developer machine with stale symlinks but fails on every fresh checkout,
which is exactly what a Bazel action is. Reorder the Podfile so
flutter_install_all_ios_pods runs before anything reads plugin sources.
This is a latent bug even for non-Bazel builds; fixing it is safe to land
independently.
Note on the sealed SDK: bin/cache stays read-only, but the iOS/macOS engine
frameworks keep owner-write because flutter build ios copies them
permissions-preserved and codesigns the copies in place. This does not weaken
the sealing guarantee — the tool never writes those files in place.
Gate: bazel build //app:app.apk //app:app.appbundle produce installable
artifacts, and :app.ios on macOS builds the unsigned Runner.app (it is
not installable until your release wrapper signs it); a second invocation
with no changes executes zero actions.
Run these checks at the end of every phase; they catch the failure modes that actually occur in migrations.
-
Byte parity against the legacy build. For each artifact you cut over (web bundle, APK, AAB, Runner.app), build the same commit both ways with matching flags and diff the outputs (
diff -rfor the web tree; compare sizes and unzipped contents for mobile packages). Do not retire a legacy job until its Bazel replacement has matched it at least once. -
Second build is cached. Hermeticity regressions show up as spurious rebuilds. After any green build:
bazel build //app:app.web && bazel build //app:app.web # The second invocation must report 0 actions executed.
The same holds for
bazel test //...(every target(cached) PASSED) and for the mobile targets despite their non-hermetic execution requirements. -
Sealed-SDK assertion. Add a small
sh_test(from@rules_shell//shell:sh_test.bzl, or Bazel's default rule autoloading) that fails if anything can write into the SDK cache — it guards against toolchain regressions and against scripts that still try toflutter precache/flutter configthe Bazel SDK:#!/bin/bash set -euo pipefail FLUTTER_BIN="$(find -L "${TEST_SRCDIR:-$PWD}" -path "*flutter_sdk/bin/flutter" | head -n 1)" CACHE_DIR="$(dirname "$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$FLUTTER_BIN")")/cache" if touch "$CACHE_DIR/.mutation_probe" 2>/dev/null; then rm -f "$CACHE_DIR/.mutation_probe" echo "SDK bin/cache is writable; expected sealed read-only" >&2 exit 1 fi echo "SDK bin/cache is sealed read-only"
(See
e2e/smoke/sdk_cache_sealed_test.shfor the full version, including the runfiles wiring.) -
Clean checkout, clean tree. On a fresh clone with no host Flutter install:
bazel test //...passes, andgit statusis untouched afterwards. This is the end-state the whole migration buys you.