feat: support transitive dependencies in BGP (RNC-CLI path) - #458
KisaneNeko wants to merge 24 commits into
Conversation
…out of expo package, add test infra Move DependencyInfo and VersionMediatingDependencySet from expo.utils to shared package to make them available for transitive dependency handling in the BGP. Add JUnit 5 test infrastructure and initial regression test for VersionMediatingDependencySet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add kotlin("test") dependency to support kotlin.test.* imports in tests
- Rename BrownfieldPrimitives.kt to BrownfieldPublishingInfo.kt per ktlint single-class-per-file rule
- Fix test class formatting per ktlint standard:no-empty-first-line-in-class-body
All tests pass: 3/3 VersionMediatingDependencySetTest tests pass
Build: BUILD SUCCESSFUL
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iance) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…overerTest Adds a real dependency to the runtimeOnly configuration and asserts it is discovered, closing a mutation-testing gap where deleting "runtimeOnly" from configNames left the test green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ollision Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The sentence 'Skip this task-registration block entirely...' was incorrectly placed inside the kotlin code fence. Moved it outside as a separate paragraph before the fence opens to ensure proper rendering and syntax highlighting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Added comprehensive documentation of the critical fix that relocated the warning sentence outside the kotlin code fence to ensure proper rendering and syntax highlighting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… POM filter Turns on the plugin's includeTransitiveDependencies option in the RNApp demo's BrownfieldLib module and deletes the hand-rolled pom.withXml / module.json post-processing task that predates this feature, now that the plugin itself strips embedded-module entries and injects real transitive dependencies.
- ci: add gradle-plugins path filter to Expo Android road-test job gates - ci: run gradle-plugins unit tests in the ktlint/detekt lint workflow - docs: split publishing/task-registration code fences so the "skip this block" note describes only the skippable part, and fix a stale below/above reference - plugin: tighten removalPredicate to require matching group AND artifact name, avoiding over-exclusion of unrelated third-party POM entries - plugin: restore diagnostic Logging.log() calls at the centralized transitive-dependency merge/injection call site - untrack accidentally-committed task-7-report.md workspace artifact and ignore .superpowers/ going forward Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… discovery ExpoPublishingHelper.appendExpoTransitiveDependenciesFromGradle enumerated "implementation", "api", "runtime" — but plain "runtime" isn't a real configuration on modern AGP/Gradle library modules (legacy Java-plugin name; the correct one is "runtimeOnly"). That leg has silently been a no-op since this code was introduced (callstack#223). Found while building the equivalent RNC-CLI discoverer for this branch, which correctly used "runtimeOnly" from the start. Fixing here as a separate, standalone bug fix rather than folding it into the feature commits — this method is only a fallback path (used when an Expo module's POM file can't be found on disk), so the blast radius is narrow, but it's a confirmed real bug worth closing while we're here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iscovery Mutation-tested manually: fails against the pre-fix "runtime" typo, passes against the "runtimeOnly" fix from the previous commit. Ran the real ExpoApp57 build with the fix applied too -- discovered-dependency counts for the 4 modules that actually exercise this fallback path (expo, expo-constants, expo-modules-core, expo-updates) are unchanged (6/2/11/11 before and after), so the bug has no observable impact on this repo's current Expo dependency set. This test is what actually proves the fix, independent of whether any current module happens to trigger it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…refs Extracts the Gradle-configuration-walking logic shared by the Expo Gradle-fallback and RNC-CLI discoverers into collectPublishableGradleDependencies, so the isPublishableCoordinate filter (rejecting dynamic/blank versions) now applies to both paths instead of only the RNC one. Also logs a warning when RncTransitiveDependencyDiscoverer can't resolve an embedded module's Gradle project, instead of silently skipping it, and removes code comments referencing a design-spec doc that was never committed to this branch. Verified with ktlintCheck + unit tests, and end-to-end via the RNApp -> AndroidApp vanilla Detox suite (built AAR with includeTransitiveDependencies enabled, inspected the generated POM/module.json for correct injection, all 4 Detox tests passed). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
A critical compilation issue and unresolved dependency discovery and publication correctness issues remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds opt-in transitive dependency discovery and publication for vanilla Brownfield projects, shares publishing logic with Expo, and fixes Expo runtimeOnly fallback handling.
Changes:
- Adds RNC dependency discovery, filtering, mediation, and metadata injection.
- Enables the option in RNApp and removes the manual workaround.
- Adds tests, documentation, and CI coverage.
File summaries
| File | Reviewed changes |
|---|---|
gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt |
Tests the option default. |
gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt |
Tests dependency version mediation. |
gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt |
Tests dependency coordinate filtering. |
gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt |
Covers runtimeOnly discovery. |
gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt |
Tests RNC dependency discovery. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt |
Adds the opt-in configuration option. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySet.kt |
Provides shared dependency mediation. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt |
Injects dependencies into publication metadata. Final note: moderate (2 votes)—the metadata path is publication-name specific. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt |
Collects Gradle dependencies. Final note: moderate (1 vote)—runtimeOnly is published as compile. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt |
Filters dependency versions. Final note: moderate (3 votes)—latest.* and Maven ranges are not rejected. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyInfo.kt |
Defines shared dependency data. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt |
Updates shared imports. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt |
Integrates discovery and metadata publishing. Final notes: critical (1 vote)—nullable captured var does not compile; moderate (1 vote)—embedded projects may not be evaluated; nit (1 vote)—Expo discovery runs twice. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt |
Restores Expo publishing data. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt |
Uses shared collection and discovery logic. |
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt |
Discovers vanilla module dependencies. Final notes: moderate (1 vote)—embedded-project evaluation may race collection; moderate (1 vote)—same-coordinate dependencies can be skipped before mediation. |
gradle-plugins/react/brownfield/gradle/libs.versions.toml |
Adds JUnit dependency versions. |
gradle-plugins/react/brownfield/build.gradle.kts |
Configures unit testing. |
docs/docs/docs/getting-started/android.mdx |
Documents the new option. Final note: nit (1 vote)—the troubleshooting guide also needs updating. |
apps/RNApp/android/BrownfieldLib/build.gradle.kts |
Enables transitive publishing and removes the workaround. |
.gitignore |
Ignores internal artifacts. |
.github/workflows/gradle-plugin-lint.yml |
Runs Gradle plugin tests. |
.github/workflows/ci.yml |
Includes plugin changes in app builds. |
Review details
Suppressed comments (6)
docs/docs/docs/getting-started/android.mdx:346
- The new opt-in is not reflected in
docs/docs/docs/guides/troubleshooting.mdx, which still says bare React Native transitive dependencies are not auto-resolved and instructs users to hand-declare them. Update that guide so users are directed toincludeTransitiveDependenciesand the manual workaround is reserved for projects that intentionally leave the option disabled.
> **Transitive dependencies:** the task-registration block above strips embedded-module entries from the generated POM by hand. If you don't need your embedded modules' own third-party dependencies to be resolvable by the app consuming this AAR, that manual block is all you need — skip the rest of this note.
>
> If you *do* want that (e.g. your embedded native modules pull in AndroidX libraries the consuming app should get automatically via Maven), set `includeTransitiveDependencies = true` in this module's `reactBrownfield { }` block instead of hand-rolling the JSON-manipulation task above — the plugin now performs the equivalent removal *and* injects your modules' real dependencies for you:
>
> ```kotlin
> reactBrownfield {
> includeTransitiveDependencies = true
> }
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt:43
- This filters only by group/artifact, so it drops an embedded module's higher requirement whenever BrownfieldLib already declares the same coordinate at a lower or dynamic version. For example, an existing
appcompat:1.6.0declaration causes a module'sappcompat:1.7.1to be discarded beforeVersionMediatingDependencySetcan mediate it, leaving the published POM at 1.6.0 and allowing a consumer to resolve below the module's requirement. Compare versions here, or merge and replace the existing publication entry with the mediated version instead of skipping unconditionally.
.filterNot { isAlreadyDeclaredByConsumer(it.groupId, it.artifactId) }
.forEach { discovered.add(it) }
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt:43
afterEvaluateonly guarantees that the Brownfield project has finished evaluation; it does not wait for each autolinkedmoduleProjectreturned byfindProject. If a native module is evaluated later in the multi-project build, itsimplementation/apideclarations are still absent here and the dependency is silently omitted from the published metadata. Ensure the embedded projects are evaluated before collection (or defer discovery until their evaluation callbacks have run).
collectPublishableGradleDependencies(moduleProject)
.filterNot { isAlreadyDeclaredByConsumer(it.groupId, it.artifactId) }
.forEach { discovered.add(it) }
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt:84
ExpoPublishingHelper.configure()still callsdiscoverAllExpoTransitiveDependenciesfor logging, and this new callback calls the same discovery again. Expo builds therefore parse the same POMs/configurations twice during configuration, adding avoidable work and duplicating any discovery failures; cache the first result or remove one of the calls.
val expoTransitiveDeps = expoPublishingHelper.discoverAllExpoTransitiveDependencies(expoProjects)
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt:90
- This discovery runs from
afterEvaluateon the Brownfield library, butfindProjectdoes not evaluate the embedded module. If an autolinked module is evaluated later, itsimplementation/api/runtimeOnlydeclarations are still unpopulated here and the code silently publishes no dependencies for that module. Ensure the embedded projects are evaluated before collecting their configurations, or defer discovery until project evaluation is complete.
if (extension.includeTransitiveDependencies) {
val rncTransitiveDeps = RncTransitiveDependencyDiscoverer(project).discover(artifacts)
Logging.log("Merged ${rncTransitiveDeps.size} transitive dependencies discovered by the RNC discoverer")
gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt:34
fromGradleDephard-codes every discovered dependency tocompile, so the newly supportedruntimeOnlyconfiguration is published in the POM as a compile dependency. That unnecessarily puts runtime-only libraries on the consuming app's compile classpath and changes their intended API visibility; preserve the configuration's runtime scope when constructingDependencyInfo.
TRANSITIVE_DEPENDENCY_CONFIG_NAMES.forEach { configName ->
val configuration = project.configurations.findByName(configName) ?: return@forEach
configuration.dependencies.forEach { dependency ->
if (dependency is DefaultProjectDependency) return@forEach
val group = dependency.group ?: return@forEach
val info = DependencyInfo.fromGradleDep(group, dependency.name, dependency.version)
if (isPublishableCoordinate(info)) result.add(info)
- Files reviewed: 22/23 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (isExpoProject && expoPublishingHelper != null) { | ||
| val expoTransitiveDeps = expoPublishingHelper.discoverAllExpoTransitiveDependencies(expoProjects) | ||
| Logging.log("Merged ${expoTransitiveDeps.size} transitive dependencies discovered from Expo") | ||
| transitiveDeps.addAll(expoTransitiveDeps) | ||
| } |
| fun isPublishableCoordinate(dependency: DependencyInfo): Boolean { | ||
| val version = dependency.version | ||
| if (version.isNullOrBlank()) return false | ||
| if (version.contains("+")) return false |
| File("$moduleBuildDir/publications/mavenAar/module.json").run { | ||
| val json = inputStream().use { JsonSlurper().parse(it) as Map<*, *> } |
hurali97
left a comment
There was a problem hiding this comment.
Great work with the PR 🚀 Left some comments, please also review the Copilot's comments.
| class PublishingMetadataInjector(private val project: Project) { | ||
| @Suppress("LongMethod") | ||
| fun reconfigureGradleModuleJSON( |
There was a problem hiding this comment.
This register a task in afterEvaluate which in most cases is discouraged. From the implementation here, we can see that it reads a file and then applies operations. It also uses a doLast block to conduct this operation.
I believe this provides us structure and enough information to register this task with inputs in the configuration phase, so before/outside of afterEvaluate.
| * Default is `false`. Expo projects already get equivalent behavior unconditionally; | ||
| * this option only affects non-Expo (RNC CLI) projects. | ||
| */ | ||
| var includeTransitiveDependencies = false |
There was a problem hiding this comment.
Let's default to true and rename/mark this as experimental.
…tion - isPublishableCoordinate now also rejects latest.release/latest.integration and Maven version ranges, not just + wildcards - PublishingMetadataInjector derives the module.json path per publication from the GenerateModuleMetadata task's own output file, instead of hardcoding "mavenAar" — works regardless of publication name - RncTransitiveDependencyDiscoverer no longer silently drops a module's higher version requirement when the consumer already declares the same coordinate at a lower version; mediates and supersedes the stale entry instead - runtimeOnly-sourced dependencies are now published under Maven's "runtime" scope instead of always "compile" - removed the redundant, premature Expo transitive-dependency discovery call in ExpoPublishingHelper.configure() (ran before afterEvaluate, duplicating the real discovery) - point the troubleshooting guide at includeTransitiveDependencies instead of only the manual workaround - gitignore docs/superpowers/ (untracked design-doc workspace artifacts) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…, address human review - Move transitive-dependency discovery/injection from project.afterEvaluate to gradle.taskGraph.whenReady: afterEvaluate only guarantees this project has finished configuring, not that embedded native module projects have — and they hadn't. Confirmed empirically that every embedded module in this repo's own demo app was reporting 'NOT EXECUTED' with empty implementation/api/ runtimeOnly configs at discovery time, meaning the discoverer had never actually found anything in any prior testing. taskGraph.whenReady only fires once the full task graph is resolved, which requires every project this one's tasks depend on (including every embedded module, since their compiled output is bundled into the AAR) to already be configured. - Remove BrownfieldLib's hand-declared androidx.core/appcompat/material dependencies in the RNApp demo, which happened to mask the above bug by duplicating what react-native-screens needs. Verified end-to-end: with them removed, the published POM now correctly contains react-native-screens' actual dependencies (appcompat, fragment-ktx, transition-ktx, coordinatorlayout, swiperefreshlayout, material, core-ktx) plus gson from another embedded module, discovered entirely through the plugin itself. - PublishingMetadataInjector: register its Gradle Module Metadata injector task and POM withXml hook eagerly during configuration instead of from afterEvaluate, per human review feedback. Only supplying the resolved dependency set to it still needs to wait, via a new configure() method. First attempt at this (a companion task registered lazily from inside a GenerateModuleMetadata configureEach callback) crashed with "DefaultTaskContainer#register(String) ... cannot be executed in the current context" when Gradle realized that task mid task-graph-resolution; fixed by using a single eagerly-registered task that walks every GenerateModuleMetadata task's own output file at execution time instead. - Rename includeTransitiveDependencies to experimentalIncludeTransitiveDependencies and flip its default to true, per human review feedback, now that the discovery bug above is fixed. Non-Expo consumers get automatic transitive- dependency publishing without any opt-in; set it to false to fall back to hand-declaring dependencies. - Update docs to describe the option as on-by-default with an opt-out, instead of opt-in. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PublishingMetadataInjector appended the mediated/injected dependency entries before removing entries matching the exclusion predicate. Since the predicate matches by coordinate only, a coordinate that is both already present (e.g. a consumer's own hand-declared dependency from before this feature existed) and marked "superseded" by RncTransitiveDependencyDiscoverer's version mediation would have both its stale and freshly-appended entries deleted by the same pass, leaving the dependency missing entirely from the published POM and module.json — worse than a duplicate. This was live but unreachable before the evaluation-order fix (superseded coordinates were never populated, since discovery never found anything), and became a real regression risk the moment includeTransitiveDependencies defaulted to true: any existing Brownfield user who already hand-declares a dependency that overlaps with what an embedded module needs would silently lose that dependency on upgrade. Fixed by reordering to remove stale matches before appending. Extracted the POM/module.json mutation logic into standalone, directly testable functions (mutatePomDependenciesNode, mutateModuleJsonVariants) and added regression tests for both, verified to actually fail against the old ordering. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… CI failure The Expo Android E2E build jobs were failing in CI trying to resolve expo.modules.* / host.exp.exponent:* coordinates as external Maven dependencies of the published brownfieldlib AAR — they don't exist as standalone Maven artifacts, since Expo's own modules are embedded, not published separately. Root cause: experimentalIncludeTransitiveDependencies now defaults to true with nothing gating it to skip Expo projects, so the RNC discoverer ran there too. Unlike the Expo-specific discovery path, RncTransitiveDependencyDiscoverer has no awareness of the Expo blacklist — it happily walks embedded non-Expo modules' own dependencies, one of which can legitimately declare a dependency directly on an Expo module's own coordinate. Combined with the earlier remove-before-append fix (which only strips pre-existing matching nodes, not filtering what gets newly appended), those coordinates got injected into the POM/module.json unfiltered. Reproduced locally end-to-end: published ExpoApp56's brownfieldlib AAR and found all 14 of the exact coordinates that broke CI in the resulting POM. Fixed at the root rather than only patching the symptom: the RNC discoverer now never runs on Expo projects at all (isExpoProject gate), restoring Expo to its exact pre-PR discovery behavior. Expo's own discovery already covers every embedded module (not just Expo's own packages), so this isn't a regression in coverage — verified the same real build now produces zero expo.*/host.exp.exponent entries while all other legitimate dependencies (Compose, Glide, Room, OkHttp, AndroidX, etc.) remain correctly present, and that RNC discovery's own log line no longer appears for this project at all. Also keeps the hard-exclude filtering (dropHardExcludedDependencies) as a safety net for the RNC-only path, with regression tests verified to fail against a broken implementation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…very Addresses two rounds of code review on the transitive-dependency work. Blocker: the plugin registered a task named `removeDependenciesFromModuleFile` during apply(), which is the exact name the published setup docs tell every bare-RN consumer to register in their own build script. Since apply() runs before the consumer's script body, bumping the plugin version and changing nothing else failed configuration with "Cannot add task '...' as a task with that name already exists". This was not gated by `experimentalIncludeTransitiveDependencies`, so opting out did not avoid it. On main the same registration existed but was only reached for Expo projects, which is why bare-RN consumers never hit it before. The plugin's task is now namespaced as `brownfieldRemoveDependenciesFromModuleFile`, leaving the documented name free. An upgrading app can keep its hand-written block: the two tasks both finalize metadata generation, run sequentially, and the legacy one only removes entries the plugin never injects. A log note tells upgraders the block is now redundant; it deliberately does not fail the build. Correctness fixes in the RNC discoverer and injector: - A consumer-declared version bypassed `isPublishableCoordinate`, so a dynamic declaration such as `androidx.core:core-ktx:1.+` could win mediation and be published as `<version>1.+</version>`. Consumer versions now take part in mediation only when publishable. - A versionless consumer declaration (BOM/platform) never populated `supersededCoordinates`, leaving the base publication's entry in place alongside the injected one - two nodes for the same coordinate. Superseding now keys on the declaration existing, not on it carrying a version. - A publication with no dependencies generates a POM with no `<dependencies>` element, where `NodeList.first()` threw. Resolution is extracted as `resolveOrCreateDependenciesNode` so tests exercise the production path rather than reimplementing the fallback. Tests: adds a TestKit fixture that configures a project still registering the documented legacy task, since no in-repo app has that shape any more; adds regression cases for the dynamic-version and versionless-declaration paths and for both branches of the `<dependencies>` resolution. Each fix was verified by reverting it and confirming the corresponding test goes red. Docs: the upgrade note in android.mdx now says the legacy block can stay, and troubleshooting.mdx records that mediation can raise (never lower) a pinned version. Not fixed here, documented in place: every dependency is added to every module metadata variant, so a runtime-scoped dependency is compile-visible to Gradle consumers. A predicate keyed on `DependencyInfo.scope` would be wrong, not just broad - `scope == "runtime"` means `implementation` on the Expo POM path but `runtimeOnly` on the RNC path (measured: 73 runtime vs 11 compile across 14 upstream Expo POMs). A correct fix keys on provenance and belongs in its own PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comments had drifted into review justification: empirical narratives, restated rationale, and prose that repeated what the code says. Cuts the added comment volume roughly in half, keeping the load-bearing ones (why whenReady rather than afterEvaluate, why the task name is namespaced, why removal precedes appending, why a scope-keyed variant filter would be wrong). No behavior change; suite unchanged at 33 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@hurali97 this was previously passing but false-positive. I've now removed the deps from demo app and implemented fixes. Since you suggested flipping the flag to This has been through multiple back-and-forth AI reviews, and tests are more useful than they used to be in the first version of this PR - should be ready for human review. |
What this does
Adds
experimentalIncludeTransitiveDependenciesto the Brownfield Gradle Plugin.A brownfield AAR bundles your native modules, but it doesn't tell the consuming app about the third party libraries those modules need, so today the host team has to declare them by hand. This makes BGP find them and write them into the published POM and Gradle Module Metadata, so Gradle resolves them on its own. Expo projects already worked this way, this brings the same thing to RNC CLI projects.
It's on by default.
Notion ticket: Brownfield: support transitive dependencies in BGP
What it means if you already use Brownfield
Your build keeps working, but the metadata you publish changes:
removeDependenciesFromModuleFileblock from the old setup docs, you can keep it or delete it. It no longer clashes with the plugin.Probably worth calling out in the changelog.
Bugs fixed along the way
Most of these came out of review.
removeDependenciesFromModuleFile, which is the same name our docs tell people to register themselves. The plugin applies first, so just bumping the version broke the build with a duplicate task error, and setting the flag tofalsedidn't help. Renamed it tobrownfieldRemoveDependenciesFromModuleFile.afterEvaluate, which only waits for the current project to be configured. The embedded modules usually weren't ready yet, so it found nothing at all. Moved totaskGraph.whenReady.androidx.core:core-ktx:1.+, that1.+could win and get published.platform()) ended up in the POM twice.appendExpoTransitiveDependenciesFromGradlelooked for a configuration calledruntime, but modern AGP calls itruntimeOnly, so it had been quietly skipping those since feat: expo config plugin #223.One thing I left alone
Every dependency goes into every variant, so runtime only ones end up visible at compile time to Gradle consumers. The obvious fix is to skip runtime scoped deps for api variants, but that doesn't work. Gradle writes
runtimefor a plainimplementationwhen it generates a POM, so on the Expo side 73 of 84 dependencies look runtime scoped, and filtering on that would drop react-android and appcompat for Expo consumers. It needs a different approach and its own PR. There's a comment in the code with the details.Testing
Published RNApp and ExpoApp57 to maven local and read through the generated POM and module.json: new dependencies get added, embedded modules stay out, dynamic versions get filtered, nothing shows up twice, everything has a real version. Built AndroidApp against it and ran Detox for both vanilla and Expo.
There are 33 unit tests, which isn't really the pattern in this repo, but this code edits publishing metadata in ways that are easy to get subtly wrong and several of them cover actual bugs from the list above. For each fix I reverted it and checked that its test went red. One of them is a TestKit test that sets a project up the old way and reproduces the duplicate task error end to end.
🤖 Generated with Claude Code