Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 67 additions & 2 deletions gradle-plugins/react/brownfield/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,83 @@ 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 {
implementation(libs.agp)
implementation(libs.common)
// 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)
implementation(libs.versioncompare)
testImplementation(libs.agp)
testImplementation(libs.common)
testImplementation(libs.junit.jupiter)
testImplementation(gradleTestKit())
testImplementation(kotlin("test"))
testKitPluginClasspath(libs.agp)
}

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>("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 {
Expand Down
7 changes: 5 additions & 2 deletions gradle-plugins/react/brownfield/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
kotlinJvm = "2.2.21"
ktlint = "12.1.1"
detekt = "1.23.7"
agp = "9.2.1"
common = "32.2.0"
# 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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -41,6 +42,7 @@ class RNBrownfieldPlugin : Plugin<Project> {

override fun apply(project: Project) {
verifyAndroidPluginApplied(project)
verifyAgpVersion(project)

this.project = project
initializers()
Expand Down Expand Up @@ -92,6 +94,13 @@ class RNBrownfieldPlugin : Plugin<Project> {
}

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"
}

Expand Down Expand Up @@ -191,6 +200,28 @@ class RNBrownfieldPlugin : Plugin<Project> {
}
}

/**
* 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<UnresolvedArtifactInfo>,
variantName: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<File> {
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<String> =
requireNotNull(System.getProperty("agp.matrix.versions")) {
"agp.matrix.versions was not injected; check the agpMatrix wiring in build.gradle.kts"
}.split(",").filter { it.isNotBlank() }
}
}
Loading