From a6dcff0b675ed1b291a9aa29984abf7ad7d2be17 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Mon, 28 Sep 2026 15:05:16 +0200 Subject: [PATCH 1/5] fix(bgp): don't force the plugin's AGP version onto consuming apps The plugin declared AGP as an `implementation` dependency, so it ended up on the app's buildscript classpath and won version resolution. Since #448 bumped it to AGP 9.2.1, every consumer gets AGP 9.2.1 and its minimum Gradle 9.4.1, which breaks Expo SDK 57 apps (Gradle 9.3.1): "Minimum supported Gradle version is 9.4.1. Current version is 9.3.1." Compile against AGP but let the app provide it at runtime. Also drop the leftover version matrix from the Expo 57 Android build job, which ran the same Expo 57 build twice. --- .github/workflows/ci.yml | 5 ----- gradle-plugins/react/brownfield/build.gradle.kts | 9 +++++++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8682592..4be24aea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,11 +217,6 @@ jobs: needs.filter.outputs.ci == 'true' ) && (needs.build-lint.result == 'success' || needs.build-lint.result == 'skipped') - strategy: - matrix: - include: - - version: '58' - - version: '57' steps: - name: Checkout diff --git a/gradle-plugins/react/brownfield/build.gradle.kts b/gradle-plugins/react/brownfield/build.gradle.kts index b73354d7..2965bb81 100644 --- a/gradle-plugins/react/brownfield/build.gradle.kts +++ b/gradle-plugins/react/brownfield/build.gradle.kts @@ -104,10 +104,15 @@ repositories { } dependencies { - implementation(libs.agp) - implementation(libs.common) + // AGP is provided by the consuming app's build. Declaring it as `implementation` puts it on the + // app's buildscript classpath, where it wins version resolution and forces this AGP (and its minimum + // Gradle version) onto the app + compileOnly(libs.agp) + compileOnly(libs.common) implementation(libs.asm.commons) implementation(libs.versioncompare) + testImplementation(libs.agp) + testImplementation(libs.common) testImplementation(libs.junit.jupiter) testImplementation(gradleTestKit()) testImplementation(kotlin("test")) From f0541ef0cb336f9ba9fc035b8e076aaa55ecfca3 Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Mon, 28 Sep 2026 15:28:14 +0200 Subject: [PATCH 2/5] test(bgp): put AGP on the TestKit plugin classpath GradleRunner.withPluginClasspath() is built from the runtime classpath, which no longer has AGP now that it is compileOnly, so LegacyConsumerConfigurationTest could not apply com.android.library. --- gradle-plugins/react/brownfield/build.gradle.kts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/gradle-plugins/react/brownfield/build.gradle.kts b/gradle-plugins/react/brownfield/build.gradle.kts index 2965bb81..032bb973 100644 --- a/gradle-plugins/react/brownfield/build.gradle.kts +++ b/gradle-plugins/react/brownfield/build.gradle.kts @@ -103,6 +103,10 @@ repositories { google() } +// AGP for GradleRunner.withPluginClasspath(), which only sees runtime dependencies, so TestKit +// projects can still apply com.android.library +val testKitPluginClasspath: Configuration by configurations.creating + dependencies { // AGP is provided by the consuming app's build. Declaring it as `implementation` puts it on the // app's buildscript classpath, where it wins version resolution and forces this AGP (and its minimum @@ -116,6 +120,11 @@ dependencies { testImplementation(libs.junit.jupiter) testImplementation(gradleTestKit()) testImplementation(kotlin("test")) + testKitPluginClasspath(libs.agp) +} + +tasks.pluginUnderTestMetadata { + pluginClasspath.from(testKitPluginClasspath) } tasks.test { From 670090de9b1b0fe3356a5ff9dff12ded165d114b Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Tue, 29 Sep 2026 13:38:28 +0200 Subject: [PATCH 3/5] test(e2e): rename AndroidApp Jest config for Expo 58 #467 renamed the Expo 56 app and AppleApp's jest.config.expo56.cjs to expo58, but not AndroidApp's, so .detoxrc.expo58.cjs points at a file that doesn't exist and Detox E2E (AndroidApp - Expo 58) fails with "Can't find a root directory while resolving a config file path". --- .../e2e/{jest.config.expo56.cjs => jest.config.expo58.cjs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/AndroidApp/e2e/{jest.config.expo56.cjs => jest.config.expo58.cjs} (100%) diff --git a/apps/AndroidApp/e2e/jest.config.expo56.cjs b/apps/AndroidApp/e2e/jest.config.expo58.cjs similarity index 100% rename from apps/AndroidApp/e2e/jest.config.expo56.cjs rename to apps/AndroidApp/e2e/jest.config.expo58.cjs From 5e9e0336746cf364e6503c9ad373a1c58014d8ff Mon Sep 17 00:00:00 2001 From: Radoslaw Nowacki Date: Tue, 29 Sep 2026 13:38:28 +0200 Subject: [PATCH 4/5] fix(bgp): compile against AGP 8.5.2 With AGP compileOnly the app provides AGP at runtime, but a plugin built against AGP 9.2.1 references AGP 9 only classes and fails on AGP 8 apps: Expo 57 (AGP 8.12) failed with NoClassDefFoundError com/android/build/api/dsl/AndroidLibrarySourceSet. Built against 8.5.2 (the version before #448) it applies on both AGP 8.12.0 and 9.2.1. --- gradle-plugins/react/brownfield/gradle/libs.versions.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gradle-plugins/react/brownfield/gradle/libs.versions.toml b/gradle-plugins/react/brownfield/gradle/libs.versions.toml index 6ce14827..e117de2d 100644 --- a/gradle-plugins/react/brownfield/gradle/libs.versions.toml +++ b/gradle-plugins/react/brownfield/gradle/libs.versions.toml @@ -2,8 +2,10 @@ kotlinJvm = "2.2.21" ktlint = "12.1.1" detekt = "1.23.7" -agp = "9.2.1" -common = "32.2.0" +# Compile against the oldest supported AGP: AGP is compileOnly, so the app provides it at runtime, +# and a plugin built against AGP 9 fails on AGP 8 apps (e.g. Expo 57) with NoClassDefFoundError +agp = "8.5.2" +common = "31.2.2" # do not bump it for now, as it throws an error for incompatible AGP used asm-commons = "9.7" versioncompare = "1.5.0" junit = "5.11.4" From 672f362ab80eed129c02f1e72eb99806d59df737 Mon Sep 17 00:00:00 2001 From: Hur Ali Date: Wed, 30 Sep 2026 16:42:14 +0500 Subject: [PATCH 5/5] fix: make agp compileOnly with test matrix --- .../react/brownfield/build.gradle.kts | 57 ++++++- .../brownfield/gradle/libs.versions.toml | 9 +- .../brownfield/plugin/RNBrownfieldPlugin.kt | 31 ++++ .../react/brownfield/plugin/RNSourceSets.kt | 7 +- .../plugin/AgpCompatibilityMatrixTest.kt | 155 ++++++++++++++++++ 5 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt diff --git a/gradle-plugins/react/brownfield/build.gradle.kts b/gradle-plugins/react/brownfield/build.gradle.kts index 032bb973..9c38a6b5 100644 --- a/gradle-plugins/react/brownfield/build.gradle.kts +++ b/gradle-plugins/react/brownfield/build.gradle.kts @@ -108,9 +108,9 @@ repositories { val testKitPluginClasspath: Configuration by configurations.creating dependencies { - // AGP is provided by the consuming app's build. Declaring it as `implementation` puts it on the - // app's buildscript classpath, where it wins version resolution and forces this AGP (and its minimum - // Gradle version) onto the app + // AGP is provided by the consuming build's buildscript classpath. Keeping it + // compileOnly stops the published module from pinning AGP (and therefore the + // minimum Gradle version) for every app that applies this plugin. compileOnly(libs.agp) compileOnly(libs.common) implementation(libs.asm.commons) @@ -127,8 +127,59 @@ tasks.pluginUnderTestMetadata { pluginClasspath.from(testKitPluginClasspath) } +/** + * AGP versions the plugin is tested against, oldest first. + * + * The first entry is the floor, and it is the same number as `agp` in `gradle/libs.versions.toml` + * (what the plugin compiles against) and `RNBrownfieldPlugin.MIN_AGP` (what the apply-time gate + * enforces). The compiler covers "we accidentally used an API newer than the floor"; this matrix + * covers the other direction — "we used something a newer AGP has since removed or changed". + */ +val agpMatrix = listOf(libs.versions.agp.get(), "9.2.1") + +/** + * An AGP below the matrix floor. Not supported, and not built against — it exists only so a test can + * prove `RNBrownfieldPlugin.MIN_AGP` actually rejects it instead of letting the consumer fail later + * with an unattributable `NoSuchMethodError`. + */ +val belowFloorAgp = "8.9.0" + +// A TestKit-ready AGP classpath per version above, keyed by version. +val agpClasspaths = + (agpMatrix + belowFloorAgp).associateWith { agpVersion -> + val suffix = agpVersion.replace('.', '_') + val scope = configurations.dependencyScope("testkitAgp$suffix") + dependencies.add(scope.name, "com.android.tools.build:gradle:$agpVersion") + configurations.resolvable("testkitAgp${suffix}Classpath") { + extendsFrom(scope.get()) + } + } + +// `withPluginClasspath()` builds the TestKit fixture classpath from the plugin's runtime +// classpath, which no longer carries AGP now that it is compileOnly. The fixtures apply +// `com.android.library`, so hand the floor AGP back to them here — test-only, so nothing +// reaches the published module. +tasks.named("pluginUnderTestMetadata") { + pluginClasspath.from(agpClasspaths.getValue(agpMatrix.first())) +} + tasks.test { useJUnitPlatform() + + // Tests that want a specific AGP build their own TestKit classpath from these, via + // `withPluginClasspath(files)` instead of the no-arg `withPluginClasspath()`. + val mainSourceSet = sourceSets.main.get() + systemProperty("agp.matrix.versions", agpMatrix.joinToString(",")) + systemProperty("agp.belowFloor.version", belowFloorAgp) + agpClasspaths.forEach { (agpVersion, agpClasspath) -> + val pluginClasspath = files(mainSourceSet.output, mainSourceSet.runtimeClasspath, agpClasspath) + inputs.files(pluginClasspath).withPropertyName("agpMatrix-$agpVersion").withNormalizer(ClasspathNormalizer::class) + jvmArgumentProviders.add( + CommandLineArgumentProvider { + listOf("-Dagp.classpath.$agpVersion=${pluginClasspath.joinToString(File.pathSeparator)}") + }, + ) + } } tasks.named("detekt").configure { diff --git a/gradle-plugins/react/brownfield/gradle/libs.versions.toml b/gradle-plugins/react/brownfield/gradle/libs.versions.toml index e117de2d..befe551d 100644 --- a/gradle-plugins/react/brownfield/gradle/libs.versions.toml +++ b/gradle-plugins/react/brownfield/gradle/libs.versions.toml @@ -2,10 +2,11 @@ kotlinJvm = "2.2.21" ktlint = "12.1.1" detekt = "1.23.7" -# Compile against the oldest supported AGP: AGP is compileOnly, so the app provides it at runtime, -# and a plugin built against AGP 9 fails on AGP 8 apps (e.g. Expo 57) with NoClassDefFoundError -agp = "8.5.2" -common = "31.2.2" # do not bump it for now, as it throws an error for incompatible AGP used +# The AGP the plugin compiles against: deliberately the OLDEST supported version, so the +# compiler rejects any accidental use of a newer AGP API. Newer AGP is covered by the +# TestKit matrix in build.gradle.kts. Keep in sync with MIN_AGP in RNBrownfieldPlugin.kt. +agp = "8.12.0" +common = "31.12.0" asm-commons = "9.7" versioncompare = "1.5.0" junit = "5.11.4" diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt index 72b2c1f9..c8fe6f57 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt @@ -1,5 +1,6 @@ package com.callstack.react.brownfield.plugin +import com.android.build.api.AndroidPluginVersion import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryVariant import com.callstack.react.brownfield.artifacts.ArtifactsResolver @@ -41,6 +42,7 @@ class RNBrownfieldPlugin : Plugin { override fun apply(project: Project) { verifyAndroidPluginApplied(project) + verifyAgpVersion(project) this.project = project initializers() @@ -92,6 +94,13 @@ class RNBrownfieldPlugin : Plugin { } companion object { + /** + * Oldest AGP the plugin supports. This is one number that must hold in three places: the + * `agp` version the plugin compiles against in `gradle/libs.versions.toml`, the floor of the + * TestKit AGP matrix in `build.gradle.kts`, and this gate. Raise all three together. + */ + val MIN_AGP = AndroidPluginVersion(8, 12) + const val EXPO_PROJECT_LOCATOR = ":expo" } @@ -191,6 +200,28 @@ class RNBrownfieldPlugin : Plugin { } } + /** + * Fails fast on AGP versions older than the plugin supports. + * + * The plugin compiles against [MIN_AGP] (see `agp` in `gradle/libs.versions.toml`), so anything + * at or above it is guaranteed to have every AGP symbol the plugin references. Below it, the + * consumer would instead hit a `NoClassDefFoundError`/`NoSuchMethodError` somewhere deep in + * configuration, with nothing pointing back at this plugin. + */ + private fun verifyAgpVersion(project: Project) { + val current = + project.extensions + .getByType(LibraryAndroidComponentsExtension::class.java) + .pluginVersion + if (current < MIN_AGP) { + throw ProjectConfigurationException( + "$PROJECT_ID requires Android Gradle Plugin $MIN_AGP or newer, " + + "but this project resolved AGP $current", + Throwable("Upgrade the Android Gradle Plugin to $MIN_AGP or newer"), + ) + } + } + private fun getAarLibraries( artifacts: List, variantName: String, diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt index 9d6c38ac..4eaa5576 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt @@ -1,5 +1,6 @@ package com.callstack.react.brownfield.plugin +import com.android.build.api.dsl.AndroidSourceSet import com.android.build.api.dsl.LibraryExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.callstack.react.brownfield.exceptions.NameSpaceNotFound @@ -41,7 +42,11 @@ object RNSourceSets { val componentsExtension = project.extensions.getByType(LibraryAndroidComponentsExtension::class.java) // Move the non-variant-specific configuration out of the loop - androidExtension.sourceSets.named("main") { sourceSet -> + // The parameter is typed as the `AndroidSourceSet` supertype on purpose. AGP 9 narrows + // this container to `AndroidLibrarySourceSet`, and letting Kotlin infer that bakes the + // AGP 9-only type into the lambda's `execute` signature, which blows up with + // NoClassDefFoundError on AGP 8 consumers. `AndroidSourceSet` is identical in both. + androidExtension.sourceSets.named("main") { sourceSet: AndroidSourceSet -> // This path is not variant-specific, so it's added once here. sourceSet.java.directories.add("${getModuleBuildDir()}/generated/autolinking/src/main/java") } diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt new file mode 100644 index 00000000..f423ce1b --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt @@ -0,0 +1,155 @@ +package com.callstack.react.brownfield.plugin + +import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.io.File +import kotlin.test.assertTrue + +/** + * Configures a minimal android library against every AGP version in the matrix declared in + * `build.gradle.kts`. + * + * The plugin compiles against the OLDEST supported AGP, which is what stops it from reaching for an + * API newer AGP added. That leaves the opposite direction uncovered: an API the floor has but a + * newer AGP removed, renamed or narrowed compiles cleanly and only breaks at the consumer. This + * test is the coverage for that direction, so the newest entry in the matrix is the one earning its + * keep here. + * + * Skipped without an Android SDK, since the plugin requires `com.android.library`. + */ +class AgpCompatibilityMatrixTest { + @ParameterizedTest(name = "configures cleanly on AGP {0}") + @MethodSource("agpVersions") + fun `the plugin configures an android library on every supported AGP`( + agpVersion: String, + @TempDir projectDir: File, + ) { + val androidSdk = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") + assumeTrue( + androidSdk != null && File(androidSdk).isDirectory, + "no Android SDK available (ANDROID_HOME/ANDROID_SDK_ROOT); skipping AGP-dependent test", + ) + + writeFixture(projectDir, androidSdk!!) + + // `--dry-run` still runs configuration in full — variant callbacks, source set wiring, task + // graph construction — which is where every AGP API the plugin touches is exercised. It just + // skips the task actions, which would need a real RN app to produce anything meaningful. + GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath(pluginClasspathFor(agpVersion)) + .withArguments("assembleDebug", "--dry-run", "--stacktrace") + .forwardOutput() + .build() + } + + @Test + fun `the apply-time gate rejects AGP older than the floor`( + @TempDir projectDir: File, + ) { + val androidSdk = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") + assumeTrue( + androidSdk != null && File(androidSdk).isDirectory, + "no Android SDK available (ANDROID_HOME/ANDROID_SDK_ROOT); skipping AGP-dependent test", + ) + + writeFixture(projectDir, androidSdk!!) + + val belowFloor = requireNotNull(System.getProperty("agp.belowFloor.version")) + val result = + GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath(pluginClasspathFor(belowFloor)) + .withArguments("tasks") + .buildAndFail() + + assertTrue( + result.output.contains("requires Android Gradle Plugin"), + "expected the plugin's own AGP gate to reject AGP $belowFloor, but the build failed " + + "for some other reason:\n${result.output}", + ) + } + + private fun pluginClasspathFor(agpVersion: String): List { + val raw = + requireNotNull(System.getProperty("agp.classpath.$agpVersion")) { + "no TestKit classpath was injected for AGP $agpVersion; check the agpMatrix wiring in build.gradle.kts" + } + return raw.split(File.pathSeparator).filter { it.isNotBlank() }.map(::File) + } + + private fun writeFixture( + projectDir: File, + androidSdk: String, + ) { + File(projectDir, "settings.gradle.kts").writeText( + """ + dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } + } + rootProject.name = "agp-matrix-consumer" + include(":app") + include(":brownfieldlib") + """.trimIndent(), + ) + File(projectDir, "local.properties").writeText("sdk.dir=$androidSdk\n") + // Stands in for the RN app project. It has to be a real `com.android.application` because + // the brownfield plugin wires into AGP's own app-side tasks; the extra registrations cover + // the tasks the React Native Gradle plugin would otherwise contribute. Empty registrations + // are enough: this test is about AGP API compatibility, not about what those tasks produce. + File(projectDir, "app").mkdirs() + File(projectDir, "app/build.gradle.kts").writeText( + """ + plugins { + id("com.android.application") + } + + android { + namespace = "com.example.agpmatrix.app" + compileSdk = 34 + } + + listOf( + "generateAutolinkingPackageList", + "createBundleDebugJsAndAssets", + "createBundleReleaseJsAndAssets", + ).forEach { name -> tasks.register(name) } + """.trimIndent(), + ) + + File(projectDir, "brownfieldlib").mkdirs() + File(projectDir, "brownfieldlib/build.gradle.kts").writeText( + """ + plugins { + id("com.android.library") + id("com.callstack.react.brownfield") + `maven-publish` + } + + android { + namespace = "com.example.agpmatrix" + compileSdk = 34 + } + + // Contributed by the React Native Gradle plugin in a real consumer; stubbed here for the + // same reason as the app-side tasks above. + tasks.register("generateCodegenSchemaFromJavaScript") + """.trimIndent(), + ) + } + + private companion object { + @JvmStatic + fun agpVersions(): List = + requireNotNull(System.getProperty("agp.matrix.versions")) { + "agp.matrix.versions was not injected; check the agpMatrix wiring in build.gradle.kts" + }.split(",").filter { it.isNotBlank() } + } +}