From 96555fe7b35917fc4c322e258a7b4d4dd8f33fb3 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Tue, 27 Oct 2026 18:22:32 +0100 Subject: [PATCH 01/14] feat: track CI builder images in selected tasks --- build-logic/conventions/README.md | 28 +++ build-logic/conventions/build.gradle.kts | 12 + .../buildlogic/ci/CiBuilderInputsExtension.kt | 50 ++++ ...dd-trace-java.ci-builder-inputs.gradle.kts | 10 + .../ci/CiBuilderInputsPluginTest.kt | 219 ++++++++++++++++++ 5 files changed, 319 insertions(+) create mode 100644 build-logic/conventions/README.md create mode 100644 build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt create mode 100644 build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts create mode 100644 build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt diff --git a/build-logic/conventions/README.md b/build-logic/conventions/README.md new file mode 100644 index 00000000000..ed58777994b --- /dev/null +++ b/build-logic/conventions/README.md @@ -0,0 +1,28 @@ +# CI builder image inputs + +Apply `dd-trace-java.ci-builder-inputs` to projects whose tests consume executables +or native libraries from the CI builder image: + +```groovy +plugins { + id 'dd-trace-java.ci-builder-inputs' +} +``` + +The plugin adds `ciBuilderIdentity` to every `Test` task in that project when +`GITLAB_CI=true`. The input contains the image digest, operating system and +architecture. CI must set `CI_BUILDER_IMAGE` to the same immutable +`registry/repository@sha256:...` reference used by the job's `image` setting. + +Register other tasks explicitly when they consume the builder environment: + +```groovy +ciBuilderInputs.track(tasks.named('quarkusNativeBuild')) +``` + +A missing or malformed image reference fails when a selected task's inputs are +queried. Commands such as `help` do not require an image reference. The plugin +does not query a registry or invoke external tools. + +Local builds receive no additional task inputs. Compiler and Muzzle tasks retain +their existing fingerprints unless explicitly registered with `track`. diff --git a/build-logic/conventions/build.gradle.kts b/build-logic/conventions/build.gradle.kts index a31634bb8ec..d7fd4ed7993 100644 --- a/build-logic/conventions/build.gradle.kts +++ b/build-logic/conventions/build.gradle.kts @@ -1,5 +1,17 @@ plugins { `kotlin-dsl` + `jvm-test-suite` +} + +testing { + suites { + named("test") { + useJUnitJupiter(libs.versions.junit5) + dependencies { + implementation(gradleTestKit()) + } + } + } } java { diff --git a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt new file mode 100644 index 00000000000..3253dc6e838 --- /dev/null +++ b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt @@ -0,0 +1,50 @@ +package datadog.buildlogic.ci + +import org.gradle.api.GradleException +import org.gradle.api.Task +import org.gradle.api.provider.ProviderFactory +import org.gradle.api.tasks.TaskProvider +import java.util.Locale +import javax.inject.Inject + +/** Tracks the image contents and platform used by selected CI tasks. */ +open class CiBuilderInputsExtension + @Inject + constructor( + providers: ProviderFactory, + ) { + // CI selection changes task configuration, so it is a configuration-cache input. + private val enabled = providers.environmentVariable("GITLAB_CI").orNull == "true" + + private val identity = + providers.environmentVariable("CI_BUILDER_IMAGE").orElse("").map { reference -> + val digest = + IMAGE_REFERENCE.matchEntire(reference)?.groupValues?.get(1) + ?: throw GradleException( + "CI_BUILDER_IMAGE must be an immutable image reference ending in " + + "@sha256:<64 hexadecimal characters> when GITLAB_CI=true", + ) + mapOf( + "digest" to "sha256:${digest.lowercase(Locale.ROOT)}", + "operatingSystem" to System.getProperty("os.name"), + "architecture" to System.getProperty("os.arch"), + ) + } + + /** Adds the builder identity to a task that consumes tools or libraries from the image. */ + fun track(task: TaskProvider) { + if (enabled) { + task.configure { track(this) } + } + } + + internal fun track(task: Task) { + if (enabled) { + task.inputs.property("ciBuilderIdentity", identity) + } + } + + private companion object { + val IMAGE_REFERENCE = Regex("[^@\\s]+@sha256:([0-9a-fA-F]{64})") + } + } diff --git a/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts b/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts new file mode 100644 index 00000000000..a21513c406e --- /dev/null +++ b/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts @@ -0,0 +1,10 @@ +import datadog.buildlogic.ci.CiBuilderInputsExtension +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.withType + +val ciBuilderInputs = extensions.create("ciBuilderInputs") + +tasks.withType().configureEach { + ciBuilderInputs.track(this) +} diff --git a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt new file mode 100644 index 00000000000..dac79725705 --- /dev/null +++ b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt @@ -0,0 +1,219 @@ +package datadog.buildlogic.ci + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome.FROM_CACHE +import org.gradle.testkit.runner.TaskOutcome.SUCCESS +import org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class CiBuilderInputsPluginTest { + @TempDir + lateinit var directory: Path + + @Test + fun `local builds add no builder inputs even when an image is supplied`() { + fixture() + directory.resolve("build.gradle").toFile().appendText( + """ + + tasks.register('inspectInputs') { + doLast { + assert !tasks.test.inputs.properties.containsKey('ciBuilderIdentity') + assert !tasks.producer.inputs.properties.containsKey('ciBuilderIdentity') + } + } + """.trimIndent(), + ) + + val local = environment(image = "not-an-immutable-image") - "GITLAB_CI" + assertEquals( + SUCCESS, + runner("inspectInputs", configurationCache = false) + .withEnvironment(local) + .build() + .task(":inspectInputs") + ?.outcome, + ) + assertEquals( + SUCCESS, + runner( + "inspectInputs", + configurationCache = false, + ).withEnvironment(local + ("GITLAB_CI" to "false")).build().task(":inspectInputs")?.outcome, + ) + } + + @Test + fun `test cache follows the image digest without invalidating compilation`() { + fixture() + + val first = runner("test").build() + assertEquals(SUCCESS, first.task(":compileTestJava")?.outcome) + assertEquals(SUCCESS, first.task(":test")?.outcome) + + val unchanged = runner("test").build() + assertEquals(UP_TO_DATE, unchanged.task(":test")?.outcome) + assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) + assertEquals(FROM_CACHE, runner("clean", "test").build().task(":test")?.outcome) + + val changed = runner("clean", "test").withEnvironment(environment(image('b'))).build() + assertEquals(FROM_CACHE, changed.task(":compileTestJava")?.outcome) + assertEquals(SUCCESS, changed.task(":test")?.outcome) + + val sameContents = + runner("clean", "test") + .withEnvironment(environment("other.example/builder@sha256:${"b".repeat(64)}")) + .build() + assertEquals(FROM_CACHE, sameContents.task(":test")?.outcome) + } + + @Test + fun `explicit producers follow the same builder identity`() { + fixture() + + assertEquals(SUCCESS, runner("producer").build().task(":producer")?.outcome) + assertEquals(FROM_CACHE, runner("clean", "producer").build().task(":producer")?.outcome) + val changed = runner("producer").withEnvironment(environment(image('b'))).build() + assertEquals(SUCCESS, changed.task(":producer")?.outcome) + } + + @Test + fun `operating system and architecture separate multi-platform image results`() { + fixture() + directory.resolve("build.gradle").toFile().appendText( + """ + + def platformField = providers.gradleProperty('changedPlatformField').orNull + if (platformField != null) { + def original = System.getProperty(platformField) + System.setProperty(platformField, original + '-changed') + gradle.buildFinished { System.setProperty(platformField, original) } + } + """.trimIndent(), + ) + + assertEquals(SUCCESS, runner("producer", configurationCache = false).build().task(":producer")?.outcome) + for (field in listOf("os.name", "os.arch")) { + val changed = runner("producer", "-PchangedPlatformField=$field", configurationCache = false).build() + assertEquals(SUCCESS, changed.task(":producer")?.outcome, changed.output) + } + } + + @Test + fun `invalid identity fails only when tracked task inputs are queried`() { + fixture() + val missing = environment() - "CI_BUILDER_IMAGE" + assertEquals( + SUCCESS, + runner("help") + .withEnvironment(missing) + .build() + .task(":help") + ?.outcome, + ) + + val invalid = + listOf( + missing, + environment(""), + environment("registry.example/builder:ci-base"), + environment("registry.example/builder@sha256:1234"), + environment("registry.example/builder@sha256:${"z".repeat(64)}"), + environment("registry.example/builder @sha256:${"a".repeat(64)}"), + ) + for (values in invalid) { + val failed = runner("producer").withEnvironment(values).buildAndFail() + assertTrue(failed.output.contains("CI_BUILDER_IMAGE must be an immutable image reference"), failed.output) + } + } + + private fun fixture() { + directory.resolve("settings.gradle").toFile().writeText( + """ + rootProject.name = 'ci-builder-inputs-fixture' + buildCache { local { directory = file('task-cache') } } + """.trimIndent(), + ) + directory.resolve("build.gradle").toFile().writeText( + """ + plugins { + id 'dd-trace-java.ci-builder-inputs' + id 'java' + } + + dependencies { testImplementation files(${junitClasspath()}) } + test { useJUnitPlatform() } + + @CacheableTask + abstract class Producer extends DefaultTask { + @OutputFile + abstract RegularFileProperty getOutputFile() + + @TaskAction + void produce() { + outputFile.get().asFile.text = 'built' + } + } + + def producer = tasks.register('producer', Producer) { + group = 'verification' + description = 'Produces an artifact using the builder environment.' + outputFile = layout.buildDirectory.file('producer.txt') + } + ciBuilderInputs.track(producer) + """.trimIndent(), + ) + directory.resolve("src/test/java").toFile().mkdirs() + directory.resolve("src/test/java/ExampleTest.java").toFile().writeText( + """ + public class ExampleTest { + @org.junit.jupiter.api.Test public void passes() {} + } + """.trimIndent(), + ) + } + + private fun junitClasspath() = + listOf( + "org.junit.jupiter.api.Test", + "org.junit.jupiter.engine.JupiterTestEngine", + "org.junit.platform.engine.TestEngine", + "org.junit.platform.launcher.Launcher", + "org.junit.platform.commons.JUnitException", + "org.opentest4j.AssertionFailedError", + ).joinToString(", ") { + "\"${Path.of( + Class + .forName(it) + .protectionDomain.codeSource.location + .toURI(), + )}\"" + } + + private fun image(character: Char = 'a') = "registry.example/builder@sha256:${character.toString().repeat(64)}" + + private fun environment(image: String = image()) = + (System.getenv() - "GITLAB_CI" - "CI_BUILDER_IMAGE") + mapOf("GITLAB_CI" to "true", "CI_BUILDER_IMAGE" to image) + + private fun runner( + vararg arguments: String, + configurationCache: Boolean = true, + ): GradleRunner = + GradleRunner + .create() + .withProjectDir(directory.toFile()) + .withPluginClasspath() + .withEnvironment(environment()) + .withArguments( + *arguments, + "--build-cache", + if (configurationCache) "--configuration-cache" else "--no-configuration-cache", + "--stacktrace", + "--max-workers=2", + "-Dorg.gradle.jvmargs=-Xmx512m", + ) +} From 7afed89ff00a5df86130d0f23d11a073ca4c473a Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Tue, 27 Oct 2026 19:13:44 +0100 Subject: [PATCH 02/14] refactor: read builder identity from a Gradle property --- build-logic/conventions/README.md | 15 ++++--- .../buildlogic/ci/CiBuilderInputsExtension.kt | 20 +++------- .../ci/CiBuilderInputsPluginTest.kt | 40 ++++++++++--------- 3 files changed, 38 insertions(+), 37 deletions(-) diff --git a/build-logic/conventions/README.md b/build-logic/conventions/README.md index ed58777994b..8d44b5c44e4 100644 --- a/build-logic/conventions/README.md +++ b/build-logic/conventions/README.md @@ -10,9 +10,14 @@ plugins { ``` The plugin adds `ciBuilderIdentity` to every `Test` task in that project when -`GITLAB_CI=true`. The input contains the image digest, operating system and -architecture. CI must set `CI_BUILDER_IMAGE` to the same immutable -`registry/repository@sha256:...` reference used by the job's `image` setting. +`GITLAB_CI=true`. The input contains the `ciBuilderImage` Gradle property, +operating system and architecture. CI can supply the property through +`ORG_GRADLE_PROJECT_ciBuilderImage`; `-PciBuilderImage=...` overrides that value. + +The property is an opaque identity. It can hold the current builder repository +and version prefix, such as `registry.example/builder:ci-`, or a future content +hash. Moving a tag to different contents without changing the property does not +invalidate cached results. Supply a content hash to detect those changes. Register other tasks explicitly when they consume the builder environment: @@ -20,8 +25,8 @@ Register other tasks explicitly when they consume the builder environment: ciBuilderInputs.track(tasks.named('quarkusNativeBuild')) ``` -A missing or malformed image reference fails when a selected task's inputs are -queried. Commands such as `help` do not require an image reference. The plugin +A missing or blank identity fails when a selected task's inputs are queried. +Commands such as `help` do not require an identity. The plugin does not query a registry or invoke external tools. Local builds receive no additional task inputs. Compiler and Muzzle tasks retain diff --git a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt index 3253dc6e838..8fea63a6112 100644 --- a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt +++ b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt @@ -4,10 +4,9 @@ import org.gradle.api.GradleException import org.gradle.api.Task import org.gradle.api.provider.ProviderFactory import org.gradle.api.tasks.TaskProvider -import java.util.Locale import javax.inject.Inject -/** Tracks the image contents and platform used by selected CI tasks. */ +/** Tracks the declared builder identity and platform used by selected CI tasks. */ open class CiBuilderInputsExtension @Inject constructor( @@ -17,15 +16,12 @@ open class CiBuilderInputsExtension private val enabled = providers.environmentVariable("GITLAB_CI").orNull == "true" private val identity = - providers.environmentVariable("CI_BUILDER_IMAGE").orElse("").map { reference -> - val digest = - IMAGE_REFERENCE.matchEntire(reference)?.groupValues?.get(1) - ?: throw GradleException( - "CI_BUILDER_IMAGE must be an immutable image reference ending in " + - "@sha256:<64 hexadecimal characters> when GITLAB_CI=true", - ) + providers.gradleProperty("ciBuilderImage").orElse("").map { image -> + if (image.isBlank()) { + throw GradleException("ciBuilderImage must be a nonblank CI builder identity when GITLAB_CI=true") + } mapOf( - "digest" to "sha256:${digest.lowercase(Locale.ROOT)}", + "image" to image, "operatingSystem" to System.getProperty("os.name"), "architecture" to System.getProperty("os.arch"), ) @@ -43,8 +39,4 @@ open class CiBuilderInputsExtension task.inputs.property("ciBuilderIdentity", identity) } } - - private companion object { - val IMAGE_REFERENCE = Regex("[^@\\s]+@sha256:([0-9a-fA-F]{64})") - } } diff --git a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt index dac79725705..0d2b7f49d94 100644 --- a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt +++ b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt @@ -29,7 +29,7 @@ class CiBuilderInputsPluginTest { """.trimIndent(), ) - val local = environment(image = "not-an-immutable-image") - "GITLAB_CI" + val local = environment(image = "") - "GITLAB_CI" assertEquals( SUCCESS, runner("inspectInputs", configurationCache = false) @@ -48,7 +48,7 @@ class CiBuilderInputsPluginTest { } @Test - fun `test cache follows the image digest without invalidating compilation`() { + fun `test cache follows tag identities without invalidating compilation`() { fixture() val first = runner("test").build() @@ -60,15 +60,23 @@ class CiBuilderInputsPluginTest { assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) assertEquals(FROM_CACHE, runner("clean", "test").build().task(":test")?.outcome) - val changed = runner("clean", "test").withEnvironment(environment(image('b'))).build() + val changed = runner("clean", "test").withEnvironment(environment("registry.example/builder:next-")).build() assertEquals(FROM_CACHE, changed.task(":compileTestJava")?.outcome) assertEquals(SUCCESS, changed.task(":test")?.outcome) - val sameContents = - runner("clean", "test") - .withEnvironment(environment("other.example/builder@sha256:${"b".repeat(64)}")) + val hashProperty = "-PciBuilderImage=sha256:${"b".repeat(64)}" + val futureHash = + runner("clean", "test", hashProperty) + .withEnvironment(environment("")) .build() - assertEquals(FROM_CACHE, sameContents.task(":test")?.outcome) + assertEquals(FROM_CACHE, futureHash.task(":compileTestJava")?.outcome) + assertEquals(SUCCESS, futureHash.task(":test")?.outcome) + + val overriddenEnvironment = + runner("clean", "test", hashProperty) + .withEnvironment(environment("registry.example/builder:another-tag")) + .build() + assertEquals(FROM_CACHE, overriddenEnvironment.task(":test")?.outcome) } @Test @@ -77,7 +85,7 @@ class CiBuilderInputsPluginTest { assertEquals(SUCCESS, runner("producer").build().task(":producer")?.outcome) assertEquals(FROM_CACHE, runner("clean", "producer").build().task(":producer")?.outcome) - val changed = runner("producer").withEnvironment(environment(image('b'))).build() + val changed = runner("producer").withEnvironment(environment("registry.example/builder:next-")).build() assertEquals(SUCCESS, changed.task(":producer")?.outcome) } @@ -106,7 +114,7 @@ class CiBuilderInputsPluginTest { @Test fun `invalid identity fails only when tracked task inputs are queried`() { fixture() - val missing = environment() - "CI_BUILDER_IMAGE" + val missing = environment() - "ORG_GRADLE_PROJECT_ciBuilderImage" assertEquals( SUCCESS, runner("help") @@ -120,14 +128,11 @@ class CiBuilderInputsPluginTest { listOf( missing, environment(""), - environment("registry.example/builder:ci-base"), - environment("registry.example/builder@sha256:1234"), - environment("registry.example/builder@sha256:${"z".repeat(64)}"), - environment("registry.example/builder @sha256:${"a".repeat(64)}"), + environment(" \t "), ) for (values in invalid) { val failed = runner("producer").withEnvironment(values).buildAndFail() - assertTrue(failed.output.contains("CI_BUILDER_IMAGE must be an immutable image reference"), failed.output) + assertTrue(failed.output.contains("ciBuilderImage must be a nonblank CI builder identity"), failed.output) } } @@ -194,10 +199,9 @@ class CiBuilderInputsPluginTest { )}\"" } - private fun image(character: Char = 'a') = "registry.example/builder@sha256:${character.toString().repeat(64)}" - - private fun environment(image: String = image()) = - (System.getenv() - "GITLAB_CI" - "CI_BUILDER_IMAGE") + mapOf("GITLAB_CI" to "true", "CI_BUILDER_IMAGE" to image) + private fun environment(image: String = "registry.example/builder:ci-") = + (System.getenv() - "GITLAB_CI" - "ORG_GRADLE_PROJECT_ciBuilderImage") + + mapOf("GITLAB_CI" to "true", "ORG_GRADLE_PROJECT_ciBuilderImage" to image) private fun runner( vararg arguments: String, From 39736b46129ce14631c289e7feb51d6187e9ec11 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 10:08:19 +0100 Subject: [PATCH 03/14] feat: track CI builders in nested smoke tasks --- build-logic/conventions/README.md | 9 +- build-logic/conventions/build.gradle.kts | 4 + ...dd-trace-java.ci-builder-inputs.gradle.kts | 10 ++ .../ci/CiBuilderInputsPluginTest.kt | 91 ++++++++++++++++++- 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/build-logic/conventions/README.md b/build-logic/conventions/README.md index 8d44b5c44e4..3a09c6766b1 100644 --- a/build-logic/conventions/README.md +++ b/build-logic/conventions/README.md @@ -9,8 +9,11 @@ plugins { } ``` -The plugin adds `ciBuilderIdentity` to every `Test` task in that project when -`GITLAB_CI=true`. The input contains the `ciBuilderImage` Gradle property, +The plugin adds `ciBuilderIdentity` to every `Test`, `NestedGradleBuild` and +`NestedMavenBuild` task in that project when `GITLAB_CI=true`. This includes +nested build tasks created by `dd-trace-java.smoke-test-app`, in either plugin +application order. +The input contains the `ciBuilderImage` Gradle property, operating system and architecture. CI can supply the property through `ORG_GRADLE_PROJECT_ciBuilderImage`; `-PciBuilderImage=...` overrides that value. @@ -22,7 +25,7 @@ invalidate cached results. Supply a content hash to detect those changes. Register other tasks explicitly when they consume the builder environment: ```groovy -ciBuilderInputs.track(tasks.named('quarkusNativeBuild')) +ciBuilderInputs.track(tasks.named('generateNativeLibrary')) ``` A missing or blank identity fails when a selected task's inputs are queried. diff --git a/build-logic/conventions/build.gradle.kts b/build-logic/conventions/build.gradle.kts index d7fd4ed7993..ac46905f637 100644 --- a/build-logic/conventions/build.gradle.kts +++ b/build-logic/conventions/build.gradle.kts @@ -3,6 +3,10 @@ plugins { `jvm-test-suite` } +dependencies { + implementation(project(":smoke-test")) +} + testing { suites { named("test") { diff --git a/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts b/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts index a21513c406e..da912ec5afd 100644 --- a/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts +++ b/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts @@ -1,4 +1,6 @@ import datadog.buildlogic.ci.CiBuilderInputsExtension +import datadog.buildlogic.smoketest.NestedGradleBuild +import datadog.buildlogic.smoketest.NestedMavenBuild import org.gradle.api.tasks.testing.Test import org.gradle.kotlin.dsl.create import org.gradle.kotlin.dsl.withType @@ -8,3 +10,11 @@ val ciBuilderInputs = extensions.create("ciBuilderInpu tasks.withType().configureEach { ciBuilderInputs.track(this) } + +tasks.withType().configureEach { + ciBuilderInputs.track(this) +} + +tasks.withType().configureEach { + ciBuilderInputs.track(this) +} diff --git a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt index 0d2b7f49d94..c871ccb6206 100644 --- a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt +++ b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt @@ -16,7 +16,7 @@ class CiBuilderInputsPluginTest { @Test fun `local builds add no builder inputs even when an image is supplied`() { - fixture() + smokeFixture(smokePluginFirst = true) directory.resolve("build.gradle").toFile().appendText( """ @@ -24,6 +24,8 @@ class CiBuilderInputsPluginTest { doLast { assert !tasks.test.inputs.properties.containsKey('ciBuilderIdentity') assert !tasks.producer.inputs.properties.containsKey('ciBuilderIdentity') + assert !tasks.gradleProducer.inputs.properties.containsKey('ciBuilderIdentity') + assert !tasks.mavenProducer.inputs.properties.containsKey('ciBuilderIdentity') } } """.trimIndent(), @@ -89,6 +91,16 @@ class CiBuilderInputsPluginTest { assertEquals(SUCCESS, changed.task(":producer")?.outcome) } + @Test + fun `smoke producers track builder identity when the smoke plugin is applied first`() { + smokeProducerCache(smokePluginFirst = true) + } + + @Test + fun `smoke producers track builder identity when the convention plugin is applied first`() { + smokeProducerCache(smokePluginFirst = false) + } + @Test fun `operating system and architecture separate multi-platform image results`() { fixture() @@ -136,7 +148,79 @@ class CiBuilderInputsPluginTest { } } - private fun fixture() { + private fun smokeProducerCache(smokePluginFirst: Boolean) { + smokeFixture(smokePluginFirst) + val taskNames = arrayOf("gradleProducer", "mavenProducer", "compileTestJava") + + val first = runner(*taskNames).build() + for (taskName in taskNames) { + assertEquals(SUCCESS, first.task(":$taskName")?.outcome, first.output) + } + + val unchanged = runner(*taskNames).build() + assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) + for (taskName in taskNames) { + assertEquals(UP_TO_DATE, unchanged.task(":$taskName")?.outcome, unchanged.output) + } + + val restored = runner("clean", *taskNames).build() + for (taskName in taskNames) { + assertEquals(FROM_CACHE, restored.task(":$taskName")?.outcome, restored.output) + } + + val changed = runner("clean", *taskNames).withEnvironment(environment("registry.example/builder:next-")).build() + assertEquals(FROM_CACHE, changed.task(":compileTestJava")?.outcome, changed.output) + for (taskName in listOf("gradleProducer", "mavenProducer")) { + assertEquals(SUCCESS, changed.task(":$taskName")?.outcome, changed.output) + assertEquals("built", directory.resolve("build/$taskName/artifact.txt").toFile().readText()) + } + } + + private fun smokeFixture(smokePluginFirst: Boolean) { + val smokePlugin = "dd-trace-java.smoke-test-app" + val conventionPlugin = "dd-trace-java.ci-builder-inputs" + val pluginOrder = + if (smokePluginFirst) listOf(smokePlugin, conventionPlugin) else listOf(conventionPlugin, smokePlugin) + fixture(listOf("java") + pluginOrder) + directory.resolve("application").toFile().mkdirs() + directory.resolve("application/source.txt").toFile().writeText("source") + directory.resolve("mvnw").toFile().writeText("fixture Maven wrapper") + directory.resolve("build.gradle").toFile().appendText( + """ + + smokeTestApp { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(JavaVersion.current().majorVersion) + } + gradleApp { + taskName = 'gradleProducer' + artifactPath = 'artifact.txt' + sysProperty = 'gradle.artifact' + } + mavenApp { + taskName = 'mavenProducer' + artifactPath = 'artifact.txt' + sysProperty = 'maven.artifact' + } + } + + // Exercise the real producer types and their inputs without starting nested tools. + ['gradleProducer', 'mavenProducer'].each { producerName -> + tasks.named(producerName) { + applicationBuildDir = layout.buildDirectory.dir(producerName) + actions.clear() + doLast { task -> + def artifact = task.applicationBuildDir.file('artifact.txt').get().asFile + artifact.parentFile.mkdirs() + artifact.text = 'built' + } + } + } + """.trimIndent(), + ) + } + + private fun fixture(plugins: List = listOf("dd-trace-java.ci-builder-inputs", "java")) { directory.resolve("settings.gradle").toFile().writeText( """ rootProject.name = 'ci-builder-inputs-fixture' @@ -146,8 +230,7 @@ class CiBuilderInputsPluginTest { directory.resolve("build.gradle").toFile().writeText( """ plugins { - id 'dd-trace-java.ci-builder-inputs' - id 'java' + ${plugins.joinToString("\n") { "id '$it'" }} } dependencies { testImplementation files(${junitClasspath()}) } From 3476e8b5e0887f129144184da0a8d698dd6d8a29 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 10:37:12 +0100 Subject: [PATCH 04/14] refactor: expose CI builder identity without smoke coupling --- build-logic/conventions/README.md | 18 +-- build-logic/conventions/build.gradle.kts | 4 - .../buildlogic/ci/CiBuilderInputsExtension.kt | 32 +++-- ...dd-trace-java.ci-builder-inputs.gradle.kts | 10 -- .../ci/CiBuilderInputsPluginTest.kt | 120 +++++------------- 5 files changed, 54 insertions(+), 130 deletions(-) diff --git a/build-logic/conventions/README.md b/build-logic/conventions/README.md index 3a09c6766b1..c1df5866801 100644 --- a/build-logic/conventions/README.md +++ b/build-logic/conventions/README.md @@ -9,11 +9,8 @@ plugins { } ``` -The plugin adds `ciBuilderIdentity` to every `Test`, `NestedGradleBuild` and -`NestedMavenBuild` task in that project when `GITLAB_CI=true`. This includes -nested build tasks created by `dd-trace-java.smoke-test-app`, in either plugin -application order. -The input contains the `ciBuilderImage` Gradle property, +The plugin adds `ciBuilderIdentity` to every `Test` task in that project when +`GITLAB_CI=true`. The input contains the `ciBuilderImage` Gradle property, operating system and architecture. CI can supply the property through `ORG_GRADLE_PROJECT_ciBuilderImage`; `-PciBuilderImage=...` overrides that value. @@ -22,15 +19,18 @@ and version prefix, such as `registry.example/builder:ci-`, or a future content hash. Moving a tag to different contents without changing the property does not invalidate cached results. Supply a content hash to detect those changes. -Register other tasks explicitly when they consume the builder environment: +Consumer tasks can register the `ciBuilderInputs.identity` provider directly: ```groovy -ciBuilderInputs.track(tasks.named('generateNativeLibrary')) +tasks.named('generateNativeLibrary') { + inputs.property('ciBuilderIdentity', ciBuilderInputs.identity).optional(true) +} ``` A missing or blank identity fails when a selected task's inputs are queried. Commands such as `help` do not require an identity. The plugin does not query a registry or invoke external tools. -Local builds receive no additional task inputs. Compiler and Muzzle tasks retain -their existing fingerprints unless explicitly registered with `track`. +The provider is absent outside GitLab CI. Declare it as optional so consumer tasks +can also run locally. Local `Test` tasks receive no additional input declarations. +Compiler and Muzzle tasks retain their existing fingerprints. diff --git a/build-logic/conventions/build.gradle.kts b/build-logic/conventions/build.gradle.kts index ac46905f637..d7fd4ed7993 100644 --- a/build-logic/conventions/build.gradle.kts +++ b/build-logic/conventions/build.gradle.kts @@ -3,10 +3,6 @@ plugins { `jvm-test-suite` } -dependencies { - implementation(project(":smoke-test")) -} - testing { suites { named("test") { diff --git a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt index 8fea63a6112..9a2e57107f6 100644 --- a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt +++ b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt @@ -2,8 +2,8 @@ package datadog.buildlogic.ci import org.gradle.api.GradleException import org.gradle.api.Task +import org.gradle.api.provider.Provider import org.gradle.api.provider.ProviderFactory -import org.gradle.api.tasks.TaskProvider import javax.inject.Inject /** Tracks the declared builder identity and platform used by selected CI tasks. */ @@ -15,24 +15,22 @@ open class CiBuilderInputsExtension // CI selection changes task configuration, so it is a configuration-cache input. private val enabled = providers.environmentVariable("GITLAB_CI").orNull == "true" - private val identity = - providers.gradleProperty("ciBuilderImage").orElse("").map { image -> - if (image.isBlank()) { - throw GradleException("ciBuilderImage must be a nonblank CI builder identity when GITLAB_CI=true") - } - mapOf( - "image" to image, - "operatingSystem" to System.getProperty("os.name"), - "architecture" to System.getProperty("os.arch"), - ) - } - - /** Adds the builder identity to a task that consumes tools or libraries from the image. */ - fun track(task: TaskProvider) { + /** Builder identity and platform in GitLab CI; absent in local builds. */ + val identity: Provider> = if (enabled) { - task.configure { track(this) } + providers.gradleProperty("ciBuilderImage").orElse("").map { image -> + if (image.isBlank()) { + throw GradleException("ciBuilderImage must be a nonblank CI builder identity when GITLAB_CI=true") + } + mapOf( + "image" to image, + "operatingSystem" to System.getProperty("os.name"), + "architecture" to System.getProperty("os.arch"), + ) + } + } else { + providers.provider { null } } - } internal fun track(task: Task) { if (enabled) { diff --git a/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts b/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts index da912ec5afd..a21513c406e 100644 --- a/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts +++ b/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts @@ -1,6 +1,4 @@ import datadog.buildlogic.ci.CiBuilderInputsExtension -import datadog.buildlogic.smoketest.NestedGradleBuild -import datadog.buildlogic.smoketest.NestedMavenBuild import org.gradle.api.tasks.testing.Test import org.gradle.kotlin.dsl.create import org.gradle.kotlin.dsl.withType @@ -10,11 +8,3 @@ val ciBuilderInputs = extensions.create("ciBuilderInpu tasks.withType().configureEach { ciBuilderInputs.track(this) } - -tasks.withType().configureEach { - ciBuilderInputs.track(this) -} - -tasks.withType().configureEach { - ciBuilderInputs.track(this) -} diff --git a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt index c871ccb6206..1a8fe2f6aa2 100644 --- a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt +++ b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt @@ -16,16 +16,14 @@ class CiBuilderInputsPluginTest { @Test fun `local builds add no builder inputs even when an image is supplied`() { - smokeFixture(smokePluginFirst = true) + fixture() directory.resolve("build.gradle").toFile().appendText( """ tasks.register('inspectInputs') { doLast { assert !tasks.test.inputs.properties.containsKey('ciBuilderIdentity') - assert !tasks.producer.inputs.properties.containsKey('ciBuilderIdentity') - assert !tasks.gradleProducer.inputs.properties.containsKey('ciBuilderIdentity') - assert !tasks.mavenProducer.inputs.properties.containsKey('ciBuilderIdentity') + assert !ciBuilderInputs.identity.present } } """.trimIndent(), @@ -47,6 +45,23 @@ class CiBuilderInputsPluginTest { configurationCache = false, ).withEnvironment(local + ("GITLAB_CI" to "false")).build().task(":inspectInputs")?.outcome, ) + + assertEquals( + SUCCESS, + runner("producer") + .withEnvironment(local) + .build() + .task(":producer") + ?.outcome, + ) + assertEquals( + FROM_CACHE, + runner("clean", "producer") + .withEnvironment(local + ("ORG_GRADLE_PROJECT_ciBuilderImage" to "registry.example/builder:next-")) + .build() + .task(":producer") + ?.outcome, + ) } @Test @@ -82,23 +97,17 @@ class CiBuilderInputsPluginTest { } @Test - fun `explicit producers follow the same builder identity`() { + fun `consumer providers follow the same builder identity`() { fixture() assertEquals(SUCCESS, runner("producer").build().task(":producer")?.outcome) + val unchanged = runner("producer").build() + assertEquals(UP_TO_DATE, unchanged.task(":producer")?.outcome) + assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) assertEquals(FROM_CACHE, runner("clean", "producer").build().task(":producer")?.outcome) val changed = runner("producer").withEnvironment(environment("registry.example/builder:next-")).build() assertEquals(SUCCESS, changed.task(":producer")?.outcome) - } - - @Test - fun `smoke producers track builder identity when the smoke plugin is applied first`() { - smokeProducerCache(smokePluginFirst = true) - } - - @Test - fun `smoke producers track builder identity when the convention plugin is applied first`() { - smokeProducerCache(smokePluginFirst = false) + assertEquals("built", directory.resolve("build/producer.txt").toFile().readText()) } @Test @@ -148,79 +157,7 @@ class CiBuilderInputsPluginTest { } } - private fun smokeProducerCache(smokePluginFirst: Boolean) { - smokeFixture(smokePluginFirst) - val taskNames = arrayOf("gradleProducer", "mavenProducer", "compileTestJava") - - val first = runner(*taskNames).build() - for (taskName in taskNames) { - assertEquals(SUCCESS, first.task(":$taskName")?.outcome, first.output) - } - - val unchanged = runner(*taskNames).build() - assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) - for (taskName in taskNames) { - assertEquals(UP_TO_DATE, unchanged.task(":$taskName")?.outcome, unchanged.output) - } - - val restored = runner("clean", *taskNames).build() - for (taskName in taskNames) { - assertEquals(FROM_CACHE, restored.task(":$taskName")?.outcome, restored.output) - } - - val changed = runner("clean", *taskNames).withEnvironment(environment("registry.example/builder:next-")).build() - assertEquals(FROM_CACHE, changed.task(":compileTestJava")?.outcome, changed.output) - for (taskName in listOf("gradleProducer", "mavenProducer")) { - assertEquals(SUCCESS, changed.task(":$taskName")?.outcome, changed.output) - assertEquals("built", directory.resolve("build/$taskName/artifact.txt").toFile().readText()) - } - } - - private fun smokeFixture(smokePluginFirst: Boolean) { - val smokePlugin = "dd-trace-java.smoke-test-app" - val conventionPlugin = "dd-trace-java.ci-builder-inputs" - val pluginOrder = - if (smokePluginFirst) listOf(smokePlugin, conventionPlugin) else listOf(conventionPlugin, smokePlugin) - fixture(listOf("java") + pluginOrder) - directory.resolve("application").toFile().mkdirs() - directory.resolve("application/source.txt").toFile().writeText("source") - directory.resolve("mvnw").toFile().writeText("fixture Maven wrapper") - directory.resolve("build.gradle").toFile().appendText( - """ - - smokeTestApp { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(JavaVersion.current().majorVersion) - } - gradleApp { - taskName = 'gradleProducer' - artifactPath = 'artifact.txt' - sysProperty = 'gradle.artifact' - } - mavenApp { - taskName = 'mavenProducer' - artifactPath = 'artifact.txt' - sysProperty = 'maven.artifact' - } - } - - // Exercise the real producer types and their inputs without starting nested tools. - ['gradleProducer', 'mavenProducer'].each { producerName -> - tasks.named(producerName) { - applicationBuildDir = layout.buildDirectory.dir(producerName) - actions.clear() - doLast { task -> - def artifact = task.applicationBuildDir.file('artifact.txt').get().asFile - artifact.parentFile.mkdirs() - artifact.text = 'built' - } - } - } - """.trimIndent(), - ) - } - - private fun fixture(plugins: List = listOf("dd-trace-java.ci-builder-inputs", "java")) { + private fun fixture() { directory.resolve("settings.gradle").toFile().writeText( """ rootProject.name = 'ci-builder-inputs-fixture' @@ -230,7 +167,8 @@ class CiBuilderInputsPluginTest { directory.resolve("build.gradle").toFile().writeText( """ plugins { - ${plugins.joinToString("\n") { "id '$it'" }} + id 'dd-trace-java.ci-builder-inputs' + id 'java' } dependencies { testImplementation files(${junitClasspath()}) } @@ -252,7 +190,9 @@ class CiBuilderInputsPluginTest { description = 'Produces an artifact using the builder environment.' outputFile = layout.buildDirectory.file('producer.txt') } - ciBuilderInputs.track(producer) + producer.configure { + inputs.property('ciBuilderIdentity', ciBuilderInputs.identity).optional(true) + } """.trimIndent(), ) directory.resolve("src/test/java").toFile().mkdirs() From 54512c06448972fc968321ef2e379c2b26ce9bb3 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 13:17:44 +0100 Subject: [PATCH 05/14] refactor: clarify CI builder input registration --- .../kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt | 2 +- .../src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt index 9a2e57107f6..ba0c7b601f4 100644 --- a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt +++ b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt @@ -32,7 +32,7 @@ open class CiBuilderInputsExtension providers.provider { null } } - internal fun track(task: Task) { + internal fun addTo(task: Task) { if (enabled) { task.inputs.property("ciBuilderIdentity", identity) } diff --git a/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts b/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts index a21513c406e..479d414f345 100644 --- a/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts +++ b/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts @@ -6,5 +6,5 @@ import org.gradle.kotlin.dsl.withType val ciBuilderInputs = extensions.create("ciBuilderInputs") tasks.withType().configureEach { - ciBuilderInputs.track(this) + ciBuilderInputs.addTo(this) } From 04750447ce0936e4105220ff55044d56e6d5facc Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 13:56:23 +0100 Subject: [PATCH 06/14] feat: add CI builder inputs to process tasks --- build-logic/conventions/README.md | 43 ++---- .../buildlogic/ci/CiBuilderInputsExtension.kt | 4 +- ...dd-trace-java.ci-builder-inputs.gradle.kts | 10 ++ .../ci/CiBuilderInputsPluginTest.kt | 134 +++++++++++++++++- 4 files changed, 155 insertions(+), 36 deletions(-) diff --git a/build-logic/conventions/README.md b/build-logic/conventions/README.md index c1df5866801..5f4887d8afa 100644 --- a/build-logic/conventions/README.md +++ b/build-logic/conventions/README.md @@ -1,36 +1,23 @@ # CI builder image inputs -Apply `dd-trace-java.ci-builder-inputs` to projects whose tests consume executables -or native libraries from the CI builder image: +The `dd-trace-java.ci-builder-inputs` convention is enabled only in GitLab CI +(`GITLAB_CI=true`). It reads the `ciBuilderImage` Gradle property and adds +`ciBuilderIdentity` inputs to `Test`, `Exec` and `JavaExec` tasks in projects that apply it. -```groovy -plugins { - id 'dd-trace-java.ci-builder-inputs' -} -``` - -The plugin adds `ciBuilderIdentity` to every `Test` task in that project when -`GITLAB_CI=true`. The input contains the `ciBuilderImage` Gradle property, -operating system and architecture. CI can supply the property through -`ORG_GRADLE_PROJECT_ciBuilderImage`; `-PciBuilderImage=...` overrides that value. +> [!NOTE] +> The property is an opaque identity. It can hold the current builder repository +> and version prefix, such as `registry.example/builder:ci-`, or a future content +> hash. Moving a tag to different contents without changing the property does not +> invalidate cached results. -The property is an opaque identity. It can hold the current builder repository -and version prefix, such as `registry.example/builder:ci-`, or a future content -hash. Moving a tag to different contents without changing the property does not -invalidate cached results. Supply a content hash to detect those changes. +For other tasks, declare the input explicitly. The provider returns an empty map locally: -Consumer tasks can register the `ciBuilderInputs.identity` provider directly: +```kotlin +plugins { + id("dd-trace-java.ci-builder-inputs") +} -```groovy -tasks.named('generateNativeLibrary') { - inputs.property('ciBuilderIdentity', ciBuilderInputs.identity).optional(true) +tasks.named("generateNativeLibrary") { + inputs.property("ciBuilderIdentity", ciBuilderInputs.identity) } ``` - -A missing or blank identity fails when a selected task's inputs are queried. -Commands such as `help` do not require an identity. The plugin -does not query a registry or invoke external tools. - -The provider is absent outside GitLab CI. Declare it as optional so consumer tasks -can also run locally. Local `Test` tasks receive no additional input declarations. -Compiler and Muzzle tasks retain their existing fingerprints. diff --git a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt index ba0c7b601f4..6974e05bbdc 100644 --- a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt +++ b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt @@ -15,7 +15,7 @@ open class CiBuilderInputsExtension // CI selection changes task configuration, so it is a configuration-cache input. private val enabled = providers.environmentVariable("GITLAB_CI").orNull == "true" - /** Builder identity and platform in GitLab CI; absent in local builds. */ + /** Builder identity and platform in GitLab CI; an empty map in local builds. */ val identity: Provider> = if (enabled) { providers.gradleProperty("ciBuilderImage").orElse("").map { image -> @@ -29,7 +29,7 @@ open class CiBuilderInputsExtension ) } } else { - providers.provider { null } + providers.provider { emptyMap() } } internal fun addTo(task: Task) { diff --git a/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts b/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts index 479d414f345..2a40e51b02c 100644 --- a/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts +++ b/build-logic/conventions/src/main/kotlin/dd-trace-java.ci-builder-inputs.gradle.kts @@ -1,4 +1,6 @@ import datadog.buildlogic.ci.CiBuilderInputsExtension +import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.testing.Test import org.gradle.kotlin.dsl.create import org.gradle.kotlin.dsl.withType @@ -8,3 +10,11 @@ val ciBuilderInputs = extensions.create("ciBuilderInpu tasks.withType().configureEach { ciBuilderInputs.addTo(this) } + +tasks.withType().configureEach { + ciBuilderInputs.addTo(this) +} + +tasks.withType().configureEach { + ciBuilderInputs.addTo(this) +} diff --git a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt index 1a8fe2f6aa2..5d7a231e464 100644 --- a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt +++ b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt @@ -16,14 +16,16 @@ class CiBuilderInputsPluginTest { @Test fun `local builds add no builder inputs even when an image is supplied`() { - fixture() + processFixture() directory.resolve("build.gradle").toFile().appendText( """ tasks.register('inspectInputs') { doLast { assert !tasks.test.inputs.properties.containsKey('ciBuilderIdentity') - assert !ciBuilderInputs.identity.present + assert !tasks.execProcess.inputs.properties.containsKey('ciBuilderIdentity') + assert !tasks.javaProcess.inputs.properties.containsKey('ciBuilderIdentity') + assert ciBuilderInputs.identity.get().isEmpty() } } """.trimIndent(), @@ -110,6 +112,83 @@ class CiBuilderInputsPluginTest { assertEquals("built", directory.resolve("build/producer.txt").toFile().readText()) } + @Test + fun `process outputs follow CI identity and CI local transitions`() { + processFixture() + val tasks = arrayOf("execProcess", "javaProcess") + + val first = runner(*tasks).build() + for (task in tasks) { + assertEquals(SUCCESS, first.task(":$task")?.outcome, first.output) + assertEquals("built", directory.resolve("build/$task.txt").toFile().readText()) + } + + val unchanged = runner(*tasks).build() + assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) + for (task in tasks) { + assertEquals(UP_TO_DATE, unchanged.task(":$task")?.outcome, unchanged.output) + } + + val changed = runner(*tasks).withEnvironment(environment("registry.example/builder:next-")).build() + for (task in tasks) { + assertEquals(SUCCESS, changed.task(":$task")?.outcome, changed.output) + } + + val local = environment("") - "GITLAB_CI" + val localBuild = runner(*tasks).withEnvironment(local).build() + for (task in tasks) { + assertEquals(SUCCESS, localBuild.task(":$task")?.outcome, localBuild.output) + } + + val unchangedLocal = runner(*tasks).withEnvironment(local).build() + assertTrue(unchangedLocal.output.contains("Reusing configuration cache"), unchangedLocal.output) + for (task in tasks) { + assertEquals(UP_TO_DATE, unchangedLocal.task(":$task")?.outcome, unchangedLocal.output) + } + + val backInCi = runner(*tasks).build() + for (task in tasks) { + assertEquals(SUCCESS, backInCi.task(":$task")?.outcome, backInCi.output) + } + } + + @Test + fun `Kotlin ad hoc inputs accept empty local identity and track CI identity`() { + fixture() + directory.resolve("build.gradle").toFile().delete() + directory.resolve("build.gradle.kts").toFile().writeText( + """ + plugins { + id("dd-trace-java.ci-builder-inputs") + } + + tasks.register("producer") { + inputs.property("ciBuilderIdentity", ciBuilderInputs.identity) + val artifact = layout.buildDirectory.file("producer.txt") + outputs.file(artifact) + doLast { artifact.get().asFile.writeText("built") } + } + """.trimIndent(), + ) + val local = environment("") - "GITLAB_CI" + + assertEquals( + SUCCESS, + runner("producer") + .withEnvironment(local) + .build() + .task(":producer") + ?.outcome, + ) + val unchanged = runner("producer").withEnvironment(local).build() + assertEquals(UP_TO_DATE, unchanged.task(":producer")?.outcome, unchanged.output) + assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) + assertEquals(SUCCESS, runner("producer").build().task(":producer")?.outcome) + val changed = runner("producer").withEnvironment(environment("registry.example/builder:next-")).build() + assertEquals(SUCCESS, changed.task(":producer")?.outcome, changed.output) + assertEquals("built", directory.resolve("build/producer.txt").toFile().readText()) + } + @Test fun `operating system and architecture separate multi-platform image results`() { fixture() @@ -134,7 +213,7 @@ class CiBuilderInputsPluginTest { @Test fun `invalid identity fails only when tracked task inputs are queried`() { - fixture() + processFixture() val missing = environment() - "ORG_GRADLE_PROJECT_ciBuilderImage" assertEquals( SUCCESS, @@ -152,11 +231,54 @@ class CiBuilderInputsPluginTest { environment(" \t "), ) for (values in invalid) { - val failed = runner("producer").withEnvironment(values).buildAndFail() - assertTrue(failed.output.contains("ciBuilderImage must be a nonblank CI builder identity"), failed.output) + for (task in listOf("producer", "execProcess", "javaProcess")) { + val failed = runner(task).withEnvironment(values).buildAndFail() + assertTrue(failed.output.contains("ciBuilderImage must be a nonblank CI builder identity"), failed.output) + } } } + private fun processFixture() { + fixture() + directory.resolve("src/main/java").toFile().mkdirs() + directory.resolve("src/main/java/Writer.java").toFile().writeText( + """ + import java.nio.charset.StandardCharsets; + import java.nio.file.Files; + import java.nio.file.Path; + import java.nio.file.Paths; + + public class Writer { + public static void main(String[] args) throws Exception { + Path output = Paths.get(args[0]); + Files.createDirectories(output.getParent()); + Files.write(output, "built".getBytes(StandardCharsets.UTF_8)); + } + } + """.trimIndent(), + ) + directory.resolve("build.gradle").toFile().appendText( + """ + + tasks.register('execProcess', Exec) { + inputs.files(sourceSets.main.output) + def artifact = layout.buildDirectory.file('execProcess.txt') + outputs.file(artifact) + commandLine new File(System.getProperty('java.home'), 'bin/java'), + '-cp', sourceSets.main.output.classesDirs.asPath, 'Writer', artifact.get().asFile.absolutePath + } + + tasks.register('javaProcess', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'Writer' + def artifact = layout.buildDirectory.file('javaProcess.txt') + outputs.file(artifact) + args artifact.get().asFile.absolutePath + } + """.trimIndent(), + ) + } + private fun fixture() { directory.resolve("settings.gradle").toFile().writeText( """ @@ -191,7 +313,7 @@ class CiBuilderInputsPluginTest { outputFile = layout.buildDirectory.file('producer.txt') } producer.configure { - inputs.property('ciBuilderIdentity', ciBuilderInputs.identity).optional(true) + inputs.property('ciBuilderIdentity', ciBuilderInputs.identity) } """.trimIndent(), ) From d2b1d4aa02e5560de87c6698e0f20ba0a4c27ac1 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 14:15:31 +0100 Subject: [PATCH 07/14] docs: explain CI builder cache inputs --- build-logic/conventions/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build-logic/conventions/README.md b/build-logic/conventions/README.md index 5f4887d8afa..839230cb660 100644 --- a/build-logic/conventions/README.md +++ b/build-logic/conventions/README.md @@ -1,7 +1,8 @@ # CI builder image inputs -The `dd-trace-java.ci-builder-inputs` convention is enabled only in GitLab CI -(`GITLAB_CI=true`). It reads the `ciBuilderImage` Gradle property and adds +The `dd-trace-java.ci-builder-inputs` convention tracks the CI builder image identity +in Gradle build cache keys. It is enabled only in GitLab CI (`GITLAB_CI=true`). +It reads the `ciBuilderImage` Gradle property and adds `ciBuilderIdentity` inputs to `Test`, `Exec` and `JavaExec` tasks in projects that apply it. > [!NOTE] From 4b3be0bc85a6c06ca722673111cac5f63a81c9cb Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 14:23:40 +0100 Subject: [PATCH 08/14] test: simplify CI builder input coverage --- .../ci/CiBuilderInputsPluginTest.kt | 328 +++--------------- 1 file changed, 47 insertions(+), 281 deletions(-) diff --git a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt index 5d7a231e464..e0183845e30 100644 --- a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt +++ b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt @@ -15,205 +15,38 @@ class CiBuilderInputsPluginTest { lateinit var directory: Path @Test - fun `local builds add no builder inputs even when an image is supplied`() { - processFixture() - directory.resolve("build.gradle").toFile().appendText( - """ - - tasks.register('inspectInputs') { - doLast { - assert !tasks.test.inputs.properties.containsKey('ciBuilderIdentity') - assert !tasks.execProcess.inputs.properties.containsKey('ciBuilderIdentity') - assert !tasks.javaProcess.inputs.properties.containsKey('ciBuilderIdentity') - assert ciBuilderInputs.identity.get().isEmpty() - } - } - """.trimIndent(), - ) - - val local = environment(image = "") - "GITLAB_CI" - assertEquals( - SUCCESS, - runner("inspectInputs", configurationCache = false) - .withEnvironment(local) - .build() - .task(":inspectInputs") - ?.outcome, - ) - assertEquals( - SUCCESS, - runner( - "inspectInputs", - configurationCache = false, - ).withEnvironment(local + ("GITLAB_CI" to "false")).build().task(":inspectInputs")?.outcome, - ) - - assertEquals( - SUCCESS, - runner("producer") - .withEnvironment(local) - .build() - .task(":producer") - ?.outcome, - ) - assertEquals( - FROM_CACHE, - runner("clean", "producer") - .withEnvironment(local + ("ORG_GRADLE_PROJECT_ciBuilderImage" to "registry.example/builder:next-")) - .build() - .task(":producer") - ?.outcome, - ) - } - - @Test - fun `test cache follows tag identities without invalidating compilation`() { + fun `builder identity invalidates cached outputs and reuses configuration`() { fixture() + assertEquals(SUCCESS, runner("produce").build().task(":produce")?.outcome) - val first = runner("test").build() - assertEquals(SUCCESS, first.task(":compileTestJava")?.outcome) - assertEquals(SUCCESS, first.task(":test")?.outcome) - - val unchanged = runner("test").build() - assertEquals(UP_TO_DATE, unchanged.task(":test")?.outcome) + val unchanged = runner("produce").build() + assertEquals(UP_TO_DATE, unchanged.task(":produce")?.outcome) assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) - assertEquals(FROM_CACHE, runner("clean", "test").build().task(":test")?.outcome) - - val changed = runner("clean", "test").withEnvironment(environment("registry.example/builder:next-")).build() - assertEquals(FROM_CACHE, changed.task(":compileTestJava")?.outcome) - assertEquals(SUCCESS, changed.task(":test")?.outcome) + assertEquals(FROM_CACHE, runner("clean", "produce").build().task(":produce")?.outcome) - val hashProperty = "-PciBuilderImage=sha256:${"b".repeat(64)}" - val futureHash = - runner("clean", "test", hashProperty) - .withEnvironment(environment("")) - .build() - assertEquals(FROM_CACHE, futureHash.task(":compileTestJava")?.outcome) - assertEquals(SUCCESS, futureHash.task(":test")?.outcome) - - val overriddenEnvironment = - runner("clean", "test", hashProperty) - .withEnvironment(environment("registry.example/builder:another-tag")) - .build() - assertEquals(FROM_CACHE, overriddenEnvironment.task(":test")?.outcome) + val changed = runner("produce").withEnvironment(environment("builder:next")).build() + assertEquals(SUCCESS, changed.task(":produce")?.outcome) + assertEquals("built", directory.resolve("build/artifact.txt").toFile().readText()) } @Test - fun `consumer providers follow the same builder identity`() { + fun `automatic inputs apply in CI and local identity is empty`() { fixture() - - assertEquals(SUCCESS, runner("producer").build().task(":producer")?.outcome) - val unchanged = runner("producer").build() - assertEquals(UP_TO_DATE, unchanged.task(":producer")?.outcome) - assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) - assertEquals(FROM_CACHE, runner("clean", "producer").build().task(":producer")?.outcome) - val changed = runner("producer").withEnvironment(environment("registry.example/builder:next-")).build() - assertEquals(SUCCESS, changed.task(":producer")?.outcome) - assertEquals("built", directory.resolve("build/producer.txt").toFile().readText()) - } - - @Test - fun `process outputs follow CI identity and CI local transitions`() { - processFixture() - val tasks = arrayOf("execProcess", "javaProcess") - - val first = runner(*tasks).build() - for (task in tasks) { - assertEquals(SUCCESS, first.task(":$task")?.outcome, first.output) - assertEquals("built", directory.resolve("build/$task.txt").toFile().readText()) - } - - val unchanged = runner(*tasks).build() - assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) - for (task in tasks) { - assertEquals(UP_TO_DATE, unchanged.task(":$task")?.outcome, unchanged.output) - } - - val changed = runner(*tasks).withEnvironment(environment("registry.example/builder:next-")).build() - for (task in tasks) { - assertEquals(SUCCESS, changed.task(":$task")?.outcome, changed.output) - } - + assertEquals(SUCCESS, runner("inspect", configurationCache = false).build().task(":inspect")?.outcome) val local = environment("") - "GITLAB_CI" - val localBuild = runner(*tasks).withEnvironment(local).build() - for (task in tasks) { - assertEquals(SUCCESS, localBuild.task(":$task")?.outcome, localBuild.output) - } - - val unchangedLocal = runner(*tasks).withEnvironment(local).build() - assertTrue(unchangedLocal.output.contains("Reusing configuration cache"), unchangedLocal.output) - for (task in tasks) { - assertEquals(UP_TO_DATE, unchangedLocal.task(":$task")?.outcome, unchangedLocal.output) - } - - val backInCi = runner(*tasks).build() - for (task in tasks) { - assertEquals(SUCCESS, backInCi.task(":$task")?.outcome, backInCi.output) - } - } - - @Test - fun `Kotlin ad hoc inputs accept empty local identity and track CI identity`() { - fixture() - directory.resolve("build.gradle").toFile().delete() - directory.resolve("build.gradle.kts").toFile().writeText( - """ - plugins { - id("dd-trace-java.ci-builder-inputs") - } - - tasks.register("producer") { - inputs.property("ciBuilderIdentity", ciBuilderInputs.identity) - val artifact = layout.buildDirectory.file("producer.txt") - outputs.file(artifact) - doLast { artifact.get().asFile.writeText("built") } - } - """.trimIndent(), - ) - val local = environment("") - "GITLAB_CI" - assertEquals( SUCCESS, - runner("producer") + runner("inspect", "produce", configurationCache = false) .withEnvironment(local) .build() - .task(":producer") + .task(":inspect") ?.outcome, ) - val unchanged = runner("producer").withEnvironment(local).build() - assertEquals(UP_TO_DATE, unchanged.task(":producer")?.outcome, unchanged.output) - assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) - assertEquals(SUCCESS, runner("producer").build().task(":producer")?.outcome) - val changed = runner("producer").withEnvironment(environment("registry.example/builder:next-")).build() - assertEquals(SUCCESS, changed.task(":producer")?.outcome, changed.output) - assertEquals("built", directory.resolve("build/producer.txt").toFile().readText()) } @Test - fun `operating system and architecture separate multi-platform image results`() { + fun `missing and blank identities fail only when inputs are queried`() { fixture() - directory.resolve("build.gradle").toFile().appendText( - """ - - def platformField = providers.gradleProperty('changedPlatformField').orNull - if (platformField != null) { - def original = System.getProperty(platformField) - System.setProperty(platformField, original + '-changed') - gradle.buildFinished { System.setProperty(platformField, original) } - } - """.trimIndent(), - ) - - assertEquals(SUCCESS, runner("producer", configurationCache = false).build().task(":producer")?.outcome) - for (field in listOf("os.name", "os.arch")) { - val changed = runner("producer", "-PchangedPlatformField=$field", configurationCache = false).build() - assertEquals(SUCCESS, changed.task(":producer")?.outcome, changed.output) - } - } - - @Test - fun `invalid identity fails only when tracked task inputs are queried`() { - processFixture() val missing = environment() - "ORG_GRADLE_PROJECT_ciBuilderImage" assertEquals( SUCCESS, @@ -223,128 +56,61 @@ class CiBuilderInputsPluginTest { .task(":help") ?.outcome, ) - - val invalid = - listOf( - missing, - environment(""), - environment(" \t "), - ) - for (values in invalid) { - for (task in listOf("producer", "execProcess", "javaProcess")) { - val failed = runner(task).withEnvironment(values).buildAndFail() - assertTrue(failed.output.contains("ciBuilderImage must be a nonblank CI builder identity"), failed.output) - } + for (values in listOf(missing, environment(" "))) { + val failed = runner("produce").withEnvironment(values).buildAndFail() + assertTrue(failed.output.contains("ciBuilderImage must be a nonblank CI builder identity"), failed.output) } } - private fun processFixture() { - fixture() - directory.resolve("src/main/java").toFile().mkdirs() - directory.resolve("src/main/java/Writer.java").toFile().writeText( - """ - import java.nio.charset.StandardCharsets; - import java.nio.file.Files; - import java.nio.file.Path; - import java.nio.file.Paths; - - public class Writer { - public static void main(String[] args) throws Exception { - Path output = Paths.get(args[0]); - Files.createDirectories(output.getParent()); - Files.write(output, "built".getBytes(StandardCharsets.UTF_8)); - } - } - """.trimIndent(), - ) - directory.resolve("build.gradle").toFile().appendText( - """ - - tasks.register('execProcess', Exec) { - inputs.files(sourceSets.main.output) - def artifact = layout.buildDirectory.file('execProcess.txt') - outputs.file(artifact) - commandLine new File(System.getProperty('java.home'), 'bin/java'), - '-cp', sourceSets.main.output.classesDirs.asPath, 'Writer', artifact.get().asFile.absolutePath - } - - tasks.register('javaProcess', JavaExec) { - classpath = sourceSets.main.runtimeClasspath - mainClass = 'Writer' - def artifact = layout.buildDirectory.file('javaProcess.txt') - outputs.file(artifact) - args artifact.get().asFile.absolutePath - } - """.trimIndent(), - ) - } - private fun fixture() { - directory.resolve("settings.gradle").toFile().writeText( + directory.resolve("settings.gradle.kts").toFile().writeText( """ - rootProject.name = 'ci-builder-inputs-fixture' - buildCache { local { directory = file('task-cache') } } + rootProject.name = "ci-builder-inputs-fixture" + buildCache { local { directory = file("task-cache") } } """.trimIndent(), ) - directory.resolve("build.gradle").toFile().writeText( + directory.resolve("build.gradle.kts").toFile().writeText( """ + import org.gradle.api.tasks.testing.Test + plugins { - id 'dd-trace-java.ci-builder-inputs' - id 'java' + base + id("dd-trace-java.ci-builder-inputs") } - dependencies { testImplementation files(${junitClasspath()}) } - test { useJUnitPlatform() } + val identity = ciBuilderInputs.identity + val ci = providers.environmentVariable("GITLAB_CI").orNull == "true" + val automaticTasks = listOf( + tasks.register("test"), + tasks.register("exec"), + tasks.register("javaExec") + ) + tasks.register("inspect") { + doLast { + check(identity.get().isEmpty() == !ci) + val expectedIdentity = if (ci) identity.get() else null + for (task in automaticTasks) { + check(task.get().inputs.properties["ciBuilderIdentity"] == expectedIdentity) + } + } + } @CacheableTask - abstract class Producer extends DefaultTask { - @OutputFile - abstract RegularFileProperty getOutputFile() + abstract class Produce : DefaultTask() { + @get:OutputFile abstract val artifact: RegularFileProperty @TaskAction - void produce() { - outputFile.get().asFile.text = 'built' - } - } - - def producer = tasks.register('producer', Producer) { - group = 'verification' - description = 'Produces an artifact using the builder environment.' - outputFile = layout.buildDirectory.file('producer.txt') + fun produce() { artifact.get().asFile.writeText("built") } } - producer.configure { - inputs.property('ciBuilderIdentity', ciBuilderInputs.identity) - } - """.trimIndent(), - ) - directory.resolve("src/test/java").toFile().mkdirs() - directory.resolve("src/test/java/ExampleTest.java").toFile().writeText( - """ - public class ExampleTest { - @org.junit.jupiter.api.Test public void passes() {} + tasks.register("produce") { + inputs.property("ciBuilderIdentity", identity) + artifact.set(layout.buildDirectory.file("artifact.txt")) } """.trimIndent(), ) } - private fun junitClasspath() = - listOf( - "org.junit.jupiter.api.Test", - "org.junit.jupiter.engine.JupiterTestEngine", - "org.junit.platform.engine.TestEngine", - "org.junit.platform.launcher.Launcher", - "org.junit.platform.commons.JUnitException", - "org.opentest4j.AssertionFailedError", - ).joinToString(", ") { - "\"${Path.of( - Class - .forName(it) - .protectionDomain.codeSource.location - .toURI(), - )}\"" - } - - private fun environment(image: String = "registry.example/builder:ci-") = + private fun environment(image: String = "builder:current") = (System.getenv() - "GITLAB_CI" - "ORG_GRADLE_PROJECT_ciBuilderImage") + mapOf("GITLAB_CI" to "true", "ORG_GRADLE_PROJECT_ciBuilderImage" to image) From 87b52a65834fa0ff21db276020f73a123922c373 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 14:25:53 +0100 Subject: [PATCH 09/14] docs: label Gradle Kotlin DSL snippet --- build-logic/conventions/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-logic/conventions/README.md b/build-logic/conventions/README.md index 839230cb660..05ec6319aeb 100644 --- a/build-logic/conventions/README.md +++ b/build-logic/conventions/README.md @@ -13,7 +13,7 @@ It reads the `ciBuilderImage` Gradle property and adds For other tasks, declare the input explicitly. The provider returns an empty map locally: -```kotlin +```Gradle Kotlin DSL plugins { id("dd-trace-java.ci-builder-inputs") } From 3941728ddf34b085951097a657801b54a00b256d Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 14:30:46 +0100 Subject: [PATCH 10/14] test: remove CI builder convention tests --- build-logic/conventions/build.gradle.kts | 12 -- .../ci/CiBuilderInputsPluginTest.kt | 134 ------------------ 2 files changed, 146 deletions(-) delete mode 100644 build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt diff --git a/build-logic/conventions/build.gradle.kts b/build-logic/conventions/build.gradle.kts index d7fd4ed7993..a31634bb8ec 100644 --- a/build-logic/conventions/build.gradle.kts +++ b/build-logic/conventions/build.gradle.kts @@ -1,17 +1,5 @@ plugins { `kotlin-dsl` - `jvm-test-suite` -} - -testing { - suites { - named("test") { - useJUnitJupiter(libs.versions.junit5) - dependencies { - implementation(gradleTestKit()) - } - } - } } java { diff --git a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt b/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt deleted file mode 100644 index e0183845e30..00000000000 --- a/build-logic/conventions/src/test/kotlin/datadog/buildlogic/ci/CiBuilderInputsPluginTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package datadog.buildlogic.ci - -import org.gradle.testkit.runner.GradleRunner -import org.gradle.testkit.runner.TaskOutcome.FROM_CACHE -import org.gradle.testkit.runner.TaskOutcome.SUCCESS -import org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.nio.file.Path - -class CiBuilderInputsPluginTest { - @TempDir - lateinit var directory: Path - - @Test - fun `builder identity invalidates cached outputs and reuses configuration`() { - fixture() - assertEquals(SUCCESS, runner("produce").build().task(":produce")?.outcome) - - val unchanged = runner("produce").build() - assertEquals(UP_TO_DATE, unchanged.task(":produce")?.outcome) - assertTrue(unchanged.output.contains("Reusing configuration cache"), unchanged.output) - assertEquals(FROM_CACHE, runner("clean", "produce").build().task(":produce")?.outcome) - - val changed = runner("produce").withEnvironment(environment("builder:next")).build() - assertEquals(SUCCESS, changed.task(":produce")?.outcome) - assertEquals("built", directory.resolve("build/artifact.txt").toFile().readText()) - } - - @Test - fun `automatic inputs apply in CI and local identity is empty`() { - fixture() - assertEquals(SUCCESS, runner("inspect", configurationCache = false).build().task(":inspect")?.outcome) - val local = environment("") - "GITLAB_CI" - assertEquals( - SUCCESS, - runner("inspect", "produce", configurationCache = false) - .withEnvironment(local) - .build() - .task(":inspect") - ?.outcome, - ) - } - - @Test - fun `missing and blank identities fail only when inputs are queried`() { - fixture() - val missing = environment() - "ORG_GRADLE_PROJECT_ciBuilderImage" - assertEquals( - SUCCESS, - runner("help") - .withEnvironment(missing) - .build() - .task(":help") - ?.outcome, - ) - for (values in listOf(missing, environment(" "))) { - val failed = runner("produce").withEnvironment(values).buildAndFail() - assertTrue(failed.output.contains("ciBuilderImage must be a nonblank CI builder identity"), failed.output) - } - } - - private fun fixture() { - directory.resolve("settings.gradle.kts").toFile().writeText( - """ - rootProject.name = "ci-builder-inputs-fixture" - buildCache { local { directory = file("task-cache") } } - """.trimIndent(), - ) - directory.resolve("build.gradle.kts").toFile().writeText( - """ - import org.gradle.api.tasks.testing.Test - - plugins { - base - id("dd-trace-java.ci-builder-inputs") - } - - val identity = ciBuilderInputs.identity - val ci = providers.environmentVariable("GITLAB_CI").orNull == "true" - val automaticTasks = listOf( - tasks.register("test"), - tasks.register("exec"), - tasks.register("javaExec") - ) - tasks.register("inspect") { - doLast { - check(identity.get().isEmpty() == !ci) - val expectedIdentity = if (ci) identity.get() else null - for (task in automaticTasks) { - check(task.get().inputs.properties["ciBuilderIdentity"] == expectedIdentity) - } - } - } - - @CacheableTask - abstract class Produce : DefaultTask() { - @get:OutputFile abstract val artifact: RegularFileProperty - - @TaskAction - fun produce() { artifact.get().asFile.writeText("built") } - } - tasks.register("produce") { - inputs.property("ciBuilderIdentity", identity) - artifact.set(layout.buildDirectory.file("artifact.txt")) - } - """.trimIndent(), - ) - } - - private fun environment(image: String = "builder:current") = - (System.getenv() - "GITLAB_CI" - "ORG_GRADLE_PROJECT_ciBuilderImage") + - mapOf("GITLAB_CI" to "true", "ORG_GRADLE_PROJECT_ciBuilderImage" to image) - - private fun runner( - vararg arguments: String, - configurationCache: Boolean = true, - ): GradleRunner = - GradleRunner - .create() - .withProjectDir(directory.toFile()) - .withPluginClasspath() - .withEnvironment(environment()) - .withArguments( - *arguments, - "--build-cache", - if (configurationCache) "--configuration-cache" else "--no-configuration-cache", - "--stacktrace", - "--max-workers=2", - "-Dorg.gradle.jvmargs=-Xmx512m", - ) -} From 3cfcf41af59ccdfe1d9af0ab63ace0dadf30b048 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 10:55:32 +0100 Subject: [PATCH 11/14] chore: apply builder image fingerprints in CI --- .gitlab-ci.yml | 2 ++ dd-java-agent/agent-ci-visibility/build.gradle | 1 + .../agent-profiling/profiling-controller-ddprof/build.gradle | 1 + dd-java-agent/cws-tls/build.gradle | 1 + dd-smoke-tests/quarkus-native/build.gradle | 2 ++ dd-smoke-tests/spring-boot-3.0-native/build.gradle | 2 ++ utils/container-utils/build.gradle.kts | 1 + utils/test-utils/build.gradle.kts | 1 + 8 files changed, 11 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 520cd6ab224..6a21e09634f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,6 +62,8 @@ variables: GRADLE_PLUGIN_PROXY: "https://depot-read-api-java.us1.ddbuild.io/magicmirror/magicmirror/@current/" BUILDER_IMAGE_REPO: "registry.ddbuild.io/images/mirror/dd-trace-java-docker-build" # images are pinned in images/mirror.lock.yaml in the ddoghq/images repo BUILDER_IMAGE_VERSION_PREFIX: "ci-" # use either an empty string (e.g. "") for latest images or a version followed by a hyphen (e.g. "ci-" or "123_merge-") + # shortcut: tag republishing is not detected; use a content hash when CI builds the image. + ORG_GRADLE_PROJECT_ciBuilderImage: "${BUILDER_IMAGE_REPO}:${BUILDER_IMAGE_VERSION_PREFIX}" TEST_COUNTS_S3_BUCKET: "dd-trace-java-ci-test-reports" REPO_NOTIFICATION_CHANNEL: "#apm-java-escalations" DEFAULT_TEST_JVMS: /^(8|11|17|21|25|tip)$/ # the latest "tip" version is 27 diff --git a/dd-java-agent/agent-ci-visibility/build.gradle b/dd-java-agent/agent-ci-visibility/build.gradle index 375df7c0315..f083beb6827 100644 --- a/dd-java-agent/agent-ci-visibility/build.gradle +++ b/dd-java-agent/agent-ci-visibility/build.gradle @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { + id 'dd-trace-java.ci-builder-inputs' id 'com.gradleup.shadow' id 'org.jetbrains.kotlin.jvm' id 'dd-trace-java.version-file' diff --git a/dd-java-agent/agent-profiling/profiling-controller-ddprof/build.gradle b/dd-java-agent/agent-profiling/profiling-controller-ddprof/build.gradle index 1b31128050a..3e141234ad9 100644 --- a/dd-java-agent/agent-profiling/profiling-controller-ddprof/build.gradle +++ b/dd-java-agent/agent-profiling/profiling-controller-ddprof/build.gradle @@ -1,4 +1,5 @@ plugins { + id 'dd-trace-java.ci-builder-inputs' id 'dd-trace-java.module.internal-library' } diff --git a/dd-java-agent/cws-tls/build.gradle b/dd-java-agent/cws-tls/build.gradle index 3b20009adaf..9126b87e1f0 100644 --- a/dd-java-agent/cws-tls/build.gradle +++ b/dd-java-agent/cws-tls/build.gradle @@ -2,6 +2,7 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import org.apache.maven.model.License plugins { + id 'dd-trace-java.ci-builder-inputs' id 'com.gradleup.shadow' id 'dd-trace-java.version-file' id 'dd-trace-java.module.product-subsystem' diff --git a/dd-smoke-tests/quarkus-native/build.gradle b/dd-smoke-tests/quarkus-native/build.gradle index dc2c747fd46..3d2b749c8b5 100644 --- a/dd-smoke-tests/quarkus-native/build.gradle +++ b/dd-smoke-tests/quarkus-native/build.gradle @@ -2,6 +2,7 @@ import java.util.concurrent.Callable import java.util.regex.Pattern plugins { + id 'dd-trace-java.ci-builder-inputs' id 'dd-trace-java.smoke-test-app' id 'dd-trace-java.module.smoke-test' } @@ -46,6 +47,7 @@ smokeTestApp { environment.put('GRAALVM_HOME', graalvmHome) inputs { property('graalvmRelease', fileContent(graalvmHome, 'release')) + property('ciBuilderIdentity', ciBuilderInputs.identity) } } projectJar('apiJar', project(':dd-trace-api')) diff --git a/dd-smoke-tests/spring-boot-3.0-native/build.gradle b/dd-smoke-tests/spring-boot-3.0-native/build.gradle index 2424d22e6ad..55a95194616 100644 --- a/dd-smoke-tests/spring-boot-3.0-native/build.gradle +++ b/dd-smoke-tests/spring-boot-3.0-native/build.gradle @@ -2,6 +2,7 @@ import java.util.concurrent.Callable import java.util.regex.Pattern plugins { + id 'dd-trace-java.ci-builder-inputs' id 'dd-trace-java.smoke-test-app' id 'dd-trace-java.module.smoke-test' } @@ -44,6 +45,7 @@ smokeTestApp { environment.put('NATIVE_IMAGE_DEPRECATED_BUILDER_SANITATION', 'true') inputs { property('graalvmRelease', fileContent(graalvmHome, 'release')) + property('ciBuilderIdentity', ciBuilderInputs.identity) } } projectJar('apiJar', project(':dd-trace-api')) diff --git a/utils/container-utils/build.gradle.kts b/utils/container-utils/build.gradle.kts index f4b3ff6e862..16195e483d0 100644 --- a/utils/container-utils/build.gradle.kts +++ b/utils/container-utils/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + id("dd-trace-java.ci-builder-inputs") `java-library` id("dd-trace-java.module.internal-library") } diff --git a/utils/test-utils/build.gradle.kts b/utils/test-utils/build.gradle.kts index bb2a07b6da7..0e3a238295d 100644 --- a/utils/test-utils/build.gradle.kts +++ b/utils/test-utils/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + id("dd-trace-java.ci-builder-inputs") `java-library` id("dd-trace-java.module.internal-library") } From a90cc779522e176d2a1d99ce71d406e8a17eda6a Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 15:04:40 +0100 Subject: [PATCH 12/14] docs: explain CI builder input consumers --- dd-java-agent/agent-ci-visibility/build.gradle | 1 + .../agent-profiling/profiling-controller-ddprof/build.gradle | 1 + dd-java-agent/cws-tls/build.gradle | 1 + dd-smoke-tests/quarkus-native/build.gradle | 1 + dd-smoke-tests/spring-boot-3.0-native/build.gradle | 1 + utils/container-utils/build.gradle.kts | 1 + utils/test-utils/build.gradle.kts | 1 + 7 files changed, 7 insertions(+) diff --git a/dd-java-agent/agent-ci-visibility/build.gradle b/dd-java-agent/agent-ci-visibility/build.gradle index f083beb6827..2327b8d601c 100644 --- a/dd-java-agent/agent-ci-visibility/build.gradle +++ b/dd-java-agent/agent-ci-visibility/build.gradle @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { + // ShellCommandExecutorTest invokes the image's echo, cat and sleep commands. id 'dd-trace-java.ci-builder-inputs' id 'com.gradleup.shadow' id 'org.jetbrains.kotlin.jvm' diff --git a/dd-java-agent/agent-profiling/profiling-controller-ddprof/build.gradle b/dd-java-agent/agent-profiling/profiling-controller-ddprof/build.gradle index 3e141234ad9..354dfc5d348 100644 --- a/dd-java-agent/agent-profiling/profiling-controller-ddprof/build.gradle +++ b/dd-java-agent/agent-profiling/profiling-controller-ddprof/build.gradle @@ -1,4 +1,5 @@ plugins { + // Tests load the native profiler selected for the image's libc variant. id 'dd-trace-java.ci-builder-inputs' id 'dd-trace-java.module.internal-library' } diff --git a/dd-java-agent/cws-tls/build.gradle b/dd-java-agent/cws-tls/build.gradle index 9126b87e1f0..c7b7357ed8f 100644 --- a/dd-java-agent/cws-tls/build.gradle +++ b/dd-java-agent/cws-tls/build.gradle @@ -2,6 +2,7 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import org.apache.maven.model.License plugins { + // CWS tests exercise JNA native memory and the image's native runtime. id 'dd-trace-java.ci-builder-inputs' id 'com.gradleup.shadow' id 'dd-trace-java.version-file' diff --git a/dd-smoke-tests/quarkus-native/build.gradle b/dd-smoke-tests/quarkus-native/build.gradle index 3d2b749c8b5..f139cd30d1d 100644 --- a/dd-smoke-tests/quarkus-native/build.gradle +++ b/dd-smoke-tests/quarkus-native/build.gradle @@ -2,6 +2,7 @@ import java.util.concurrent.Callable import java.util.regex.Pattern plugins { + // Native smoke builds use GraalVM and native tools from the builder environment. id 'dd-trace-java.ci-builder-inputs' id 'dd-trace-java.smoke-test-app' id 'dd-trace-java.module.smoke-test' diff --git a/dd-smoke-tests/spring-boot-3.0-native/build.gradle b/dd-smoke-tests/spring-boot-3.0-native/build.gradle index 55a95194616..7ad10fc9e99 100644 --- a/dd-smoke-tests/spring-boot-3.0-native/build.gradle +++ b/dd-smoke-tests/spring-boot-3.0-native/build.gradle @@ -2,6 +2,7 @@ import java.util.concurrent.Callable import java.util.regex.Pattern plugins { + // Native smoke builds use GraalVM and native tools from the builder environment. id 'dd-trace-java.ci-builder-inputs' id 'dd-trace-java.smoke-test-app' id 'dd-trace-java.module.smoke-test' diff --git a/utils/container-utils/build.gradle.kts b/utils/container-utils/build.gradle.kts index 16195e483d0..60c3eb25603 100644 --- a/utils/container-utils/build.gradle.kts +++ b/utils/container-utils/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + // ContainerInfoTest invokes the image's ls -id command. id("dd-trace-java.ci-builder-inputs") `java-library` id("dd-trace-java.module.internal-library") diff --git a/utils/test-utils/build.gradle.kts b/utils/test-utils/build.gradle.kts index 0e3a238295d..32bc00c7a1b 100644 --- a/utils/test-utils/build.gradle.kts +++ b/utils/test-utils/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + // PortableCommandTest invokes the image's echo, cat, sleep and tail commands. id("dd-trace-java.ci-builder-inputs") `java-library` id("dd-trace-java.module.internal-library") From 48e74b44a3b45d193f47d8cce0d52602048a23c0 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Thu, 29 Oct 2026 12:56:44 +0100 Subject: [PATCH 13/14] fix: use the common CI flag for builder inputs --- build-logic/conventions/README.md | 2 +- .../datadog/buildlogic/ci/CiBuilderInputsExtension.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build-logic/conventions/README.md b/build-logic/conventions/README.md index 05ec6319aeb..0683a5b133e 100644 --- a/build-logic/conventions/README.md +++ b/build-logic/conventions/README.md @@ -1,7 +1,7 @@ # CI builder image inputs The `dd-trace-java.ci-builder-inputs` convention tracks the CI builder image identity -in Gradle build cache keys. It is enabled only in GitLab CI (`GITLAB_CI=true`). +in Gradle build cache keys. It is enabled only in CI (`CI=true`). It reads the `ciBuilderImage` Gradle property and adds `ciBuilderIdentity` inputs to `Test`, `Exec` and `JavaExec` tasks in projects that apply it. diff --git a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt index 6974e05bbdc..24a47fe96e0 100644 --- a/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt +++ b/build-logic/conventions/src/main/kotlin/datadog/buildlogic/ci/CiBuilderInputsExtension.kt @@ -13,14 +13,14 @@ open class CiBuilderInputsExtension providers: ProviderFactory, ) { // CI selection changes task configuration, so it is a configuration-cache input. - private val enabled = providers.environmentVariable("GITLAB_CI").orNull == "true" + private val enabled = providers.environmentVariable("CI").orNull == "true" - /** Builder identity and platform in GitLab CI; an empty map in local builds. */ + /** Builder identity and platform in CI; an empty map in local builds. */ val identity: Provider> = if (enabled) { providers.gradleProperty("ciBuilderImage").orElse("").map { image -> if (image.isBlank()) { - throw GradleException("ciBuilderImage must be a nonblank CI builder identity when GITLAB_CI=true") + throw GradleException("ciBuilderImage must be a nonblank CI builder identity when CI=true") } mapOf( "image" to image, From 5ed610c39091420101f8f22dfd7b37d60b8fd1f6 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Fri, 30 Oct 2026 12:36:59 +0100 Subject: [PATCH 14/14] chore: narrow CI builder image inputs --- dd-java-agent/agent-ci-visibility/build.gradle | 2 +- dd-java-agent/cws-tls/build.gradle | 2 -- docs/how_to_work_with_gradle.md | 12 ++++++++++++ utils/container-utils/build.gradle.kts | 2 -- utils/test-utils/build.gradle.kts | 2 -- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/dd-java-agent/agent-ci-visibility/build.gradle b/dd-java-agent/agent-ci-visibility/build.gradle index 2327b8d601c..91d07ab4efa 100644 --- a/dd-java-agent/agent-ci-visibility/build.gradle +++ b/dd-java-agent/agent-ci-visibility/build.gradle @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { - // ShellCommandExecutorTest invokes the image's echo, cat and sleep commands. + // Git tests generate and read pack files using the image's Git executable. id 'dd-trace-java.ci-builder-inputs' id 'com.gradleup.shadow' id 'org.jetbrains.kotlin.jvm' diff --git a/dd-java-agent/cws-tls/build.gradle b/dd-java-agent/cws-tls/build.gradle index c7b7357ed8f..3b20009adaf 100644 --- a/dd-java-agent/cws-tls/build.gradle +++ b/dd-java-agent/cws-tls/build.gradle @@ -2,8 +2,6 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import org.apache.maven.model.License plugins { - // CWS tests exercise JNA native memory and the image's native runtime. - id 'dd-trace-java.ci-builder-inputs' id 'com.gradleup.shadow' id 'dd-trace-java.version-file' id 'dd-trace-java.module.product-subsystem' diff --git a/docs/how_to_work_with_gradle.md b/docs/how_to_work_with_gradle.md index aca5060e459..476061e3c45 100644 --- a/docs/how_to_work_with_gradle.md +++ b/docs/how_to_work_with_gradle.md @@ -386,6 +386,18 @@ tasks.register("processData") { > Prefer annotations in custom task classes for better type safety and documentation. > Use the programmatic API for ad-hoc tasks registered with `tasks.register`. +#### CI Builder Image Inputs + +Apply the [`dd-trace-java.ci-builder-inputs` convention](../build-logic/conventions/README.md) +when a module's cacheable tasks depend on the CI image's native toolchain, runtime libraries, +or tool-specific output, such as Git pack generation. Routine uses of `cat`, `echo`, `ls` +or `sleep` alone do not justify invalidating tasks on every image change. + +The convention adds the builder identity to `Test`, `Exec` and `JavaExec` inputs when +`CI=true`. Other task types, including native smoke producers, declare the identity +explicitly. See the convention reference for the CI property and input declarations. +Local builds require no configuration. + #### Lazy Configuration with Providers Gradle uses **lazy configuration** to defer value resolution until needed. Instead of setting values directly, you use `Property` and `Provider` types: diff --git a/utils/container-utils/build.gradle.kts b/utils/container-utils/build.gradle.kts index 60c3eb25603..f4b3ff6e862 100644 --- a/utils/container-utils/build.gradle.kts +++ b/utils/container-utils/build.gradle.kts @@ -1,6 +1,4 @@ plugins { - // ContainerInfoTest invokes the image's ls -id command. - id("dd-trace-java.ci-builder-inputs") `java-library` id("dd-trace-java.module.internal-library") } diff --git a/utils/test-utils/build.gradle.kts b/utils/test-utils/build.gradle.kts index 32bc00c7a1b..bb2a07b6da7 100644 --- a/utils/test-utils/build.gradle.kts +++ b/utils/test-utils/build.gradle.kts @@ -1,6 +1,4 @@ plugins { - // PortableCommandTest invokes the image's echo, cat, sleep and tail commands. - id("dd-trace-java.ci-builder-inputs") `java-library` id("dd-trace-java.module.internal-library") }