Skip to content

Latest commit

 

History

History
589 lines (479 loc) · 21.7 KB

File metadata and controls

589 lines (479 loc) · 21.7 KB

Migrating a script-based Flutter app to rules_flutter

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.

Phase 0: Baseline — what you probably have

A typical pre-Bazel Flutter repo looks like this:

  • A setup.sh/Makefile that runs flutter pub get in each package, plus a host Flutter install (or a version manager) that drifts between machines.
  • build_runner watch running 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 host protoc, 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: in pubspec.yaml to 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.json files 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 run refresher for the IDE analyzer.
  • Tests, analysis, and format checks are ordinary bazel test targets.
  • Web/Android/iOS release jobs shrink to "checkout, secrets, bazel build, upload artifacts".

Phase 1: Bootstrap the module and toolchain

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.

Phase 2: Pin pub.dev dependencies

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 in

Then, 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, ...) list

Check 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_overrides are baked in at update time. The .update helper runs flutter pub deps --json against your real pubspec.yaml, so the report records the resolved (overridden) versions. Editing an override without rerunning .update leaves 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.json files 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).

Phase 3: flutter_library with codegen in the build

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.

Keeping the IDE happy

Two complementary patterns restore the analyzer's view of generated code:

  1. build_runner run helpers. flutter_library emits 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 when build_runner_modes is 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 used build_runner watch switch 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_multirun for watch+serve setups — see the README).

  2. A refresher target for action-generated output. The prepared workspace built by flutter_library already contains everything the generators produced. A small script target rsyncs it back into the source tree for the analyzer (sh_binary comes 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_generated after 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).

Phase 4: Protos

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).

Phase 5: Tests and lint cutover

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_format

Gate: bazel test //app/... passes twice in a row, with the second run served entirely from cache ((cached) PASSED for every target).

Phase 6: Web cutover

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 -- 8080 serves the built bundle locally.
  • bazel run //app:app.dev runs flutter run -d web-server in your source checkout with the hermetic SDK and the web dart_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_artifacts

Investigate 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.

Phase 7: Mobile cutover

Android (apk / appbundle)

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_HOME points at a real SDK installation.
  • The NDK must be installed inside the SDK at ndk/<version> (via sdkmanager). AGP 8 dropped ndk.dir support and resolves the NDK through sdk.dir, so a standalone NDK path is not enough.
  • Do not register rules_android_ndk toolchains. Registering them forces the @androidndk repository to be fetched for every build, including web and test builds that never touch Android. The SDK-embedded NDK covers AGP's needs; the optional android_ndk attribute 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.aab

No 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

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.

Phase 8: Verification gates

Run these checks at the end of every phase; they catch the failure modes that actually occur in migrations.

  1. 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 -r for 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.

  2. 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.

  3. 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 to flutter precache/flutter config the 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.sh for the full version, including the runfiles wiring.)

  4. Clean checkout, clean tree. On a fresh clone with no host Flutter install: bazel test //... passes, and git status is untouched afterwards. This is the end-state the whole migration buys you.