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/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 diff --git a/gradle-plugins/react/brownfield/build.gradle.kts b/gradle-plugins/react/brownfield/build.gradle.kts index b73354d7..9c38a6b5 100644 --- a/gradle-plugins/react/brownfield/build.gradle.kts +++ b/gradle-plugins/react/brownfield/build.gradle.kts @@ -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") { + 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 6ce14827..befe551d 100644 --- a/gradle-plugins/react/brownfield/gradle/libs.versions.toml +++ b/gradle-plugins/react/brownfield/gradle/libs.versions.toml @@ -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" 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() } + } +}