From 845c485d627e18a3c4a442f47a2bbb3874260bf3 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Fri, 3 Jul 2026 20:37:07 +0200 Subject: [PATCH 1/6] ci: Catch build issue by comparing release artifacts with jardiff after build job The new job adds a `compareToReferenceJar` task (via a new `dd-trace-java.jardiff` plugin) that runs jardiff `--stat --exit-code` against a reference jar (the one from `build` job) and fails on any difference, there should be none since the jar being rebuilt is coming from the same commit in a pipeline. --- .gitlab-ci.yml | 17 ++ buildSrc/build.gradle.kts | 5 + .../plugin/jardiff/JardiffComparison.kt | 84 ++++++ .../gradle/plugin/jardiff/JardiffExtension.kt | 31 ++ .../gradle/plugin/jardiff/JardiffPlugin.kt | 96 ++++++ .../gradle/plugin/jardiff/JardiffTask.kt | 226 +++++++++++++++ .../plugin/jardiff/JardiffComparisonTest.kt | 95 ++++++ .../jardiff/JardiffTaskIntegrationTest.kt | 274 ++++++++++++++++++ gradle/publish.gradle | 1 + .../feature-flagging-api/build.gradle.kts | 6 + 10 files changed, 835 insertions(+) create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffComparison.kt create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt create mode 100644 buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt create mode 100644 buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffComparisonTest.kt create mode 100644 buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 99cb7ad562c..81a13d1fabb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -495,6 +495,23 @@ test_published_artifacts: paths: - ./check_reports +validate_build: + extends: .gradle_build + stage: tests + needs: [ build ] + variables: + CACHE_TYPE: "lib" + script: + # Preserve the `build` job artifacts before Gradle rebuilds and overwrites them under + # workspace/**/build/libs, so jardiff can compare the rebuilt jars against them. + - mkdir -p reference-artifacts + - cp workspace/dd-java-agent/build/libs/*.jar reference-artifacts/ + - cp workspace/dd-trace-api/build/libs/*.jar reference-artifacts/ + - cp workspace/dd-trace-ot/build/libs/*.jar reference-artifacts/ + - ./gradlew --version + # This will run the shadowJar task and exercise the build cache, allowing to identify build-cache issues + - ./gradlew compareToReferenceJar -PjardiffReferenceDir="$CI_PROJECT_DIR/reference-artifacts" -PskipTests $GRADLE_ARGS + .check_job: extends: .gradle_build needs: [ build ] diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 441a90dcba0..991ca899596 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -73,6 +73,11 @@ gradlePlugin { id = "dd-trace-java.sca-enrichments" implementationClass = "datadog.gradle.plugin.sca.ScaEnrichmentsPlugin" } + + create("jardiff-plugin") { + id = "dd-trace-java.jardiff" + implementationClass = "datadog.gradle.plugin.jardiff.JardiffPlugin" + } } } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffComparison.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffComparison.kt new file mode 100644 index 00000000000..4be130aabe0 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffComparison.kt @@ -0,0 +1,84 @@ +package datadog.gradle.plugin.jardiff + +import java.io.File + +/** + * Pure, Gradle-free helpers for driving the [jardiff](https://github.com/bric3/jardiff) CLI. + * + * Kept separate from [JardiffTask] so the argument construction and exit-code interpretation can + * be unit-tested without spinning up a Gradle build or resolving the jardiff jar. + */ +object JardiffComparison { + /** Outcome of a jardiff run, derived from its process exit value. */ + enum class Outcome { + /** Exit 0 — the two jars are identical (for the selected include/exclude set). */ + IDENTICAL, + + /** Exit 1 — jardiff reported differences (behaves like `diff(1)` under `--exit-code`). */ + DIFFERENT, + + /** Any other exit value — jardiff itself failed (bad arguments, unreadable jar, ...). */ + ERROR, + } + + /** + * Builds the jardiff argument list comparing [reference] (left) against [candidate] (right). + * + * [mode] selects the output mode (e.g. `--stat` for a `git diff --stat`-like summary, `--status`, + * or blank for the default full diff). `--exit-code` is always added. + * The [JardiffTask] relies on the process exit value. + * [includes]/[excludes] are comma-joined glob patterns (empty means comparing every entry), and + * [additionalOptions] are passed through verbatim, right before the two jars. + */ + fun buildArguments( + reference: File, + candidate: File, + mode: String, + includes: List = emptyList(), + excludes: List = emptyList(), + additionalOptions: List = emptyList(), + ): List = buildList { + if (mode.isNotBlank()) { + add(mode) + } + add("--exit-code") + add("--color=never") + if (includes.isNotEmpty()) { + add("--include=" + includes.joinToString(",")) + } + if (excludes.isNotEmpty()) { + add("--exclude=" + excludes.joinToString(",")) + } + addAll(additionalOptions) + // jardiff positional arguments: . + // The reference (the artifact validated by the build job) is the left/baseline side, + // the freshly built candidate is the right side. + add(reference.absolutePath) + add(candidate.absolutePath) + } + + /** Maps a jardiff process exit value to an [Outcome]. */ + fun outcomeOf(exitValue: Int): Outcome = when (exitValue) { + 0 -> Outcome.IDENTICAL + 1 -> Outcome.DIFFERENT + else -> Outcome.ERROR + } + + /** + * Renders the equivalent `java -cp … ` shell command, so the comparison + * can be copy-pasted and reproduced outside Gradle. Tokens containing shell-significant + * characters (spaces, globs, commas, ...) are single-quoted. + */ + fun shellCommandLine(classpath: Iterable, mainClass: String, arguments: List): String { + val classpathValue = classpath.joinToString(File.pathSeparator) { it.absolutePath } + return (listOf("java", "-cp", classpathValue, mainClass) + arguments) + .joinToString(" ", transform = ::shellQuote) + } + + private fun shellQuote(token: String): String = + if (token.isNotEmpty() && token.all { it.isLetterOrDigit() || it in "/._-=:" }) { + token + } else { + "'" + token.replace("'", "'\\''") + "'" + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt new file mode 100644 index 00000000000..f8837f66c47 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt @@ -0,0 +1,31 @@ +package datadog.gradle.plugin.jardiff + +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** Configuration for the [JardiffPlugin]. */ +interface JardiffExtension { + /** + * Maven coordinate of the jardiff CLI resolved to run the comparison. + * Defaults to [JardiffPlugin.DEFAULT_TOOL_COORDINATE]. + */ + val toolCoordinate: Property + + /** + * Fully qualified main class of the jardiff CLI. + * Defaults to [JardiffPlugin.DEFAULT_MAIN_CLASS]. + */ + val mainClass: Property + + /** + * jardiff output mode flag, e.g. `--stat` or `--status`; blank selects the default full diff. + * Defaults to [JardiffPlugin.DEFAULT_MODE]. + */ + val mode: Property + + /** + * Extra jardiff options passed verbatim, right before the two jars (e.g. `--ignore-member-order` + * or `--class-text-producer=javap`). Empty by default. + */ + val additionalOptions: ListProperty +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt new file mode 100644 index 00000000000..19dd48e8098 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt @@ -0,0 +1,96 @@ +package datadog.gradle.plugin.jardiff + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.bundling.AbstractArchiveTask +import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.named +import org.gradle.kotlin.dsl.register + +/** + * Registers the `compareToReferenceJar` task ([JardiffTask]) and wires its reusable inputs: + * - the jardiff CLI classpath (resolved from [JardiffExtension.toolCoordinate]), + * - the candidate archive — the module's main publishable jar (the shadow jar when the shadow + * plugin is applied, otherwise the plain jar), + * - the reference jar, resolved from the `-PjardiffReferenceDir` project property by matching the + * candidate's file name inside that directory. + * + * Apply it with `id("dd-trace-java.jardiff")`. + */ +class JardiffPlugin : Plugin { + override fun apply(project: Project) { + val extension = project.extensions.create("jardiff") + extension.toolCoordinate.convention(DEFAULT_TOOL_COORDINATE) + extension.mainClass.convention(DEFAULT_MAIN_CLASS) + extension.mode.convention(DEFAULT_MODE) + extension.additionalOptions.convention(emptyList()) + + // Use a detached configuration (created here, resolved only when the task runs) + // + // This keeps the jardiff artifact out of `lockAllConfigurations()` dependency locking. + // The dependency is added lazily, so overriding `jardiff.toolCoordinate` still takes effect. + // The `@jar` requests the artifact only, because jardiff ships a self-contained "fat" CLI jar + // under a non-default Gradle Module Metadata variant, the default resolution misses it. + // Appending `@jar` ignores metadata and fetches that jar. + val toolClasspath = project.configurations.detachedConfiguration().apply { + dependencies.addLater( + extension.toolCoordinate.map { coordinate -> + val artifactOnly = if ('@' in coordinate) coordinate else "$coordinate@jar" + project.dependencies.create(artifactOnly) + }, + ) + } + + val projectDirectory = project.layout.projectDirectory + val referenceDirProperty = + project.providers.gradleProperty("jardiffReferenceDir").filter { it.isNotBlank() } + + val compare = project.tasks.register(COMPARE_TASK_NAME) { + group = "verification" + description = "Compares the built jar against a reference jar (typically the CI `build` " + + "job artifact) using jardiff, failing if they differ. Set the reference with " + + "--reference-jar= or -PjardiffReferenceDir=." + jardiffClasspath.convention(toolClasspath) + mainClass.convention(extension.mainClass) + mode.convention(extension.mode) + additionalOptions.convention(extension.additionalOptions) + // Ignore **/*.version by default, except under CI where the build and deploy + // jobs share the same commit. + ignoreVersionFiles.convention( + project.providers.environmentVariable("CI").map { false }.orElse(true), + ) + reportFile.convention(project.layout.buildDirectory.file("reports/jardiff/comparison.txt")) + referenceJar.convention( + referenceDirProperty.flatMap { dir -> + candidateJar.map { candidate -> projectDirectory.dir(dir).file(candidate.asFile.name) } + }, + ) + } + + // candidateJar = the module's main publishable archive + project.pluginManager.withPlugin("java") { + compare.configure { + candidateJar.convention( + project.tasks.named("jar").flatMap { it.archiveFile }, + ) + } + } + project.pluginManager.withPlugin("com.gradleup.shadow") { + compare.configure { + candidateJar.set( + project.tasks.named("shadowJar").flatMap { it.archiveFile }, + ) + } + } + } + + companion object { + const val DEFAULT_TOOL_COORDINATE = "io.github.bric3.jardiff:jardiff-cli:0.2.0" + + const val DEFAULT_MAIN_CLASS = "io.github.bric3.jardiff.app.Main" + + const val DEFAULT_MODE = "--stat" + + const val COMPARE_TASK_NAME = "compareToReferenceJar" + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt new file mode 100644 index 00000000000..2f18509a030 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt @@ -0,0 +1,226 @@ +package datadog.gradle.plugin.jardiff + +import java.io.ByteArrayOutputStream +import java.io.File +import javax.inject.Inject +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.options.Option +import org.gradle.process.ExecOperations + +/** + * Compares a freshly built jar against a reference jar using the + * [jardiff](https://github.com/bric3/jardiff) CLI and fails the build if they differ. + * + * This guards Maven Central publication against non-deterministic rebuilds: the artifact about to + * be published (that was possibly rebuilt with faults) is compared against a reference artifact. + * + * The reference jar is resolved, in order of precedence, from: + * 1. the `--reference-jar=` command-line option (handy to compare a single artifact locally + * before publishing), then + * 2. the [referenceJar] property (wired by the `dd-trace-java.jardiff` plugin from the + * `-PjardiffReferenceDir` project property by matching the built jar's file name in that + * directory). + * + * The comparison is mandatory: once the task runs, a missing reference fails the build, so a + * dropped CI property or a forgotten `--reference-jar` cannot silently publish an unverified jar. + * Modules that have no reference artifact to compare can disable the task instead + * (`compareToReferenceJar { enabled = false }`). + */ +abstract class JardiffTask @Inject constructor( + private val execOperations: ExecOperations, +) : DefaultTask() { + + /** The freshly built jar to validate (the main publication artifact). */ + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val candidateJar: RegularFileProperty + + /** + * The reference jar to compare against. Optional; usually wired from `-PjardiffReferenceDir`. + * Kept [Internal] (the task always re-runs, see the `upToDateWhen { false }` below) so a missing + * reference produces a clear error message rather than a generic input-validation failure. + */ + @get:Internal + abstract val referenceJar: RegularFileProperty + + /** + * Command-line override for the reference jar path; takes precedence over [referenceJar]. + * A relative path is resolved against the current working directory (the Gradle invocation + * directory), matching how CLI users expect paths to behave. + */ + @get:Input + @get:Optional + @get:Option( + option = "reference-jar", + description = "Path to the reference jar to compare the built jar against " + + "(typically the artifact produced by the CI `build` job).", + ) + abstract val referenceJarPath: Property + + /** Runtime classpath hosting the jardiff CLI ([mainClass]). */ + @get:Classpath + abstract val jardiffClasspath: ConfigurableFileCollection + + /** Glob patterns of entries to include in the comparison; empty means compare everything. */ + @get:Input + abstract val includes: ListProperty + + /** Glob patterns of entries to exclude from the comparison. */ + @get:Input + abstract val excludes: ListProperty + + /** + * jardiff output mode flag (e.g. `--stat`, `--status`); blank selects the default full diff. + * Defaulted by the plugin; overridable on the command line. + */ + @get:Input + @get:Option( + option = "mode", + description = "jardiff output mode flag, e.g. --mode=--status (blank selects the default full " + + "diff). Overrides the jardiff extension.", + ) + abstract val mode: Property + + /** + * Extra jardiff options passed verbatim, right before the two jars (e.g. `--ignore-member-order`). + * Defaulted by the plugin; overridable on the command line (repeatable). + */ + @get:Input + @get:Option( + option = "jardiff-option", + description = "Additional jardiff option passed verbatim; repeatable, e.g. " + + "--jardiff-option=--ignore-member-order. Overrides the jardiff extension.", + ) + abstract val additionalOptions: ListProperty + + /** + * When true, the `.version` files entries are excluded from the comparison. + * This is useful in particular as those files can be part of runtimeClasspath normalization + * which ignores them too. `false` under CI, and true otherwise; overridable on the command line. + */ + @get:Input + @get:Option( + option = "ignore-version-files", + description = "Exclude **/*.version entries (volatile git-hash stamps) from the comparison.", + ) + abstract val ignoreVersionFiles: Property + + /** Fully qualified main class of the jardiff CLI (defaulted by the plugin). Overridable for testing. */ + @get:Input + abstract val mainClass: Property + + /** Destination of the captured jardiff report. */ + @get:OutputFile + abstract val reportFile: RegularFileProperty + + init { + includes.convention(emptyList()) + excludes.convention(emptyList()) + // This task is a publication gate, and as such must never be skipped as "up-to-date": + // i.e. always re-run so a divergent rebuild cannot slip through on a stale execution history. + outputs.upToDateWhen { false } + } + + @TaskAction + fun compare() { + val reference = resolveReferenceJar() + ?: throw GradleException( + "No reference jar configured to compare the built jar against.\n" + + "Provide one via --reference-jar= or -PjardiffReferenceDir= (the directory " + + "holding the `build` job artifacts), or disable this task for modules with no reference.", + ) + if (!reference.isFile) { + throw GradleException( + "Reference jar does not exist: ${reference.absolutePath}\n" + + "Pass an existing jar via --reference-jar= or point -PjardiffReferenceDir at the " + + "directory holding the `build` job artifacts.", + ) + } + val candidate = candidateJar.get().asFile + + val effectiveExcludes = buildList { + addAll(excludes.get()) + if (ignoreVersionFiles.get()) { + add("**/*.version") + } + } + val arguments = JardiffComparison.buildArguments( + reference = reference, + candidate = candidate, + mode = mode.get(), + includes = includes.get(), + excludes = effectiveExcludes, + additionalOptions = additionalOptions.get(), + ) + + val toolClasspath = jardiffClasspath + val mainClassName = mainClass.get() + logger.info( + "jardiff command: {}", + JardiffComparison.shellCommandLine(toolClasspath.files, mainClassName, arguments), + ) + val captured = ByteArrayOutputStream() + val execResult = execOperations.javaexec { + classpath = toolClasspath + mainClass.set(mainClassName) + args(arguments) + standardOutput = captured + errorOutput = captured + isIgnoreExitValue = true + } + + val report = captured.toString("UTF-8") + val reportDestination = reportFile.get().asFile + reportDestination.parentFile?.mkdirs() + reportDestination.writeText(report) + + when (JardiffComparison.outcomeOf(execResult.exitValue)) { + JardiffComparison.Outcome.IDENTICAL -> + logger.lifecycle("✓ ${candidate.name} is identical to the reference jar ${reference.name}") + + JardiffComparison.Outcome.DIFFERENT -> + throw GradleException( + buildString { + appendLine("Built jar differs from the reference jar.") + appendLine("TODO: inspect build") + appendLine() + appendLine(" candidate : ${candidate.absolutePath}") + appendLine(" reference : ${reference.absolutePath}") + appendLine(" report : ${reportDestination.absolutePath}") + appendLine() + appendLine("jardiff report:") + append(report.ifBlank { "(no output captured)" }) + }, + ) + + JardiffComparison.Outcome.ERROR -> + throw GradleException( + "jardiff failed with exit code ${execResult.exitValue} while comparing " + + "${candidate.name} against ${reference.name}. Output:\n" + + report.ifBlank { "(no output captured)" }, + ) + } + } + + private fun resolveReferenceJar(): File? { + referenceJarPath.orNull?.takeIf { it.isNotBlank() }?.let { return File(it) } + if (referenceJar.isPresent) { + return referenceJar.get().asFile + } + return null + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffComparisonTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffComparisonTest.kt new file mode 100644 index 00000000000..fded087aefd --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffComparisonTest.kt @@ -0,0 +1,95 @@ +package datadog.gradle.plugin.jardiff + +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class JardiffComparisonTest { + + private val reference = File("/tmp/reference/dd-java-agent-1.0.0.jar") + private val candidate = File("/tmp/candidate/dd-java-agent-1.0.0.jar") + + @Test + fun `builds stat comparison arguments with the reference on the left`() { + val arguments = JardiffComparison.buildArguments(reference, candidate, mode = "--stat") + + assertThat(arguments).containsExactly( + "--stat", + "--exit-code", + "--color=never", + reference.absolutePath, + candidate.absolutePath, + ) + } + + @Test + fun `joins include patterns with commas before the positional arguments`() { + val arguments = JardiffComparison.buildArguments( + reference, + candidate, + mode = "--stat", + includes = listOf("**/*.class", "META-INF/**"), + ) + + assertThat(arguments).containsExactly( + "--stat", + "--exit-code", + "--color=never", + "--include=**/*.class,META-INF/**", + reference.absolutePath, + candidate.absolutePath, + ) + } + + @Test + fun `joins exclude patterns with commas`() { + val arguments = JardiffComparison.buildArguments( + reference, + candidate, + mode = "--stat", + excludes = listOf("**/*.txt"), + ) + + assertThat(arguments).contains("--exclude=**/*.txt") + // Both filter flags precede the two jars. + assertThat(arguments.indexOf("--exclude=**/*.txt")) + .isLessThan(arguments.indexOf(reference.absolutePath)) + } + + @Test + fun `omits filter flags when no patterns are given`() { + val arguments = JardiffComparison.buildArguments(reference, candidate, mode = "--stat") + + assertThat(arguments).noneMatch { it.startsWith("--include") || it.startsWith("--exclude") } + } + + @Test + fun `uses the given mode, and omits it when blank`() { + assertThat(JardiffComparison.buildArguments(reference, candidate, mode = "--status")) + .startsWith("--status") + assertThat(JardiffComparison.buildArguments(reference, candidate, mode = "")) + .startsWith("--exit-code") + } + + @Test + fun `appends additional options before the positional arguments`() { + val arguments = JardiffComparison.buildArguments( + reference, + candidate, + mode = "--stat", + additionalOptions = listOf("--ignore-member-order", "--class-text-producer=javap"), + ) + + assertThat(arguments).contains("--ignore-member-order", "--class-text-producer=javap") + assertThat(arguments.indexOf("--ignore-member-order")) + .isLessThan(arguments.indexOf(reference.absolutePath)) + } + + @Test + fun `maps exit values to outcomes like diff`() { + assertThat(JardiffComparison.outcomeOf(0)).isEqualTo(JardiffComparison.Outcome.IDENTICAL) + assertThat(JardiffComparison.outcomeOf(1)).isEqualTo(JardiffComparison.Outcome.DIFFERENT) + assertThat(JardiffComparison.outcomeOf(2)).isEqualTo(JardiffComparison.Outcome.ERROR) + assertThat(JardiffComparison.outcomeOf(137)).isEqualTo(JardiffComparison.Outcome.ERROR) + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt new file mode 100644 index 00000000000..0ef74d63c5a --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt @@ -0,0 +1,274 @@ +package datadog.gradle.plugin.jardiff + +import datadog.gradle.plugin.GradleFixture +import org.assertj.core.api.Assertions.assertThat +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Test + +/** + * Exercises the `dd-trace-java.jardiff` plugin and its `compareToReferenceJar` task end-to-end. + */ +class JardiffTaskIntegrationTest : GradleFixture() { + + @Test + fun `passes when the candidate matches the reference`() { + writeProject() + writeJar("candidate.jar", "same-bytes") + writeJar("reference.jar", "same-bytes") + + val result = run("compareToReferenceJar") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(buildFile("reports/jardiff/comparison.txt")).exists().content() + .contains("no differences") + } + + @Test + fun `fails and reports the diff when the candidate differs`() { + writeProject() + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("compareToReferenceJar", expectFailure = true) + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(result.output) + .contains("Built jar differs from the reference jar") + .contains("1 file changed") + assertThat(buildFile("reports/jardiff/comparison.txt")).exists().content() + .contains("1 file changed") + } + + @Test + fun `reference-jar option takes precedence over the referenceJar property`() { + writeProject() + writeJar("candidate.jar", "shared-bytes") + // The wired reference would match (identical bytes) and pass... + writeJar("reference.jar", "shared-bytes") + // ...but the command-line override points at a diverging jar, so the build must fail. + writeJar("override.jar", "diverging-bytes") + + val result = run( + "compareToReferenceJar", + "--reference-jar=${file("override.jar").absolutePath}", + expectFailure = true, + ) + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(result.output).contains("Built jar differs from the reference jar") + } + + @Test + fun `resolves the reference from jardiffReferenceDir by matching the candidate file name`() { + writeProject(referenceJarLine = "") + writeJar("candidate.jar", "dir-bytes") + // A directory holding a jar with the SAME file name as the candidate (as across CI jobs). + dir("refs") + writeJar("refs/candidate.jar", "dir-bytes") + + val result = run("compareToReferenceJar", "-PjardiffReferenceDir=${file("refs").absolutePath}") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(buildFile("reports/jardiff/comparison.txt")).exists().content() + .contains("no differences") + } + + @Test + fun `fails when no reference is configured`() { + // The comparison is mandatory: a missing reference must fail, never silently skip. + writeProject(referenceJarLine = "") + writeJar("candidate.jar", "candidate-bytes") + + val result = run("compareToReferenceJar", expectFailure = true) + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(result.output).contains("No reference jar configured") + assertThat(buildFile("reports/jardiff/comparison.txt")).doesNotExist() + } + + @Test + fun `skips comparison when the task is disabled`() { + writeProject( + referenceJarLine = "", + taskBody = "enabled = false", + ) + writeJar("candidate.jar", "candidate-bytes") + + val result = run("compareToReferenceJar") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SKIPPED) + assertThat(result.output).doesNotContain("No reference jar configured") + assertThat(buildFile("reports/jardiff/comparison.txt")).doesNotExist() + } + + @Test + fun `passes include and exclude patterns to jardiff`() { + writeProject( + taskBody = """ + includes.set(listOf("**/*.class", "META-INF/**")) + excludes.set(listOf("**/*.txt")) + """, + ) + writeJar("candidate.jar", "same-bytes") + writeJar("reference.jar", "same-bytes") + + val result = run("compareToReferenceJar") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(buildFile("reports/jardiff/comparison.txt")).content() + .contains("--include=**/*.class,META-INF/**") + .contains("--exclude=**/*.txt") + } + + @Test + fun `applies main class, mode and additional options from the jardiff extension`() { + writeSettings("""rootProject.name = "jardiff-ext-test"""") + writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff")) + writeRootProject( + """ + import datadog.gradle.plugin.jardiff.JardiffTask + + plugins { + java + id("dd-trace-java.jardiff") + } + + jardiff { + mainClass.set("fake.jardiff.Main") + mode.set("--status") + additionalOptions.set(listOf("--ignore-member-order")) + } + + tasks.named("compareToReferenceJar") { + dependsOn("classes") + jardiffClasspath.setFrom(sourceSets["main"].output) + candidateJar.set(layout.projectDirectory.file("candidate.jar")) + referenceJar.set(layout.projectDirectory.file("reference.jar")) + } + """, + ) + writeJar("candidate.jar", "same-bytes") + writeJar("reference.jar", "same-bytes") + + val result = run("compareToReferenceJar") + + // SUCCESS proves the extension's mainClass reached the task (the stub Main is 'fake.jardiff.Main', + // not the default), and the report echoes the mode + additional option from the extension. + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(buildFile("reports/jardiff/comparison.txt")).content() + .contains("--status") + .contains("--ignore-member-order") + } + + @Test + fun `applies mode and additional options passed as command-line options`() { + writeProject() + writeJar("candidate.jar", "same-bytes") + writeJar("reference.jar", "same-bytes") + + val result = run( + "compareToReferenceJar", + "--mode=--status", + "--jardiff-option=--ignore-member-order", + "--jardiff-option=--class-text-producer=javap", + ) + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(buildFile("reports/jardiff/comparison.txt")).content() + .contains("--status") + .contains("--ignore-member-order") + .contains("--class-text-producer=javap") + } + + @Test + fun `ignoreVersionFiles excludes version stamps from the comparison`() { + writeProject(taskBody = "ignoreVersionFiles.set(true)") + writeJar("candidate.jar", "same-bytes") + writeJar("reference.jar", "same-bytes") + + val result = run("compareToReferenceJar") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(buildFile("reports/jardiff/comparison.txt")).content() + .contains("--exclude=**/*.version") + } + + @Test + fun `compares version stamps under CI (ignoreVersionFiles defaults to false)`() { + // No explicit ignoreVersionFiles: the plugin's CI-aware convention drives it. With CI set, the + // build and deploy jobs share a commit, so the stamps are compared (not excluded). + writeProject() + writeJar("candidate.jar", "same-bytes") + writeJar("reference.jar", "same-bytes") + + val result = run("compareToReferenceJar", env = mapOf("CI" to "true")) + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(buildFile("reports/jardiff/comparison.txt")).content() + .doesNotContain("**/*.version") + } + + private fun writeJar(name: String, content: String) { + file(name).also { it.parentFile?.mkdirs() }.writeBytes(content.toByteArray()) + } + + private fun writeProject( + taskBody: String = "", + referenceJarLine: String = + """referenceJar.set(layout.projectDirectory.file("reference.jar"))""", + ) { + writeSettings("""rootProject.name = "jardiff-stub-test"""") + writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff")) + // The task's classpath and main class point at the compiled stub instead of the real jardiff CLI. + writeRootProject( + """ + import datadog.gradle.plugin.jardiff.JardiffTask + + plugins { + java + id("dd-trace-java.jardiff") + } + + tasks.named("compareToReferenceJar") { + dependsOn("classes") + jardiffClasspath.setFrom(sourceSets["main"].output) + mainClass.set("fake.jardiff.Main") + candidateJar.set(layout.projectDirectory.file("candidate.jar")) + $referenceJarLine + $taskBody + } + """, + ) + } + + companion object { + /** + * Minimal stand-in for the jardiff CLI in the given package: prints the received arguments, + * byte-compares the last two positional arguments (left/right jars) and mirrors jardiff's + * `--exit-code` semantics. + */ + private fun stubMainSource(packageName: String): String = """ + package $packageName; + + import java.nio.file.Files; + import java.nio.file.Paths; + import java.util.Arrays; + + public class Main { + public static void main(String[] args) throws Exception { + System.out.println("jardiff-stub args: " + Arrays.toString(args)); + String left = args[args.length - 2]; + String right = args[args.length - 1]; + byte[] leftBytes = Files.readAllBytes(Paths.get(left)); + byte[] rightBytes = Files.readAllBytes(Paths.get(right)); + if (Arrays.equals(leftBytes, rightBytes)) { + System.out.println("no differences"); + System.exit(0); + } + System.out.println(" 1 file changed, 1 insertion(+)"); + System.exit(Arrays.asList(args).contains("--exit-code") ? 1 : 0); + } + } + """.trimIndent() + } +} diff --git a/gradle/publish.gradle b/gradle/publish.gradle index 86d06ae9a85..b95913eaf07 100644 --- a/gradle/publish.gradle +++ b/gradle/publish.gradle @@ -1,6 +1,7 @@ apply plugin: 'maven-publish' apply plugin: 'signing' apply plugin: 'dd-trace-java.version-file' +apply plugin: 'dd-trace-java.jardiff' /** * Proper publishing requires the following environment variables: diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index ba8b43ca32c..88531ceaeed 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -77,3 +77,9 @@ tasks.withType().configureEach { tasks.withType().configureEach { javadocTool = javaToolchains.javadocToolFor(java.toolchain) } + +// The dd-openfeature provider jar is not produced by the CI `build` job, so there is no reference +// artifact to compare against. Disable the release jar comparison gate registered by publish.gradle. +tasks.named("compareToReferenceJar") { + enabled = false +} From 8ee734553e4973f93162ebc8c06f41fbea6fa402 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Thu, 9 Jul 2026 18:55:17 +0200 Subject: [PATCH 2/6] ci: Reword and task api tweaks --- .../gradle/plugin/jardiff/JardiffPlugin.kt | 1 + .../gradle/plugin/jardiff/JardiffTask.kt | 90 ++++++++++++------- .../jardiff/JardiffTaskIntegrationTest.kt | 4 +- 3 files changed, 59 insertions(+), 36 deletions(-) diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt index 19dd48e8098..14135783578 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt @@ -61,6 +61,7 @@ class JardiffPlugin : Plugin { ) reportFile.convention(project.layout.buildDirectory.file("reports/jardiff/comparison.txt")) referenceJar.convention( + // Use the same name as the candidate jar referenceDirProperty.flatMap { dir -> candidateJar.map { candidate -> projectDirectory.dir(dir).file(candidate.asFile.name) } }, diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt index 2f18509a030..e290242938b 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt @@ -22,33 +22,47 @@ import org.gradle.api.tasks.options.Option import org.gradle.process.ExecOperations /** - * Compares a freshly built jar against a reference jar using the + * Compares a candidate jar against a reference jar using the * [jardiff](https://github.com/bric3/jardiff) CLI and fails the build if they differ. * - * This guards Maven Central publication against non-deterministic rebuilds: the artifact about to - * be published (that was possibly rebuilt with faults) is compared against a reference artifact. + * The same task class supports two plugin-configured modes: + * - `compareToReferenceJar` wires [candidateJar] to the project's archive output, so Gradle builds + * that archive before comparing it. + * - `compareJarFiles` leaves [candidateJar] unset and expects `--candidate-jar=`, so it can + * compare an already-produced artifact without depending on `jar` or `shadowJar`. * * The reference jar is resolved, in order of precedence, from: - * 1. the `--reference-jar=` command-line option (handy to compare a single artifact locally - * before publishing), then + * 1. the `--reference-jar=` command-line option, then * 2. the [referenceJar] property (wired by the `dd-trace-java.jardiff` plugin from the * `-PjardiffReferenceDir` project property by matching the built jar's file name in that * directory). - * - * The comparison is mandatory: once the task runs, a missing reference fails the build, so a - * dropped CI property or a forgotten `--reference-jar` cannot silently publish an unverified jar. - * Modules that have no reference artifact to compare can disable the task instead - * (`compareToReferenceJar { enabled = false }`). */ abstract class JardiffTask @Inject constructor( private val execOperations: ExecOperations, ) : DefaultTask() { - /** The freshly built jar to validate (the main publication artifact). */ + /** + * The project archive to validate. Optional so the same task class can also compare explicit + * file paths without depending on the archive-producing task. + */ @get:InputFile + @get:Optional @get:PathSensitive(PathSensitivity.NONE) abstract val candidateJar: RegularFileProperty + /** + * Command-line override for the candidate jar path; takes precedence over [candidateJar]. + * A relative path is resolved against the current working directory (the Gradle invocation + * directory), matching how CLI users expect paths to behave. + */ + @get:Input + @get:Optional + @get:Option( + option = "candidate-jar", + description = "Path to the candidate jar to compare against the reference jar.", + ) + abstract val candidateJarPath: Property + /** * The reference jar to compare against. Optional; usually wired from `-PjardiffReferenceDir`. * Kept [Internal] (the task always re-runs, see the `upToDateWhen { false }` below) so a missing @@ -66,8 +80,7 @@ abstract class JardiffTask @Inject constructor( @get:Optional @get:Option( option = "reference-jar", - description = "Path to the reference jar to compare the built jar against " + - "(typically the artifact produced by the CI `build` job).", + description = "Path to the reference jar to compare the candidate jar against.", ) abstract val referenceJarPath: Property @@ -130,27 +143,14 @@ abstract class JardiffTask @Inject constructor( init { includes.convention(emptyList()) excludes.convention(emptyList()) - // This task is a publication gate, and as such must never be skipped as "up-to-date": - // i.e. always re-run so a divergent rebuild cannot slip through on a stale execution history. + // These comparisons are explicit verification gates and must never be skipped as up-to-date. outputs.upToDateWhen { false } } @TaskAction fun compare() { val reference = resolveReferenceJar() - ?: throw GradleException( - "No reference jar configured to compare the built jar against.\n" + - "Provide one via --reference-jar= or -PjardiffReferenceDir= (the directory " + - "holding the `build` job artifacts), or disable this task for modules with no reference.", - ) - if (!reference.isFile) { - throw GradleException( - "Reference jar does not exist: ${reference.absolutePath}\n" + - "Pass an existing jar via --reference-jar= or point -PjardiffReferenceDir at the " + - "directory holding the `build` job artifacts.", - ) - } - val candidate = candidateJar.get().asFile + val candidate = resolveCandidateJar() val effectiveExcludes = buildList { addAll(excludes.get()) @@ -195,7 +195,7 @@ abstract class JardiffTask @Inject constructor( JardiffComparison.Outcome.DIFFERENT -> throw GradleException( buildString { - appendLine("Built jar differs from the reference jar.") + appendLine("Candidate jar differs from the reference jar.") appendLine("TODO: inspect build") appendLine() appendLine(" candidate : ${candidate.absolutePath}") @@ -216,11 +216,33 @@ abstract class JardiffTask @Inject constructor( } } - private fun resolveReferenceJar(): File? { - referenceJarPath.orNull?.takeIf { it.isNotBlank() }?.let { return File(it) } - if (referenceJar.isPresent) { - return referenceJar.get().asFile + private fun resolveReferenceJar(): File = + referenceJarPath.orNull?.takeIf { it.isNotBlank() }?.let(::File)?.let { + requireExistingJar(it, "Reference jar") + } ?: when { + referenceJar.isPresent -> requireExistingJar(referenceJar.get().asFile, "Reference jar") + else -> throw GradleException( + "No reference jar configured to compare the candidate jar against.\n" + + "Provide one via --reference-jar= or -PjardiffReferenceDir= (the directory " + + "holding the `build` job artifacts), or disable this task for modules with no reference.", + ) + } + + private fun resolveCandidateJar(): File = + candidateJarPath.orNull?.takeIf { it.isNotBlank() }?.let(::File)?.let { + requireExistingJar(it, "Candidate jar") + } ?: when { + candidateJar.isPresent -> requireExistingJar(candidateJar.get().asFile, "Candidate jar") + else -> throw GradleException( + "No candidate jar configured to compare against the reference jar.\n" + + "Pass an existing jar via --candidate-jar= or configure the task's candidateJar.", + ) + } + + private fun requireExistingJar(jar: File, role: String): File { + if (!jar.isFile) { + throw GradleException("$role does not exist: ${jar.absolutePath}") } - return null + return jar } } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt index 0ef74d63c5a..4047e8271e6 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt @@ -33,7 +33,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.FAILED) assertThat(result.output) - .contains("Built jar differs from the reference jar") + .contains("Candidate jar differs from the reference jar") .contains("1 file changed") assertThat(buildFile("reports/jardiff/comparison.txt")).exists().content() .contains("1 file changed") @@ -55,7 +55,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { ) assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.FAILED) - assertThat(result.output).contains("Built jar differs from the reference jar") + assertThat(result.output).contains("Candidate jar differs from the reference jar") } @Test From eb21a1f7ae98a9c4ca239a31faa7f17e0c477da4 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Thu, 16 Jul 2026 00:20:04 +0200 Subject: [PATCH 3/6] feat: skip jardiff when jar hashes match --- .../gradle/plugin/jardiff/JardiffExtension.kt | 6 + .../gradle/plugin/jardiff/JardiffPlugin.kt | 5 +- .../gradle/plugin/jardiff/JardiffTask.kt | 96 +++++++++-- .../jardiff/JardiffTaskIntegrationTest.kt | 150 +++++++++-------- .../gradle/plugin/jardiff/JardiffTaskTest.kt | 152 ++++++++++++++++++ dd-trace-api/build.gradle.kts | 5 + 6 files changed, 337 insertions(+), 77 deletions(-) create mode 100644 buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt index f8837f66c47..9268e95a3bb 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt @@ -28,4 +28,10 @@ interface JardiffExtension { * or `--class-text-producer=javap`). Empty by default. */ val additionalOptions: ListProperty + + /** + * When true, compare the candidate and reference jar hashes before launching jardiff. If the + * hashes match, the task succeeds without invoking jardiff. Defaults to true. + */ + val hashCheck: Property } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt index 14135783578..e33c92c5c37 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt @@ -24,6 +24,8 @@ class JardiffPlugin : Plugin { extension.mainClass.convention(DEFAULT_MAIN_CLASS) extension.mode.convention(DEFAULT_MODE) extension.additionalOptions.convention(emptyList()) + extension.reportDir.convention(project.layout.buildDirectory.dir("reports/jardiff")) + extension.hashCheck.convention(true) // Use a detached configuration (created here, resolved only when the task runs) // @@ -54,12 +56,13 @@ class JardiffPlugin : Plugin { mainClass.convention(extension.mainClass) mode.convention(extension.mode) additionalOptions.convention(extension.additionalOptions) + reportDir.convention(extension.reportDir) + hashCheck.convention(extension.hashCheck) // Ignore **/*.version by default, except under CI where the build and deploy // jobs share the same commit. ignoreVersionFiles.convention( project.providers.environmentVariable("CI").map { false }.orElse(true), ) - reportFile.convention(project.layout.buildDirectory.file("reports/jardiff/comparison.txt")) referenceJar.convention( // Use the same name as the candidate jar referenceDirProperty.flatMap { dir -> diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt index e290242938b..569a26680dc 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt @@ -2,10 +2,12 @@ package datadog.gradle.plugin.jardiff import java.io.ByteArrayOutputStream import java.io.File +import java.security.MessageDigest import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property @@ -14,7 +16,7 @@ import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction @@ -120,6 +122,17 @@ abstract class JardiffTask @Inject constructor( ) abstract val additionalOptions: ListProperty + /** + * When true, hash the candidate and reference jars before running jardiff. Matching hashes mean + * the jars are byte-for-byte identical, so the task can skip the more expensive jardiff process. + */ + @get:Input + @get:Option( + option = "hash-check", + description = "Hash the jars before launching jardiff and skip jardiff when hashes match.", + ) + abstract val hashCheck: Property + /** * When true, the `.version` files entries are excluded from the comparison. * This is useful in particular as those files can be part of runtimeClasspath normalization @@ -136,13 +149,19 @@ abstract class JardiffTask @Inject constructor( @get:Input abstract val mainClass: Property - /** Destination of the captured jardiff report. */ - @get:OutputFile + /** Directory receiving captured jardiff reports. */ + @get:OutputDirectory + abstract val reportDir: DirectoryProperty + + /** Optional exact destination for the captured jardiff report. Prefer [reportDir]. */ + @get:Internal abstract val reportFile: RegularFileProperty init { includes.convention(emptyList()) excludes.convention(emptyList()) + reportDir.convention(project.layout.buildDirectory.dir("reports/jardiff")) + hashCheck.convention(true) // These comparisons are explicit verification gates and must never be skipped as up-to-date. outputs.upToDateWhen { false } } @@ -151,6 +170,16 @@ abstract class JardiffTask @Inject constructor( fun compare() { val reference = resolveReferenceJar() val candidate = resolveCandidateJar() + val reportDestination = reportDestination(candidate) + val hashesMatch = sameHash(reference, candidate) + + if (hashesMatch) { + val message = + "SHA-256 hashes match for ${candidate.name} and ${reference.name}; jardiff was skipped." + writeReport(reportDestination, "$message\n") + logger.lifecycle("✓ $message Report: ${reportDestination.absolutePath}") + return + } val effectiveExcludes = buildList { addAll(excludes.get()) @@ -184,13 +213,32 @@ abstract class JardiffTask @Inject constructor( } val report = captured.toString("UTF-8") - val reportDestination = reportFile.get().asFile - reportDestination.parentFile?.mkdirs() - reportDestination.writeText(report) + writeReport(reportDestination, report) when (JardiffComparison.outcomeOf(execResult.exitValue)) { - JardiffComparison.Outcome.IDENTICAL -> - logger.lifecycle("✓ ${candidate.name} is identical to the reference jar ${reference.name}") + JardiffComparison.Outcome.IDENTICAL -> { + if (!hashesMatch) { + val message = + "SHA-256 hashes differ for ${candidate.name} and ${reference.name}, " + + "but jardiff detected no differences." + logger.warn(message) + if (hashCheck.get()) { + throw GradleException( + buildString { + appendLine(message) + appendLine() + appendLine(" candidate : ${candidate.absolutePath}") + appendLine(" reference : ${reference.absolutePath}") + appendLine(" report : ${reportDestination.absolutePath}") + }, + ) + } + } + logger.lifecycle( + "✓ ${candidate.name} is identical to the reference jar ${reference.name}. " + + "Report: ${reportDestination.absolutePath}", + ) + } JardiffComparison.Outcome.DIFFERENT -> throw GradleException( @@ -210,7 +258,8 @@ abstract class JardiffTask @Inject constructor( JardiffComparison.Outcome.ERROR -> throw GradleException( "jardiff failed with exit code ${execResult.exitValue} while comparing " + - "${candidate.name} against ${reference.name}. Output:\n" + + "${candidate.name} against ${reference.name}. Report: ${reportDestination.absolutePath}. " + + "Output:\n" + report.ifBlank { "(no output captured)" }, ) } @@ -245,4 +294,33 @@ abstract class JardiffTask @Inject constructor( } return jar } + + private fun reportDestination(candidate: File): File = + when { + reportFile.isPresent -> reportFile.get().asFile + else -> reportDir.file("${candidate.name}.txt").get().asFile + } + + private fun sameHash(left: File, right: File): Boolean = + left.length() == right.length() && sha256(left).contentEquals(sha256(right)) + + private fun sha256(file: File): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) { + break + } + digest.update(buffer, 0, read) + } + } + return digest.digest() + } + + private fun writeReport(reportDestination: File, report: String) { + reportDestination.parentFile?.mkdirs() + reportDestination.writeText(report) + } } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt index 4047e8271e6..547b8649afb 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt @@ -10,35 +10,6 @@ import org.junit.jupiter.api.Test */ class JardiffTaskIntegrationTest : GradleFixture() { - @Test - fun `passes when the candidate matches the reference`() { - writeProject() - writeJar("candidate.jar", "same-bytes") - writeJar("reference.jar", "same-bytes") - - val result = run("compareToReferenceJar") - - assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(buildFile("reports/jardiff/comparison.txt")).exists().content() - .contains("no differences") - } - - @Test - fun `fails and reports the diff when the candidate differs`() { - writeProject() - writeJar("candidate.jar", "candidate-bytes") - writeJar("reference.jar", "reference-bytes") - - val result = run("compareToReferenceJar", expectFailure = true) - - assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.FAILED) - assertThat(result.output) - .contains("Candidate jar differs from the reference jar") - .contains("1 file changed") - assertThat(buildFile("reports/jardiff/comparison.txt")).exists().content() - .contains("1 file changed") - } - @Test fun `reference-jar option takes precedence over the referenceJar property`() { writeProject() @@ -69,21 +40,8 @@ class JardiffTaskIntegrationTest : GradleFixture() { val result = run("compareToReferenceJar", "-PjardiffReferenceDir=${file("refs").absolutePath}") assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(buildFile("reports/jardiff/comparison.txt")).exists().content() - .contains("no differences") - } - - @Test - fun `fails when no reference is configured`() { - // The comparison is mandatory: a missing reference must fail, never silently skip. - writeProject(referenceJarLine = "") - writeJar("candidate.jar", "candidate-bytes") - - val result = run("compareToReferenceJar", expectFailure = true) - - assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.FAILED) - assertThat(result.output).contains("No reference jar configured") - assertThat(buildFile("reports/jardiff/comparison.txt")).doesNotExist() + assertThat(jardiffReport()).exists().content() + .contains("jardiff was skipped") } @Test @@ -98,7 +56,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SKIPPED) assertThat(result.output).doesNotContain("No reference jar configured") - assertThat(buildFile("reports/jardiff/comparison.txt")).doesNotExist() + assertThat(jardiffReport()).doesNotExist() } @Test @@ -107,15 +65,17 @@ class JardiffTaskIntegrationTest : GradleFixture() { taskBody = """ includes.set(listOf("**/*.class", "META-INF/**")) excludes.set(listOf("**/*.txt")) + hashCheck.set(false) """, + compareBytes = false, ) - writeJar("candidate.jar", "same-bytes") - writeJar("reference.jar", "same-bytes") + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") val result = run("compareToReferenceJar") assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(buildFile("reports/jardiff/comparison.txt")).content() + assertThat(jardiffReport()).content() .contains("--include=**/*.class,META-INF/**") .contains("--exclude=**/*.txt") } @@ -123,7 +83,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { @Test fun `applies main class, mode and additional options from the jardiff extension`() { writeSettings("""rootProject.name = "jardiff-ext-test"""") - writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff")) + writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff", compareBytes = false)) writeRootProject( """ import datadog.gradle.plugin.jardiff.JardiffTask @@ -137,6 +97,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { mainClass.set("fake.jardiff.Main") mode.set("--status") additionalOptions.set(listOf("--ignore-member-order")) + hashCheck.set(false) } tasks.named("compareToReferenceJar") { @@ -147,24 +108,33 @@ class JardiffTaskIntegrationTest : GradleFixture() { } """, ) - writeJar("candidate.jar", "same-bytes") - writeJar("reference.jar", "same-bytes") + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") val result = run("compareToReferenceJar") // SUCCESS proves the extension's mainClass reached the task (the stub Main is 'fake.jardiff.Main', // not the default), and the report echoes the mode + additional option from the extension. assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(buildFile("reports/jardiff/comparison.txt")).content() + assertThat(jardiffReport()).content() .contains("--status") .contains("--ignore-member-order") } @Test fun `applies mode and additional options passed as command-line options`() { - writeProject() - writeJar("candidate.jar", "same-bytes") - writeJar("reference.jar", "same-bytes") + writeProject(compareBytes = false) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + file("build.gradle.kts").appendText( + """ + + tasks.named("compareToReferenceJar") { + hashCheck.set(false) + } + """.trimIndent(), + ) val result = run( "compareToReferenceJar", @@ -174,7 +144,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { ) assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(buildFile("reports/jardiff/comparison.txt")).content() + assertThat(jardiffReport()).content() .contains("--status") .contains("--ignore-member-order") .contains("--class-text-producer=javap") @@ -182,14 +152,20 @@ class JardiffTaskIntegrationTest : GradleFixture() { @Test fun `ignoreVersionFiles excludes version stamps from the comparison`() { - writeProject(taskBody = "ignoreVersionFiles.set(true)") - writeJar("candidate.jar", "same-bytes") - writeJar("reference.jar", "same-bytes") + writeProject( + taskBody = """ + ignoreVersionFiles.set(true) + hashCheck.set(false) + """, + compareBytes = false, + ) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") val result = run("compareToReferenceJar") assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(buildFile("reports/jardiff/comparison.txt")).content() + assertThat(jardiffReport()).content() .contains("--exclude=**/*.version") } @@ -197,28 +173,68 @@ class JardiffTaskIntegrationTest : GradleFixture() { fun `compares version stamps under CI (ignoreVersionFiles defaults to false)`() { // No explicit ignoreVersionFiles: the plugin's CI-aware convention drives it. With CI set, the // build and deploy jobs share a commit, so the stamps are compared (not excluded). - writeProject() - writeJar("candidate.jar", "same-bytes") - writeJar("reference.jar", "same-bytes") + writeProject(taskBody = "hashCheck.set(false)", compareBytes = false) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") val result = run("compareToReferenceJar", env = mapOf("CI" to "true")) assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(buildFile("reports/jardiff/comparison.txt")).content() + assertThat(jardiffReport()).content() .doesNotContain("**/*.version") } + @Test + fun `writes reports under the configured reportDir`() { + writeSettings("""rootProject.name = "jardiff-report-dir-test"""") + writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff")) + writeRootProject( + """ + import datadog.gradle.plugin.jardiff.JardiffTask + + plugins { + java + id("dd-trace-java.jardiff") + } + + jardiff { + reportDir.set(layout.buildDirectory.dir("custom-jardiff-reports")) + } + + tasks.named("compareToReferenceJar") { + dependsOn("classes") + jardiffClasspath.setFrom(sourceSets["main"].output) + mainClass.set("fake.jardiff.Main") + candidateJar.set(layout.projectDirectory.file("candidate.jar")) + referenceJar.set(layout.projectDirectory.file("reference.jar")) + } + """, + ) + writeJar("candidate.jar", "same-bytes") + writeJar("reference.jar", "same-bytes") + + val result = run("compareToReferenceJar") + + assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(buildFile("custom-jardiff-reports/candidate.jar.txt")).exists().content() + .contains("jardiff was skipped") + } + private fun writeJar(name: String, content: String) { file(name).also { it.parentFile?.mkdirs() }.writeBytes(content.toByteArray()) } + private fun jardiffReport(candidateName: String = "candidate.jar") = + buildFile("reports/jardiff/$candidateName.txt") + private fun writeProject( taskBody: String = "", referenceJarLine: String = """referenceJar.set(layout.projectDirectory.file("reference.jar"))""", + compareBytes: Boolean = true, ) { writeSettings("""rootProject.name = "jardiff-stub-test"""") - writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff")) + writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff", compareBytes)) // The task's classpath and main class point at the compiled stub instead of the real jardiff CLI. writeRootProject( """ @@ -247,7 +263,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { * byte-compares the last two positional arguments (left/right jars) and mirrors jardiff's * `--exit-code` semantics. */ - private fun stubMainSource(packageName: String): String = """ + private fun stubMainSource(packageName: String, compareBytes: Boolean = true): String = """ package $packageName; import java.nio.file.Files; @@ -261,7 +277,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { String right = args[args.length - 1]; byte[] leftBytes = Files.readAllBytes(Paths.get(left)); byte[] rightBytes = Files.readAllBytes(Paths.get(right)); - if (Arrays.equals(leftBytes, rightBytes)) { + if (${if (compareBytes) "Arrays.equals(leftBytes, rightBytes)" else "true"}) { System.out.println("no differences"); System.exit(0); } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt new file mode 100644 index 00000000000..a62025d0958 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt @@ -0,0 +1,152 @@ +package datadog.gradle.plugin.jardiff + +import datadog.gradle.plugin.GradleFixture +import org.assertj.core.api.Assertions.assertThat +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Test + +/** Exercises [JardiffTask] behavior without the plugin's archive/reference-dir wiring. */ +class JardiffTaskTest : GradleFixture() { + + @Test + fun `passes without running jardiff when the candidate hash matches the reference hash`() { + writeProject() + writeJar("candidate.jar", "same-bytes") + writeJar("reference.jar", "same-bytes") + + val result = run("jardiffTask") + + assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(result.output).contains("Report:").contains("candidate.jar.txt") + assertThat(jardiffReport()).exists().content() + .contains("jardiff was skipped") + } + + @Test + fun `fails and reports the diff when jardiff finds differences`() { + writeProject() + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("jardiffTask", expectFailure = true) + + assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(result.output) + .contains("Candidate jar differs from the reference jar") + .contains("1 file changed") + .doesNotContain("SHA-256 hashes differ") + assertThat(jardiffReport()).exists().content() + .contains("1 file changed") + } + + @Test + fun `fails when no reference is configured`() { + writeProject(referenceJarLine = "") + writeJar("candidate.jar", "candidate-bytes") + + val result = run("jardiffTask", expectFailure = true) + + assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(result.output).contains("No reference jar configured") + assertThat(jardiffReport()).doesNotExist() + } + + @Test + fun `fails when hashes differ but jardiff reports no differences and hashCheck is enabled`() { + writeProject(compareBytes = false) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("jardiffTask", expectFailure = true) + + assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(result.output) + .contains("SHA-256 hashes differ for candidate.jar and reference.jar") + .contains("but jardiff detected no differences") + assertThat(jardiffReport()).exists().content() + .contains("no differences") + } + + @Test + fun `warns when hashes differ but jardiff reports no differences and hashCheck is disabled`() { + writeProject( + taskBody = "hashCheck.set(false)", + compareBytes = false, + ) + writeJar("candidate.jar", "candidate-bytes") + writeJar("reference.jar", "reference-bytes") + + val result = run("jardiffTask") + + assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(result.output) + .contains("SHA-256 hashes differ for candidate.jar and reference.jar") + .contains("but jardiff detected no differences") + assertThat(jardiffReport()).exists().content() + .contains("no differences") + } + + private fun writeJar(name: String, content: String) { + file(name).also { it.parentFile?.mkdirs() }.writeBytes(content.toByteArray()) + } + + private fun jardiffReport(candidateName: String = "candidate.jar") = + buildFile("reports/jardiff/$candidateName.txt") + + private fun writeProject( + taskBody: String = "", + referenceJarLine: String = + """referenceJar.set(layout.projectDirectory.file("reference.jar"))""", + compareBytes: Boolean = true, + ) { + writeSettings("""rootProject.name = "jardiff-task-test"""") + writeJavaSource("fake.jardiff.Main", stubMainSource("fake.jardiff", compareBytes)) + writeRootProject( + """ + import datadog.gradle.plugin.jardiff.JardiffTask + + plugins { + java + id("dd-trace-java.jardiff") + } + + tasks.register("jardiffTask") { + dependsOn("classes") + jardiffClasspath.setFrom(sourceSets["main"].output) + mainClass.set("fake.jardiff.Main") + mode.set("--stat") + ignoreVersionFiles.set(true) + candidateJar.set(layout.projectDirectory.file("candidate.jar")) + $referenceJarLine + $taskBody + } + """, + ) + } + + companion object { + private fun stubMainSource(packageName: String, compareBytes: Boolean = true): String = """ + package $packageName; + + import java.nio.file.Files; + import java.nio.file.Paths; + import java.util.Arrays; + + public class Main { + public static void main(String[] args) throws Exception { + System.out.println("jardiff-stub args: " + Arrays.toString(args)); + String left = args[args.length - 2]; + String right = args[args.length - 1]; + byte[] leftBytes = Files.readAllBytes(Paths.get(left)); + byte[] rightBytes = Files.readAllBytes(Paths.get(right)); + if (${if (compareBytes) "Arrays.equals(leftBytes, rightBytes)" else "true"}) { + System.out.println("no differences"); + System.exit(0); + } + System.out.println(" 1 file changed, 1 insertion(+)"); + System.exit(Arrays.asList(args).contains("--exit-code") ? 1 : 0); + } + } + """.trimIndent() + } +} diff --git a/dd-trace-api/build.gradle.kts b/dd-trace-api/build.gradle.kts index 46e975af41f..26da347149f 100644 --- a/dd-trace-api/build.gradle.kts +++ b/dd-trace-api/build.gradle.kts @@ -5,6 +5,11 @@ plugins { apply(from = "$rootDir/gradle/java.gradle") apply(from = "$rootDir/gradle/publish.gradle") +configure { + // jar is not cacheable, and may differ + hashCheck = false +} + extra["minimumBranchCoverage"] = 0.8 // These are tested outside of this module since this module mainly just defines 'API' From 09f3fb6b87e491a923bcbe441e3e773255ae8eed Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Thu, 16 Jul 2026 00:23:03 +0200 Subject: [PATCH 4/6] feat: write jardiff reports per jar --- .gitlab-ci.yml | 10 ++++++++++ .../datadog/gradle/plugin/jardiff/JardiffExtension.kt | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 81a13d1fabb..1c57c6be244 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -511,6 +511,16 @@ validate_build: - ./gradlew --version # This will run the shadowJar task and exercise the build cache, allowing to identify build-cache issues - ./gradlew compareToReferenceJar -PjardiffReferenceDir="$CI_PROJECT_DIR/reference-artifacts" -PskipTests $GRADLE_ARGS + after_script: + - source .gitlab/gitlab-utils.sh + - gitlab_section_start "collect-reports" "Collecting reports" + - .gitlab/collect_reports.sh --destination ./check_reports + - gitlab_section_end "collect-reports" + artifacts: + when: always + paths: + - ./check_reports + - '.gradle/daemon/*/*.out.log' .check_job: extends: .gradle_build diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt index 9268e95a3bb..d43c4826ecf 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt @@ -1,5 +1,6 @@ package datadog.gradle.plugin.jardiff +import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property @@ -29,6 +30,11 @@ interface JardiffExtension { */ val additionalOptions: ListProperty + /** + * Directory receiving jardiff reports. Defaults to `build/reports/jardiff` in the target project. + */ + val reportDir: DirectoryProperty + /** * When true, compare the candidate and reference jar hashes before launching jardiff. If the * hashes match, the task succeeds without invoking jardiff. Defaults to true. From 69c5fe62c099a07de7144c99ca20b80decc04303 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Thu, 16 Jul 2026 10:47:46 +0200 Subject: [PATCH 5/6] style: Tweak log messages --- .../kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt | 8 +++----- .../gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt | 4 ++-- .../datadog/gradle/plugin/jardiff/JardiffTaskTest.kt | 3 +-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt index 569a26680dc..580024c9337 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt @@ -174,10 +174,9 @@ abstract class JardiffTask @Inject constructor( val hashesMatch = sameHash(reference, candidate) if (hashesMatch) { - val message = - "SHA-256 hashes match for ${candidate.name} and ${reference.name}; jardiff was skipped." + val message = "SHA-256 hashes match for ${candidate.name} and ${reference.name}." writeReport(reportDestination, "$message\n") - logger.lifecycle("✓ $message Report: ${reportDestination.absolutePath}") + logger.info("Skipping jardiff for ${candidate.name} because SHA-256 hashes match.") return } @@ -235,8 +234,7 @@ abstract class JardiffTask @Inject constructor( } } logger.lifecycle( - "✓ ${candidate.name} is identical to the reference jar ${reference.name}. " + - "Report: ${reportDestination.absolutePath}", + "Jardiff comparison passed for ${candidate.name} against ${reference.name}.", ) } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt index 547b8649afb..1614c1d40e2 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt @@ -41,7 +41,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) assertThat(jardiffReport()).exists().content() - .contains("jardiff was skipped") + .contains("SHA-256 hashes match for candidate.jar and candidate.jar") } @Test @@ -217,7 +217,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) assertThat(buildFile("custom-jardiff-reports/candidate.jar.txt")).exists().content() - .contains("jardiff was skipped") + .contains("SHA-256 hashes match for candidate.jar and reference.jar") } private fun writeJar(name: String, content: String) { diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt index a62025d0958..63f2c59e661 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt @@ -17,9 +17,8 @@ class JardiffTaskTest : GradleFixture() { val result = run("jardiffTask") assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(result.output).contains("Report:").contains("candidate.jar.txt") assertThat(jardiffReport()).exists().content() - .contains("jardiff was skipped") + .contains("SHA-256 hashes match for candidate.jar and reference.jar") } @Test From 0fb87f443dcdb4959ee7adcf9a244a251b112c75 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Thu, 16 Jul 2026 23:03:40 +0200 Subject: [PATCH 6/6] chore(gradle): PR review --- .../gradle/plugin/jardiff/JardiffExtension.kt | 6 ++--- .../gradle/plugin/jardiff/JardiffPlugin.kt | 4 ++-- .../gradle/plugin/jardiff/JardiffTask.kt | 22 +++++++++---------- .../jardiff/JardiffTaskIntegrationTest.kt | 20 +++++------------ .../gradle/plugin/jardiff/JardiffTaskTest.kt | 10 ++++----- dd-trace-api/build.gradle.kts | 2 +- 6 files changed, 28 insertions(+), 36 deletions(-) diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt index d43c4826ecf..ea30caede98 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffExtension.kt @@ -36,8 +36,8 @@ interface JardiffExtension { val reportDir: DirectoryProperty /** - * When true, compare the candidate and reference jar hashes before launching jardiff. If the - * hashes match, the task succeeds without invoking jardiff. Defaults to true. + * When true, tolerate mismatching candidate and reference jar hashes if jardiff reports no + * differences. Defaults to false. */ - val hashCheck: Property + val ignoreHashCheck: Property } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt index e33c92c5c37..d73fdd7a4c8 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffPlugin.kt @@ -25,7 +25,7 @@ class JardiffPlugin : Plugin { extension.mode.convention(DEFAULT_MODE) extension.additionalOptions.convention(emptyList()) extension.reportDir.convention(project.layout.buildDirectory.dir("reports/jardiff")) - extension.hashCheck.convention(true) + extension.ignoreHashCheck.convention(false) // Use a detached configuration (created here, resolved only when the task runs) // @@ -57,7 +57,7 @@ class JardiffPlugin : Plugin { mode.convention(extension.mode) additionalOptions.convention(extension.additionalOptions) reportDir.convention(extension.reportDir) - hashCheck.convention(extension.hashCheck) + ignoreHashCheck.convention(extension.ignoreHashCheck) // Ignore **/*.version by default, except under CI where the build and deploy // jobs share the same commit. ignoreVersionFiles.convention( diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt index 580024c9337..bf81ba2da93 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt @@ -123,15 +123,15 @@ abstract class JardiffTask @Inject constructor( abstract val additionalOptions: ListProperty /** - * When true, hash the candidate and reference jars before running jardiff. Matching hashes mean - * the jars are byte-for-byte identical, so the task can skip the more expensive jardiff process. + * When true, tolerate mismatching candidate and reference jar hashes if jardiff reports no + * differences. Matching hashes still skip the more expensive jardiff process. */ @get:Input @get:Option( - option = "hash-check", - description = "Hash the jars before launching jardiff and skip jardiff when hashes match.", + option = "ignore-hash-check", + description = "Do not fail when jar hashes differ but jardiff detects no differences.", ) - abstract val hashCheck: Property + abstract val ignoreHashCheck: Property /** * When true, the `.version` files entries are excluded from the comparison. @@ -161,7 +161,7 @@ abstract class JardiffTask @Inject constructor( includes.convention(emptyList()) excludes.convention(emptyList()) reportDir.convention(project.layout.buildDirectory.dir("reports/jardiff")) - hashCheck.convention(true) + ignoreHashCheck.convention(false) // These comparisons are explicit verification gates and must never be skipped as up-to-date. outputs.upToDateWhen { false } } @@ -218,10 +218,10 @@ abstract class JardiffTask @Inject constructor( JardiffComparison.Outcome.IDENTICAL -> { if (!hashesMatch) { val message = - "SHA-256 hashes differ for ${candidate.name} and ${reference.name}, " + + "SHA-256 hashes differ for ${candidate.name} (candidate) and ${reference.name} (reference), " + "but jardiff detected no differences." logger.warn(message) - if (hashCheck.get()) { + if (!ignoreHashCheck.get()) { throw GradleException( buildString { appendLine(message) @@ -234,7 +234,7 @@ abstract class JardiffTask @Inject constructor( } } logger.lifecycle( - "Jardiff comparison passed for ${candidate.name} against ${reference.name}.", + "Jardiff comparison passed for ${candidate.name} (candidate) against ${reference.name} (reference).", ) } @@ -242,7 +242,7 @@ abstract class JardiffTask @Inject constructor( throw GradleException( buildString { appendLine("Candidate jar differs from the reference jar.") - appendLine("TODO: inspect build") + appendLine("TODO: inspect gradle build scripts") appendLine() appendLine(" candidate : ${candidate.absolutePath}") appendLine(" reference : ${reference.absolutePath}") @@ -256,7 +256,7 @@ abstract class JardiffTask @Inject constructor( JardiffComparison.Outcome.ERROR -> throw GradleException( "jardiff failed with exit code ${execResult.exitValue} while comparing " + - "${candidate.name} against ${reference.name}. Report: ${reportDestination.absolutePath}. " + + "${candidate.name} (candidate) against ${reference.name} (reference)." + "Output:\n" + report.ifBlank { "(no output captured)" }, ) diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt index 1614c1d40e2..d2fe632d7a4 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt @@ -65,7 +65,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { taskBody = """ includes.set(listOf("**/*.class", "META-INF/**")) excludes.set(listOf("**/*.txt")) - hashCheck.set(false) + ignoreHashCheck.set(true) """, compareBytes = false, ) @@ -97,7 +97,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { mainClass.set("fake.jardiff.Main") mode.set("--status") additionalOptions.set(listOf("--ignore-member-order")) - hashCheck.set(false) + ignoreHashCheck.set(true) } tasks.named("compareToReferenceJar") { @@ -122,25 +122,17 @@ class JardiffTaskIntegrationTest : GradleFixture() { } @Test - fun `applies mode and additional options passed as command-line options`() { + fun `applies mode additional options and ignoreHashCheck passed as command-line options`() { writeProject(compareBytes = false) writeJar("candidate.jar", "candidate-bytes") writeJar("reference.jar", "reference-bytes") - file("build.gradle.kts").appendText( - """ - - tasks.named("compareToReferenceJar") { - hashCheck.set(false) - } - """.trimIndent(), - ) - val result = run( "compareToReferenceJar", "--mode=--status", "--jardiff-option=--ignore-member-order", "--jardiff-option=--class-text-producer=javap", + "--ignore-hash-check", ) assertThat(result.task(":compareToReferenceJar")?.outcome).isEqualTo(TaskOutcome.SUCCESS) @@ -155,7 +147,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { writeProject( taskBody = """ ignoreVersionFiles.set(true) - hashCheck.set(false) + ignoreHashCheck.set(true) """, compareBytes = false, ) @@ -173,7 +165,7 @@ class JardiffTaskIntegrationTest : GradleFixture() { fun `compares version stamps under CI (ignoreVersionFiles defaults to false)`() { // No explicit ignoreVersionFiles: the plugin's CI-aware convention drives it. With CI set, the // build and deploy jobs share a commit, so the stamps are compared (not excluded). - writeProject(taskBody = "hashCheck.set(false)", compareBytes = false) + writeProject(taskBody = "ignoreHashCheck.set(true)", compareBytes = false) writeJar("candidate.jar", "candidate-bytes") writeJar("reference.jar", "reference-bytes") diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt index 63f2c59e661..ea06e336526 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskTest.kt @@ -51,7 +51,7 @@ class JardiffTaskTest : GradleFixture() { } @Test - fun `fails when hashes differ but jardiff reports no differences and hashCheck is enabled`() { + fun `fails when hashes differ but jardiff reports no differences and ignoreHashCheck is false`() { writeProject(compareBytes = false) writeJar("candidate.jar", "candidate-bytes") writeJar("reference.jar", "reference-bytes") @@ -60,16 +60,16 @@ class JardiffTaskTest : GradleFixture() { assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.FAILED) assertThat(result.output) - .contains("SHA-256 hashes differ for candidate.jar and reference.jar") + .contains("SHA-256 hashes differ for candidate.jar (candidate) and reference.jar (reference)") .contains("but jardiff detected no differences") assertThat(jardiffReport()).exists().content() .contains("no differences") } @Test - fun `warns when hashes differ but jardiff reports no differences and hashCheck is disabled`() { + fun `warns when hashes differ but jardiff reports no differences and ignoreHashCheck is true`() { writeProject( - taskBody = "hashCheck.set(false)", + taskBody = "ignoreHashCheck.set(true)", compareBytes = false, ) writeJar("candidate.jar", "candidate-bytes") @@ -79,7 +79,7 @@ class JardiffTaskTest : GradleFixture() { assertThat(result.task(":jardiffTask")?.outcome).isEqualTo(TaskOutcome.SUCCESS) assertThat(result.output) - .contains("SHA-256 hashes differ for candidate.jar and reference.jar") + .contains("SHA-256 hashes differ for candidate.jar (candidate) and reference.jar (reference)") .contains("but jardiff detected no differences") assertThat(jardiffReport()).exists().content() .contains("no differences") diff --git a/dd-trace-api/build.gradle.kts b/dd-trace-api/build.gradle.kts index 26da347149f..70a003348c9 100644 --- a/dd-trace-api/build.gradle.kts +++ b/dd-trace-api/build.gradle.kts @@ -7,7 +7,7 @@ apply(from = "$rootDir/gradle/publish.gradle") configure { // jar is not cacheable, and may differ - hashCheck = false + ignoreHashCheck = true } extra["minimumBranchCoverage"] = 0.8