From a76becbf313429f2db2e6178e05a6f11d3b9b59d Mon Sep 17 00:00:00 2001 From: Mikhail Novoseltsev <51940183+Sameri11@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:06:22 +0500 Subject: [PATCH] feat: bootstrap Flutter plugins through Bazel --- QUICKSTART.md | 129 +++++++------ README.md | 2 +- examples/demo_app/MODULE.bazel.lock | 2 +- examples/demo_app/android/config.MODULE.bazel | 6 + examples/demo_app/plugin_deps.MODULE.bazel | 21 +-- examples/local_plugin/MODULE.bazel.lock | 2 +- .../host_app/android/config.MODULE.bazel | 6 + .../local_plugin/plugin_deps.MODULE.bazel | 21 +-- examples/pub_plugins/MODULE.bazel.lock | 2 +- .../pub_plugins/android/config.MODULE.bazel | 6 + examples/pub_plugins/plugin_deps.MODULE.bazel | 21 +-- tests/consumer/MODULE.bazel.lock | 2 +- tests/consumer/android/config.MODULE.bazel | 4 + .../fixtures/plugin_deps.MODULE.bazel | 29 ++- tools/flutter/defs.bzl | 178 ++++++++++++++++-- tools/flutter/plugins.bzl | 127 +++---------- 16 files changed, 322 insertions(+), 236 deletions(-) diff --git a/QUICKSTART.md b/QUICKSTART.md index 7929732..c3c245e 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -68,13 +68,15 @@ Pin Bazel with `.bazelversion`: 9.2.0 ``` -Use this complete `.bazelrc` in an outside consumer (the examples import the +Use this complete `.bazelrc` in an outside consumer; the examples import +repository-shared cache settings instead: ``` common --enable_bzlmod build:android --merge_android_manifest_permissions build:android --tool_java_language_version=17 --tool_java_runtime_version=remotejdk_17 build:android --java_language_version=17 --java_runtime_version=remotejdk_17 +common:android --repo_env=ANDROID_NDK_HOME common --config=android ``` @@ -376,27 +378,24 @@ Future _checkDocumentsDirectory() async { This is the runtime assertion: after installation it must resolve a directory, not report `MissingPluginException`. -### Seed generated state before changing the module graph +### Create generated state and wire the plugin graph -The ordering is required. First create the Dart registrant placeholder, but do -**not** create an empty `plugin_deps.MODULE.bazel`: +After `flutter pub get`, create both generated-state files as zero-byte +placeholders. The plugin guards require their committed files to exist, and +`include()` requires its target file to exist while the module is evaluated: ```sh -touch lib/dart_plugin_registrant.dart -# NOT `touch plugin_deps.MODULE.bazel`. +touch plugin_deps.MODULE.bazel lib/dart_plugin_registrant.dart ``` -Move the complete `maven = use_extension(...)` through -`use_repo(maven, "flutter_maven")` block from the plugin-free `MODULE.bazel` -above into `plugin_deps.MODULE.bazel` unchanged. It is a valid seed for the -module include; an empty included file does not create `@flutter_maven`, so -`plugins.project(maven_repo = "@flutter_maven//:pin")` cannot resolve. - -Then remove that Maven block from `MODULE.bazel` and replace it with the real -plugin graph. Keep the module declaration, `rules_flutter` override, and three -direct Bazel dependencies unchanged. +In the same editing pass, replace the hand-written Maven install in +`android/config.MODULE.bazel` with the Consumer Module's permanent extension +proxy and import, then add the plugin extension and generated segment: ```python +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +use_repo(maven, "flutter_maven") + plugins = use_extension("@rules_flutter//tools/flutter:plugins.bzl", "flutter_plugins_ext") plugins.project( abis = ["arm64-v8a", "x86_64"], @@ -409,9 +408,16 @@ use_repo(plugins, "flutter_plugins") include("//:plugin_deps.MODULE.bazel") ``` -`use_repo` makes the generated per-package targets and -`@flutter_plugins//:all` visible. `include` creates the one Maven repository in -the consumer module. They are different, and both are required. +The Consumer Module owns the Maven proxy and `use_repo(maven, "flutter_maven")`; +the generated segment owns the single `maven.install`. The position of +`include()` relative to `plugins.project()` does not matter, provided the +segment declaring the repository name has been evaluated. Do not leave a +second `use_repo` in the generated segment: declaring the name in both places +fails with: + +```text +Error in use_repo: The repo name 'flutter_maven' cannot be defined by a use_repo() call at ... as it is already defined by a use_repo() call at ... +``` ### Turn on the Dart and Android plugin graph @@ -425,9 +431,9 @@ flutter_app( ) ``` -Apply that change in the same edit as the module extension above: enabling the -Dart guard without creating `@flutter_plugins` leaves its expected file -unresolvable. +This BUILD edit and the module wiring above are one transition; apply both +before running a guard. With a graph, `flutter_android_binary` derives the +conventional registrant target. In `android/app/BUILD.bazel`, retain the existing imports, embedding, and `main_activity`, then replace the opt-out Android setup with the plugin-aware @@ -467,21 +473,24 @@ registrant target. ### Generate, commit, build, and prove the transition -Use the guards' generated outputs rather than hand-editing either committed -file. The first guard fails against the seed by design; copy its expected file -from the generated repository: +The placeholders make the plugin guard fail once, as expected. Its failure +output names the updater command; run the updater rather than copying from +Bazel's output tree: ```sh bazel build //:plugins_check -cp "$(bazel info output_base)/$(bazel cquery --output=files @flutter_plugins//:plugin_deps.MODULE.bazel)" plugin_deps.MODULE.bazel -bazel build //:plugins_check +bazel run //:plugins_update bazel build //:dart_registrant_check -cp "$(bazel info output_base)/$(bazel cquery --output=files @flutter_plugins//:dart_plugin_registrant.dart)" lib/dart_plugin_registrant.dart -bazel build //:dart_registrant_check +# Run the `bazel run //:dart_registrant_update` command printed above. +bazel run //:dart_registrant_update + +bazel build //:plugins_check //:dart_registrant_check bazel test //:guards_test ``` +The guard prints this command because it owns the generated artifact's identity. + Commit `plugin_deps.MODULE.bazel` and `lib/dart_plugin_registrant.dart`. Then build and install the APK: @@ -495,15 +504,16 @@ adb install -r bazel-bin/android/app/hello_bazel.apk Launch the app and make the `path_provider` call. It should return the application documents directory without throwing `MissingPluginException`. If the app opens but the call throws that exception, the APK build and install -succeeded but plugin registration is stale or missing; refresh the generated -registrants and rebuild. +succeeded but plugin registration is stale or missing; rerun the registrant +guard and its printed updater, then rebuild. For every later pub plugin addition, removal, or upgrade, run `flutter pub get`. -Flutter refreshes `GeneratedPluginRegistrant.java`; keep its BUILD target dependent -on `@flutter_plugins//:all`, rather than maintaining a per-plugin Bazel list. -Rerun the two guards, copy their generated outputs when they drift, commit them, -rebuild, install, and exercise the changed plugin. Standard CMake-backed plugins -are generated automatically. If a plugin's Maven coordinate cannot be read statically, +Flutter refreshes `GeneratedPluginRegistrant.java`; keep its BUILD target +dependent on `@flutter_plugins//:all`, rather than maintaining a per-plugin +list. Run both guards; when a file drifts, use the updater command printed +by its guard and commit the resulting files. Rebuild, install, and +exercise the changed plugin. Standard CMake-backed plugins are generated +automatically. If a plugin's Maven coordinate cannot be read statically, declare it on that package instead of adding a second Maven install: ```python @@ -552,19 +562,17 @@ flutter pub get cd ../.. ``` -Before enabling `plugins.project()`, bootstrap the two committed generated-state -files. From the module root, create the Dart placeholder but do **not** create an -empty Maven segment: +Create both committed generated-state files as zero-byte placeholders from the +module root: ```sh -touch packages/host_app/lib/dart_plugin_registrant.dart -# NOT `touch plugin_deps.MODULE.bazel`. +touch plugin_deps.MODULE.bazel packages/host_app/lib/dart_plugin_registrant.dart ``` -Seed the root `plugin_deps.MODULE.bazel` by moving the complete -`maven = use_extension(...)` through `use_repo(maven, "flutter_maven")` block -from the plugin-free module above into that file, unchanged. This valid seed -makes `@flutter_maven//:pin` available while `plugins.project()` is evaluated. +The root `include()` requires the Maven segment to exist, and the plugin +guards' committed-file attributes are mandatory. The placeholders carry no +Maven content; the Consumer Module's wiring below makes `@flutter_maven` +visible before the generated segment is populated. Keep the root module as a composition point and delegate Android configuration: @@ -614,6 +622,9 @@ android_ndk = use_extension("@rules_flutter//tools/flutter:ndk.bzl", "android_nd use_repo(android_ndk, "androidndk", "androidndk_cmake") register_toolchains("@androidndk//:all") +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +use_repo(maven, "flutter_maven") + plugins = use_extension("@rules_flutter//tools/flutter:plugins.bzl", "flutter_plugins_ext") plugins.project( abis = ["arm64-v8a"], @@ -626,6 +637,12 @@ use_repo(plugins, "flutter_plugins") include("//:plugin_deps.MODULE.bazel") ``` +The Maven proxy and import are owned by this Consumer Module; the generated +segment contributes the single install. `include()` may appear before or after +`plugins.project()` because its position does not matter once the segment +declaring the repository name has been evaluated. Do not duplicate the +`use_repo` declaration in the generated segment. + The root needs a Bazel package solely so it can export the included generated segment: @@ -700,21 +717,24 @@ the generated Android app; the canonical full file is [`packages/host_app/android/app/BUILD.bazel`](examples/local_plugin/packages/host_app/android/app/BUILD.bazel). Generate the root Maven segment and the app's Dart registrant through their -guards; the first build is expected to fail against the seed and print the -expected generated content: +updater targets. The empty placeholders make the plugin guard fail once, and +its failure output names the Maven updater: ```sh bazel build //packages/host_app:plugins_check -cp "$(bazel info output_base)/$(bazel cquery --output=files @flutter_plugins//:plugin_deps.MODULE.bazel)" plugin_deps.MODULE.bazel -bazel build //packages/host_app:plugins_check +bazel run //packages/host_app:plugins_update bazel build //packages/host_app:dart_registrant_check -cp "$(bazel info output_base)/$(bazel cquery --output=files @flutter_plugins//:dart_plugin_registrant.dart)" packages/host_app/lib/dart_plugin_registrant.dart -bazel build //packages/host_app:dart_registrant_check +# Run the `bazel run //packages/host_app:dart_registrant_update` command printed above. +bazel run //packages/host_app:dart_registrant_update + +bazel build //packages/host_app:plugins_check //packages/host_app:dart_registrant_check bazel test //packages/host_app:guards_test bazel build //packages/host_app/android/app:host_app ``` +The guard prints this command because it owns the generated artifact's identity. + Adapt all three label families together when your layout differs: `metadata = "//:.flutter-plugins-dependencies"`, `embedding = "///android/app:flutter_embedding"`, @@ -999,10 +1019,11 @@ at the monorepo root for the subpackage shape), and map the real the explicit root-vs-named `app` label rule. After every pub resolution change, run `flutter pub get`. For any plugin graph, -then run `:plugins_check` and `:dart_registrant_check`, copy the generator's -expected files with the `bazel cquery --output=files` commands above, and commit -them with the lockfile changes. For `path:` dependencies, keep the local Dart -filegroup in `path_deps`; its content is not represented by a pub lock hash. +run `:plugins_check` and `:dart_registrant_check`; when either guard reports +drift, run the updater command it prints and commit the generated files with +the lockfile changes. For `path:` dependencies, +keep the local Dart filegroup in `path_deps`; its content is not represented by +a pub lock hash. Finally, treat building as artifact production, not runtime proof. Discover the APK, install it on a device or emulator for a shipped ABI, launch it, and diff --git a/README.md b/README.md index 87df455..1494128 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ Android release and debug packaging across the supported ABIs, including fat and - The local Flutter, Android SDK, and NDK installations are not hermetic. - Most Dart and asset actions are unsandboxed and do not support remote execution; source tracking is imperfect and Dart compilation is not incremental. - A cold analysis with an empty `HOME` may leave the `Analyzing` count unchanged for minutes while Maven/Coursier, JDK, Flutter engine, Kotlin, NDK, and tool repositories are fetched; continued download or process activity indicates network-bound setup, not proof of a deadlock. When intentionally perturbing `HOME`, pin `BAZELISK_HOME` and Bazel's startup `--output_user_root` to isolate launcher and download caches from rules behavior. -- Native assets require manual consumer recipes, and the first plugin graph requires manual bootstrap and committed generated state. +- Native assets require manual consumer recipes. A first plugin graph still needs two irreducible zero-byte placeholders: `plugin_deps.MODULE.bazel`, because `include()` requires its target file to exist, and `lib/dart_plugin_registrant.dart`, because the plugin guard's committed-file attribute is mandatory. The bootstrap path has no automated regression gate, so follow the [public plugin-graph walkthrough](QUICKSTART.md#create-generated-state-and-wire-the-plugin-graph) by hand. - Custom release signing is not supported, and `ndk-build` plugins are not supported. - Plugin Maven coordinates that cannot be read statically require `plugins.package(artifacts = ...)`. - iOS and other platform packaging are not implemented. diff --git a/examples/demo_app/MODULE.bazel.lock b/examples/demo_app/MODULE.bazel.lock index 65b0e65..9bb2ea9 100644 --- a/examples/demo_app/MODULE.bazel.lock +++ b/examples/demo_app/MODULE.bazel.lock @@ -378,7 +378,7 @@ }, "@@rules_flutter+//tools/flutter:plugins.bzl%flutter_plugins_ext": { "general": { - "bzlTransitiveDigest": "mlUnmmd2GGMEeXDB/CCY6gCL7bKjzIHQBr0JTpXCtw8=", + "bzlTransitiveDigest": "YDyYRyA+h/cB6QG169Hi67W4q0wg0xgGvvpFuEMM3Tg=", "usagesDigest": "XZeXzyR0RtbfpK9X/cRefQ0IzVT+2325G7HjeZJ9G1U=", "recordedInputs": [ "REPO_MAPPING:,flutter_maven rules_jvm_external++maven+flutter_maven", diff --git a/examples/demo_app/android/config.MODULE.bazel b/examples/demo_app/android/config.MODULE.bazel index d13d41d..57cec34 100644 --- a/examples/demo_app/android/config.MODULE.bazel +++ b/examples/demo_app/android/config.MODULE.bazel @@ -2,6 +2,12 @@ bazel_dep(name = "rules_android", version = "0.7.3") bazel_dep(name = "rules_kotlin", version = "2.4.0") bazel_dep(name = "rules_jvm_external", version = "7.1") +# The proxy and import stay in this Consumer Module so the mandatory +# `plugins.project(maven_repo = ...)` label resolves before the generated +# segment is included. +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +use_repo(maven, "flutter_maven") + # The compile SDK, pinned. Left unpinned, rules_android selects the *highest # installed* platform, which is a machine-dependent input: every APK this # example builds was byte-identical between a developer machine and a CI runner diff --git a/examples/demo_app/plugin_deps.MODULE.bazel b/examples/demo_app/plugin_deps.MODULE.bazel index a6589a9..9cdf358 100644 --- a/examples/demo_app/plugin_deps.MODULE.bazel +++ b/examples/demo_app/plugin_deps.MODULE.bazel @@ -1,18 +1,10 @@ # Generated by @rules_flutter//tools/flutter:plugins.bzl -- do not edit by hand. # -# The complete Maven artifact list: the Flutter embedding's own dependencies -# (from @rules_flutter//tools/flutter:embedding.bzl) merged with the coordinates -# extracted from every plugin's android/build.gradle. Regenerate with: +# Maven artifacts from the Flutter embedding and plugin build files. +# Run the generated updater target when `pub_plugins_check` reports drift. # -# bazel build //:plugins_check -# -# which prints this file's expected contents when it drifts. -# -# This is the *only* maven.install for @flutter_maven, deliberately. Two install -# tags sharing a repository name merge into one resolution and the later tag -# wins outright -- not highest-version, not declaration order -- so splitting -# the list let a plugin silently downgrade an artifact the embedding declared. -# One list, resolved once, with highest-wins applied across all of it. +# The Consumer Module must import `maven_repo` before this segment; it declares +# the sole `flutter_maven` install, avoiding version skew from split installs. maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( name = "flutter_maven", @@ -35,9 +27,6 @@ maven.install( "https://maven.google.com", "https://repo1.maven.org/maven2", ], - # Every artifact above is already reduced to one version, so "pinned" makes - # those chosen versions authoritative over transitive suggestions rather - # than failing resolution when a pom asks for something older. + # Keep the selected artifact versions authoritative. version_conflict_policy = "pinned", ) -use_repo(maven, "flutter_maven") diff --git a/examples/local_plugin/MODULE.bazel.lock b/examples/local_plugin/MODULE.bazel.lock index b198111..5ea341e 100644 --- a/examples/local_plugin/MODULE.bazel.lock +++ b/examples/local_plugin/MODULE.bazel.lock @@ -378,7 +378,7 @@ }, "@@rules_flutter+//tools/flutter:plugins.bzl%flutter_plugins_ext": { "general": { - "bzlTransitiveDigest": "mlUnmmd2GGMEeXDB/CCY6gCL7bKjzIHQBr0JTpXCtw8=", + "bzlTransitiveDigest": "YDyYRyA+h/cB6QG169Hi67W4q0wg0xgGvvpFuEMM3Tg=", "usagesDigest": "Z9hQSeXyPKbND6D/cNmthbIQrw93atyT653mkQZgs8Q=", "recordedInputs": [ "REPO_MAPPING:,flutter_maven rules_jvm_external++maven+flutter_maven", diff --git a/examples/local_plugin/packages/host_app/android/config.MODULE.bazel b/examples/local_plugin/packages/host_app/android/config.MODULE.bazel index 3eedf06..0d98ab3 100644 --- a/examples/local_plugin/packages/host_app/android/config.MODULE.bazel +++ b/examples/local_plugin/packages/host_app/android/config.MODULE.bazel @@ -2,6 +2,12 @@ bazel_dep(name = "rules_android", version = "0.7.3") bazel_dep(name = "rules_kotlin", version = "2.4.0") bazel_dep(name = "rules_jvm_external", version = "7.1") +# The proxy and import stay in this Consumer Module so the mandatory +# `plugins.project(maven_repo = ...)` label resolves before the generated +# segment is included. +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +use_repo(maven, "flutter_maven") + android_sdk = use_extension( "@rules_android//rules/android_sdk_repository:rule.bzl", "android_sdk_repository_extension", diff --git a/examples/local_plugin/plugin_deps.MODULE.bazel b/examples/local_plugin/plugin_deps.MODULE.bazel index 7825561..2966fbf 100644 --- a/examples/local_plugin/plugin_deps.MODULE.bazel +++ b/examples/local_plugin/plugin_deps.MODULE.bazel @@ -1,18 +1,10 @@ # Generated by @rules_flutter//tools/flutter:plugins.bzl -- do not edit by hand. # -# The complete Maven artifact list: the Flutter embedding's own dependencies -# (from @rules_flutter//tools/flutter:embedding.bzl) merged with the coordinates -# extracted from every plugin's android/build.gradle. Regenerate with: +# Maven artifacts from the Flutter embedding and plugin build files. +# Run the generated updater target when `pub_plugins_check` reports drift. # -# bazel build //packages/host_app:plugins_check -# -# which prints this file's expected contents when it drifts. -# -# This is the *only* maven.install for @flutter_maven, deliberately. Two install -# tags sharing a repository name merge into one resolution and the later tag -# wins outright -- not highest-version, not declaration order -- so splitting -# the list let a plugin silently downgrade an artifact the embedding declared. -# One list, resolved once, with highest-wins applied across all of it. +# The Consumer Module must import `maven_repo` before this segment; it declares +# the sole `flutter_maven` install, avoiding version skew from split installs. maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( name = "flutter_maven", @@ -34,9 +26,6 @@ maven.install( "https://maven.google.com", "https://repo1.maven.org/maven2", ], - # Every artifact above is already reduced to one version, so "pinned" makes - # those chosen versions authoritative over transitive suggestions rather - # than failing resolution when a pom asks for something older. + # Keep the selected artifact versions authoritative. version_conflict_policy = "pinned", ) -use_repo(maven, "flutter_maven") diff --git a/examples/pub_plugins/MODULE.bazel.lock b/examples/pub_plugins/MODULE.bazel.lock index 874aa28..c722b8d 100644 --- a/examples/pub_plugins/MODULE.bazel.lock +++ b/examples/pub_plugins/MODULE.bazel.lock @@ -378,7 +378,7 @@ }, "@@rules_flutter+//tools/flutter:plugins.bzl%flutter_plugins_ext": { "general": { - "bzlTransitiveDigest": "mlUnmmd2GGMEeXDB/CCY6gCL7bKjzIHQBr0JTpXCtw8=", + "bzlTransitiveDigest": "YDyYRyA+h/cB6QG169Hi67W4q0wg0xgGvvpFuEMM3Tg=", "usagesDigest": "YAm/jbk0GwO9p9qd76oXRuG29FRMV6Bi9F3BHgRBbI0=", "recordedInputs": [ "REPO_MAPPING:,flutter_maven rules_jvm_external++maven+flutter_maven", diff --git a/examples/pub_plugins/android/config.MODULE.bazel b/examples/pub_plugins/android/config.MODULE.bazel index f4da118..a77f024 100644 --- a/examples/pub_plugins/android/config.MODULE.bazel +++ b/examples/pub_plugins/android/config.MODULE.bazel @@ -2,6 +2,12 @@ bazel_dep(name = "rules_android", version = "0.7.3") bazel_dep(name = "rules_kotlin", version = "2.4.0") bazel_dep(name = "rules_jvm_external", version = "7.1") +# The proxy and import stay in this Consumer Module so the mandatory +# `plugins.project(maven_repo = ...)` label resolves before the generated +# segment is included. +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +use_repo(maven, "flutter_maven") + android_sdk = use_extension( "@rules_android//rules/android_sdk_repository:rule.bzl", "android_sdk_repository_extension", diff --git a/examples/pub_plugins/plugin_deps.MODULE.bazel b/examples/pub_plugins/plugin_deps.MODULE.bazel index 418892a..faf1cde 100644 --- a/examples/pub_plugins/plugin_deps.MODULE.bazel +++ b/examples/pub_plugins/plugin_deps.MODULE.bazel @@ -1,18 +1,10 @@ # Generated by @rules_flutter//tools/flutter:plugins.bzl -- do not edit by hand. # -# The complete Maven artifact list: the Flutter embedding's own dependencies -# (from @rules_flutter//tools/flutter:embedding.bzl) merged with the coordinates -# extracted from every plugin's android/build.gradle. Regenerate with: +# Maven artifacts from the Flutter embedding and plugin build files. +# Run the generated updater target when `pub_plugins_check` reports drift. # -# bazel build //:plugins_check -# -# which prints this file's expected contents when it drifts. -# -# This is the *only* maven.install for @flutter_maven, deliberately. Two install -# tags sharing a repository name merge into one resolution and the later tag -# wins outright -- not highest-version, not declaration order -- so splitting -# the list let a plugin silently downgrade an artifact the embedding declared. -# One list, resolved once, with highest-wins applied across all of it. +# The Consumer Module must import `maven_repo` before this segment; it declares +# the sole `flutter_maven` install, avoiding version skew from split installs. maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( name = "flutter_maven", @@ -35,9 +27,6 @@ maven.install( "https://maven.google.com", "https://repo1.maven.org/maven2", ], - # Every artifact above is already reduced to one version, so "pinned" makes - # those chosen versions authoritative over transitive suggestions rather - # than failing resolution when a pom asks for something older. + # Keep the selected artifact versions authoritative. version_conflict_policy = "pinned", ) -use_repo(maven, "flutter_maven") diff --git a/tests/consumer/MODULE.bazel.lock b/tests/consumer/MODULE.bazel.lock index 624e643..e78fe72 100644 --- a/tests/consumer/MODULE.bazel.lock +++ b/tests/consumer/MODULE.bazel.lock @@ -378,7 +378,7 @@ }, "@@rules_flutter+//tools/flutter:plugins.bzl%flutter_plugins_ext": { "general": { - "bzlTransitiveDigest": "mlUnmmd2GGMEeXDB/CCY6gCL7bKjzIHQBr0JTpXCtw8=", + "bzlTransitiveDigest": "YDyYRyA+h/cB6QG169Hi67W4q0wg0xgGvvpFuEMM3Tg=", "usagesDigest": "MM5qL4l0rXCX1ki2Bk8iDaeEbFfREdpk3KGY5JkftEE=", "recordedInputs": [ "REPO_MAPPING:,fake_plugin_metadata +fake_plugin_metadata+fake_plugin_metadata", diff --git a/tests/consumer/android/config.MODULE.bazel b/tests/consumer/android/config.MODULE.bazel index 949385e..f2ec380 100644 --- a/tests/consumer/android/config.MODULE.bazel +++ b/tests/consumer/android/config.MODULE.bazel @@ -17,6 +17,10 @@ use_repo(android_sdk, "androidsdk") register_toolchains("@androidsdk//:all") +# The proxy and both imports stay in this Consumer Module so the mandatory +# `plugins.project(maven_repo = ...)` label resolves before the generated +# segment is included. The plugin graph intentionally uses `plugin_maven`; +# the embedding keeps its separate `flutter_maven` repository. maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( name = "flutter_maven", diff --git a/tests/consumer/fixtures/plugin_deps.MODULE.bazel b/tests/consumer/fixtures/plugin_deps.MODULE.bazel index 4b00abd..6bad892 100644 --- a/tests/consumer/fixtures/plugin_deps.MODULE.bazel +++ b/tests/consumer/fixtures/plugin_deps.MODULE.bazel @@ -2,16 +2,30 @@ # # The complete Maven artifact list: the Flutter embedding's own dependencies # (from @rules_flutter//tools/flutter:embedding.bzl) merged with the coordinates -# extracted from every plugin's android/build.gradle. Regenerate with: +# extracted from every plugin's android/build.gradle. # -# bazel build //:plugins_check +# Drift is detected by a `pub_plugins_check` guard over this file. A project +# that calls `flutter_app` gets one as `:plugins_check` beside the app, and its +# failure output names the `bazel run` command that rewrites this file. A +# project that declares the lower-level rule itself gets that command only if it +# passes the guard's `updater` attribute; otherwise the guard prints the +# expected contents and repair is the caller's own business. # -# which prints this file's expected contents when it drifts. +# No label is named here on purpose: this file is generated by a module +# extension, which knows where the pub metadata lives but not which package +# declared the guard, and a guessed label sends a reader to a target that may +# not exist. # -# This is the *only* maven.install for @flutter_maven, deliberately. Two install -# tags sharing a repository name merge into one resolution and the later tag -# wins outright -- not highest-version, not declaration order -- so splitting -# the list let a plugin silently downgrade an artifact the embedding declared. +# Repository visibility is owned by the Consumer Module, not this generated +# segment: `plugins.project(maven_repo = ...)` resolves its `attr.label` while +# the module file is evaluated, before `include()` can evaluate this segment. +# The Consumer Module must import whichever repository `maven_repo` names before +# evaluating `plugins.project`; this file contributes the install itself. +# The conventional install declared here is named `flutter_maven`. This is the +# *only* maven.install for @flutter_maven, deliberately. Two install tags +# sharing a repository name merge into one resolution and the later tag wins +# outright -- not highest-version, not declaration order -- so splitting the +# list let a plugin silently downgrade an artifact the embedding declared. # One list, resolved once, with highest-wins applied across all of it. maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( @@ -40,4 +54,3 @@ maven.install( # than failing resolution when a pom asks for something older. version_conflict_policy = "pinned", ) -use_repo(maven, "flutter_maven") diff --git a/tools/flutter/defs.bzl b/tools/flutter/defs.bzl index 65ed1db..ad0bb0e 100644 --- a/tools/flutter/defs.bzl +++ b/tools/flutter/defs.bzl @@ -22,6 +22,7 @@ unsandboxed. Making them hermetic means modelling pub packages as Bazel repos problem and is not attempted here. """ +load("@bazel_skylib//lib:shell.bzl", "shell") load("@bazel_skylib//rules:build_test.bzl", "build_test") load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") load(":abis.bzl", "ABIS", "aot_gen_snapshot", "aot_target_compatible_with", "check_abis") @@ -527,26 +528,48 @@ def flutter_aot_library(name, srcs, abis, pubspec, entrypoint, package_config, p **kwargs ) -# What `flutter pub get` writes, at the paths pub fixes. Declared for -# invalidation: identity stamps rather than the pub-cache contents themselves, -# which nothing here models. See the invalidation section of README.md. +# Files written by `flutter pub get`, used as invalidation stamps. _PUB_STAMP = [ ".dart_tool/version", ".dart_tool/package_graph.json", "pubspec.lock", ] -# Distinguishes the documented default from a caller deliberately opting out. -# `None` remains an explicit no-registrant declaration. +# Sentinel distinguishes the default from an explicit `None`. _DEFAULT_DART_PLUGIN_REGISTRANT = struct() +# Keep macro-internal targets out of wildcard roots. +def _internal_tags(kwargs): + tags = list(kwargs.get("tags", [])) + if "manual" not in tags: + tags.append("manual") + return tags + +def _internal_kwargs(kwargs): + internal = dict(kwargs) + internal["tags"] = _internal_tags(kwargs) + return internal + +def _workspace_label(label): + """Resolve a committed destination and reject external repositories.""" + requested = str(label) + label = native.package_relative_label(label) + workspace = native.package_relative_label("//:__pkg__") + if label.repo_name != workspace.repo_name: + fail( + ( + "flutter_app: destination label {} belongs to repository {}, but " + + "BUILD_WORKSPACE_DIRECTORY can only update files in the invoking " + + "workspace." + ).format( + requested, + label.repo_name, + ), + ) + return label + # buildifier: disable=unnamed-macro -# Deliberately unnamed: every target it declares is named by convention, because -# the Android half derives those names (`//:app`, `:assets`, `:pubspec`) -# from the package alone. A `name` parameter would be a knob that cannot vary -- -# passing one produced `:myapp_arm64-v8a` while the APK still asked for -# `:app_arm64-v8a`. One Flutter app per package, which is also one pubspec per -# package. +# Target names are fixed: Android packaging derives them from the package. def flutter_app( abis = None, path_deps = [], @@ -579,7 +602,9 @@ def flutter_app( | `:assets` | the asset bundle, built for every ABI | | `:path_deps_check` | fails if a `path:` dependency is undeclared | | `:plugins_check` | fails if the committed Maven coordinates drifted | + | `:plugins_update` | writes the generated Maven segment into the workspace | | `:dart_registrant_check` | fails if the committed registrant drifted | + | `:dart_registrant_update` | writes the generated registrant into the workspace | | `:guards_test` | the guards, under `bazel test` | `:app_` and `:assets` compile to their debug shape under @@ -711,23 +736,41 @@ def flutter_app( # Both compare against @flutter_plugins, so both are skipped by a project # that has no such repo -- rather than making the macro uninstantiable there. if plugin_deps: + plugin_deps_label = _workspace_label(plugin_deps) pub_plugins_check( name = "plugins_check", - committed = plugin_deps, + committed = plugin_deps_label, expected = "@flutter_plugins//:plugin_deps.MODULE.bazel", + updater = ":plugins_update", **kwargs ) guards.append(":plugins_check") + _write_source_file( + name = "plugins_update", + source = "@flutter_plugins//:plugin_deps.MODULE.bazel", + destination = plugin_deps_label, + **_internal_kwargs(kwargs) + ) + if dart_plugin_registrant: + registrant_label = _workspace_label(dart_plugin_registrant) pub_plugins_check( name = "dart_registrant_check", - committed = dart_plugin_registrant, + committed = registrant_label, expected = "@flutter_plugins//:dart_plugin_registrant.dart", + updater = ":dart_registrant_update", **kwargs ) guards.append(":dart_registrant_check") + _write_source_file( + name = "dart_registrant_update", + source = "@flutter_plugins//:dart_plugin_registrant.dart", + destination = registrant_label, + **_internal_kwargs(kwargs) + ) + # The guards fail as *actions*, which is stronger than a test: anything # depending on one fails too, and the result is remote-cacheable. build_test # does not change that -- it only gives `bazel test` a reason to build them. @@ -991,11 +1034,17 @@ target from CI, or wire it into a test suite, before enabling a shared cache.""" def _pub_plugins_check_impl(ctx): marker = ctx.actions.declare_file(ctx.label.name + ".checked") + updater = "" + if ctx.attr.updater: + updater_label = ctx.attr.updater.label + updater = ( + "//{}:{}".format(updater_label.package, updater_label.name) if updater_label.repo_name == ctx.label.repo_name else str(updater_label) + ) ctx.actions.run_shell( command = """ set -eu -expected="$1"; committed="$2"; marker="$3" +expected="$1"; committed="$2"; marker="$3"; updater="$4" if ! diff -u "$committed" "$expected" > /dev/null 2>&1; then echo "ERROR: $committed is out of date." >&2 echo "" >&2 @@ -1003,13 +1052,23 @@ if ! diff -u "$committed" "$expected" > /dev/null 2>&1; then echo "not match the committed MODULE.bazel segment. Replace it with:" >&2 echo "" >&2 sed 's/^/ /' "$expected" >&2 + if [[ -n "$updater" ]]; then + echo "" >&2 + echo "To regenerate it, run:" >&2 + echo " bazel run $updater" >&2 + fi echo "" >&2 diff -u "$committed" "$expected" >&2 || true exit 1 fi touch "$marker" """, - arguments = [ctx.file.expected.path, ctx.file.committed.path, marker.path], + arguments = [ + ctx.file.expected.path, + ctx.file.committed.path, + marker.path, + updater, + ], inputs = [ctx.file.expected, ctx.file.committed], outputs = [marker], mnemonic = "PubPluginsCheck", @@ -1038,5 +1097,94 @@ or upgraded. This is the guard, and it prints the file to write.""", mandatory = True, doc = "The segment generated by the plugins repository rule.", ), + "updater": attr.label( + doc = "Optional updater command to print when this guard drifts.", + ), + }, +) + +_WRITE_SOURCE_FILE_SCRIPT = """#!/usr/bin/env bash +# Bazel Bash runfiles initialization. +set -uo pipefail; set +e; f=bazel_tools/tools/bash/runfiles/runfiles.bash +# shellcheck disable=SC1090 +source "${{RUNFILES_DIR:-/dev/null}}/$f" 2>/dev/null || \\ + source "$(grep -sm1 "^$f " "${{RUNFILES_MANIFEST_FILE:-/dev/null}}" | cut -f2- -d' ')" 2>/dev/null || \\ + source "$0.runfiles/$f" 2>/dev/null || \\ + source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \\ + source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \\ + {{ echo>&2 "ERROR: cannot find $f"; exit 1; }}; f=; set -e + +key={source} +source_file="$(rlocation "$key" || true)" +if [[ ! -f "$source_file" ]]; then + echo "ERROR: cannot locate generated source file $key in runfiles." >&2 + exit 1 +fi + +workspace="${{BUILD_WORKSPACE_DIRECTORY:?this target must be run with bazel run}}" +destination="$workspace"/{destination} +mkdir -p "$(dirname "$destination")" +cp "$source_file" "$destination" +""" + +def _write_source_file_impl(ctx): + script = ctx.actions.declare_file(ctx.label.name) + source = ctx.file.source + + # Convert short_path to the repository-qualified runfiles key. + source_path = source.short_path + if source_path.startswith("../"): + source_path = source_path[3:] + else: + source_path = ctx.workspace_name + "/" + source_path + + # Only source files in this workspace can be safely overwritten. + destination = ctx.file.destination + if destination.short_path.startswith("../") or not destination.is_source: + fail( + ( + "{}: destination {} is not a source file in the invoking " + + "workspace, and BUILD_WORKSPACE_DIRECTORY can only update files " + + "there. Name the committed file directly." + ).format( + ctx.label, + ctx.attr.destination.label, + ), + ) + + ctx.actions.write( + output = script, + content = _WRITE_SOURCE_FILE_SCRIPT.format( + source = shell.quote(source_path), + destination = shell.quote(destination.short_path), + ), + is_executable = True, + ) + + return [DefaultInfo( + executable = script, + runfiles = ctx.runfiles(files = [source]).merge( + ctx.attr._runfiles[DefaultInfo].default_runfiles, + ), + )] + +_write_source_file = rule( + implementation = _write_source_file_impl, + executable = True, + doc = "Writes a generated artifact into a source file in the Consumer Module.", + attrs = { + "source": attr.label( + allow_single_file = True, + mandatory = True, + doc = "The generated artifact to write back.", + ), + "destination": attr.label( + allow_single_file = True, + mandatory = True, + doc = "The committed file to overwrite in the invoking workspace.", + ), + "_runfiles": attr.label( + default = "@bazel_tools//tools/bash/runfiles", + ), }, ) diff --git a/tools/flutter/plugins.bzl b/tools/flutter/plugins.bzl index 2e9fb79..fb93bab 100644 --- a/tools/flutter/plugins.bzl +++ b/tools/flutter/plugins.bzl @@ -327,19 +327,11 @@ java_library( _MODULE_SEGMENT_HEADER = """# Generated by @rules_flutter//tools/flutter:plugins.bzl -- do not edit by hand. # -# The complete Maven artifact list: the Flutter embedding's own dependencies -# (from @rules_flutter//tools/flutter:embedding.bzl) merged with the coordinates -# extracted from every plugin's android/build.gradle. Regenerate with: +# Maven artifacts from the Flutter embedding and plugin build files. +# Run the generated updater target when `pub_plugins_check` reports drift. # -# bazel build {plugins_check} -# -# which prints this file's expected contents when it drifts. -# -# This is the *only* maven.install for @flutter_maven, deliberately. Two install -# tags sharing a repository name merge into one resolution and the later tag -# wins outright -- not highest-version, not declaration order -- so splitting -# the list let a plugin silently downgrade an artifact the embedding declared. -# One list, resolved once, with highest-wins applied across all of it. +# The Consumer Module must import `maven_repo` before this segment; it declares +# the sole `flutter_maven` install, avoiding version skew from split installs. maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( name = "flutter_maven", @@ -351,43 +343,23 @@ maven.install( ], {resolution} ) -use_repo(maven, "flutter_maven") """ -# Two ways to settle versions, and which one a project needs depends on its -# dependency graph rather than on taste -- so the consumer picks. -# -# coursier is the default and carries a real app. AndroidX poms do hold Maven -# *strict* ranges (the same artifact demanded as `[2.7.0,2.7.0]` down one path -# and `[2.5.1,2.5.1]` down another) that no POM-based resolver can widen, but -# coursier reports every such conflict in one run rather than one per run, and -# on smooth_app's 30-plugin graph there were exactly three. Declaring those -# three makes them the consumer's own coordinates, which the -# `version_conflict_policy = "pinned"` below is authoritative over. -# -# gradle reads the Gradle Module Metadata AndroidX also publishes, where the -# same constraints are loose, so it settles them with nothing declared -- but it -# requires a lock file, and it records a *variant's* checksum against the root -# coordinate's URL, so KMP modules and guava fail their integrity check -# (rules_jvm_external#1605). Working around that means pinning guava to `-jre`, -# i.e. shipping the desktop build into an Android app. -# -# Both were measured to produce byte-identical resolved versions and APKs that -# behave identically on device. See docs_internal/package-recipes.md. -_COURSIER_RESOLUTION = """ # Every artifact above is already reduced to one version, so "pinned" makes - # those chosen versions authoritative over transitive suggestions rather - # than failing resolution when a pom asks for something older. +# Coursier is the default. Gradle needs a lock file and breaks KMP checksums +# (rules_jvm_external#1605); both resolvers produce identical APKs. +_COURSIER_RESOLUTION = """ # Keep the selected artifact versions authoritative. version_conflict_policy = "pinned",{coursier_options}""" _PINNED_RESOLUTION = """ resolver = "{resolver}", - # Only the coursier resolver may omit a lock file. Repin with - # `bazel run @flutter_maven//:pin` -- note @unpinned_flutter_maven's alias is - # broken under bzlmod. + # Coursier alone may omit a lock file; repin with @flutter_maven//:pin. lock_file = "{lock_file}",""" -def _module_segment(coordinates, resolver, lock_file, coursier_options, plugins_check): +def _module_segment( + coordinates, + resolver, + lock_file, + coursier_options): return _MODULE_SEGMENT_HEADER.format( - plugins_check = plugins_check, artifacts = "".join([ "\n \"{}\",".format(c) for c in highest_versions(coordinates) @@ -1369,12 +1341,6 @@ def _flutter_plugins_impl(ctx): ctx.attr.maven_resolver, ctx.attr.maven_lock_file, ctx.attr.coursier_options, - # The guard `flutter_app` emits sits in the package that holds the - # metadata file, so the hint names the caller's own target rather - # than this repository's demo. Hardcoded, it read `//app:plugins_check` - # in every consumer's committed segment -- a package most of them do - # not have. - "//{}:plugins_check".format(ctx.attr.metadata.package), ), ) @@ -1660,26 +1626,7 @@ default cannot itself vary by mode; a debug build supplies its own }, ) -# The per-package escape hatch, and the only one. Everything a consuming project -# can say about a single pub package is said here. -# -# One tag rather than several because the alternatives -- an `override` for -# coordinates, a `recipe` for build logic, a `replace` for redirection -- are -# three names to learn for one concept. crate_universe's `crate.annotation()` -# takes the same shape for the same reason: one tag, many optional attributes. -# -# The attributes do split, though, and the split is not cosmetic. It follows a -# phase boundary Bazel enforces: -# -# module/fetch time `artifacts` feeds the single maven.install, which is -# resolved before any BUILD file is loaded. -# analysis time `bzl_file`/`macro` name a macro that runs during loading, -# long after Maven resolution has finished. -# -# Which is why `artifacts` cannot simply be something a recipe declares: a macro -# runs far too late to add anything to the resolution, and pulling deps from a -# *second* maven.install would resurrect the version-skew bug that the -# single-install rule in _MODULE_SEGMENT_HEADER exists to prevent. +# Per-package override. Artifacts resolve at fetch time; recipes run at analysis. _package = tag_class( attrs = { "name": attr.string( @@ -1714,15 +1661,7 @@ def _flutter_plugins_ext_impl(ctx): overrides = {} recipes = {} - # Dependencies first, then the root module, so the root's entry for a - # package wins. A dependency may legitimately ship a recipe for something it - # depends on -- that is why `package` is not restricted to the root the way - # `project` is -- but the application being built has the final say. - # - # Without this, these rules' own demo-app recipes leaked into a consumer's - # graph and silently replaced theirs: smooth_app declared a `rive_native` - # recipe, got rules_flutter's, and failed loading a `@@//tools/flutter` - # label that does not exist in a consumer's main repo. + # Dependencies may provide recipes; root-module settings take precedence. for mod in [m for m in ctx.modules if not m.is_root] + [m for m in ctx.modules if m.is_root]: for package in mod.tags.package: if package.artifacts: @@ -1733,50 +1672,29 @@ def _flutter_plugins_ext_impl(ctx): )) if package.bzl_file: recipes[package.name] = json.encode({ - # Canonical, because the generated repo's repo mapping is - # this module's and cannot see the user's apparent names. + # Generated repositories need canonical labels; root recipes + # must match this app's resolved packages. "bzl": str(package.bzl_file), - # The convention every recipe follows, so it is stated once - # here rather than per package. A recipe named otherwise - # still says so. "macro": package.macro or package.name + "_recipe", - # Root recipes describe this app and must match its packages; - # dependency recipes may describe packages it does not use. "root": mod.is_root, }) - # `project` is honoured from the **root module only**, and deliberately. - # - # It names a repository (`flutter_plugins`) and describes one application, so - # a dependency declaring one would both collide on the name and generate the - # wrong graph. Bazel's own guidance is that only the root module should - # directly affect repository names. Without this, adding these rules as a - # bazel_dep fails immediately -- their own demo app's project tag fires - # alongside the consumer's. - # - # `package` tags are *not* restricted this way: they name pub packages, not - # repositories, so a library module may reasonably ship a recipe for a - # package it depends on. + # Only the root may declare a project; dependency recipes remain valid. for mod in ctx.modules: if not mod.is_root: continue for project in mod.tags.project: check_abis(project.abis, "plugins.project") - # `pub get` writes both files, and writes package_config beside the - # metadata it also wrote: /.flutter-plugins-dependencies and - # /.dart_tool/package_config.json. Deriving it means a recipe - # for a non-plugin package -- the only thing that needs it -- works - # without the project having declared anything extra. + # pub get writes package_config beside the plugin metadata. package_config = project.package_config if not package_config: package_config = project.metadata.same_package_label( ".dart_tool/package_config.json", ) + # Generated repositories need the caller-resolved canonical label. # buildifier: disable=canonical-repository - # Generated repos do not inherit the caller's mapping, so retain the - # caller-resolved canonical `@@` name. maven_repo = "@@" + project.maven_repo.repo_name flutter_plugins( @@ -1791,10 +1709,7 @@ def _flutter_plugins_ext_impl(ctx): maven_repo = maven_repo, overrides = overrides, recipes = recipes, - # The embedding label comes from the root module; android_bzl - # and recipe_bzl are resolved here, in the module that owns them, so - # the generated repository does not have to reason about repo - # mapping to find them. + # Labels resolve in the Consumer Module before generation. embedding = str(project.embedding), android_bzl = str(Label("//tools/flutter:android.bzl")), recipe_bzl = str(Label("//tools/flutter:recipe.bzl")),