From 23288b07a772601470058064f3fec457387c6b20 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Fri, 3 Jul 2026 20:37:07 +0200 Subject: [PATCH] ci: compare release artifacts with jardiff before publishing `deploy_to_maven_central` rebuilds the artifacts (reusing the build cache) before publishing, so a fault in the build script could make the rebuild to produce a faulty jar that differs from the one the build job. Which was validated across the whole test chain. This add 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. This allows to gate maven publication (and github releases). --- .gitlab-ci.yml | 15 +- 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 | 225 +++++++++++++++ .../plugin/jardiff/JardiffComparisonTest.kt | 95 +++++++ .../jardiff/JardiffTaskIntegrationTest.kt | 259 ++++++++++++++++++ gradle/publish.gradle | 19 +- .../feature-flagging-api/build.gradle.kts | 6 + 10 files changed, 832 insertions(+), 3 deletions(-) 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 5138dcae082..cc9886cb2a0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1279,12 +1279,21 @@ deploy_to_maven_central: - export MAVEN_CENTRAL_PASSWORD=$(aws ssm get-parameter --region us-east-1 --name ci.dd-trace-java.central_password --with-decryption --query "Parameter.Value" --out text) - export GPG_PRIVATE_KEY=$(aws ssm get-parameter --region us-east-1 --name ci.dd-trace-java.signing.gpg_private_key --with-decryption --query "Parameter.Value" --out text) - export GPG_PASSWORD=$(aws ssm get-parameter --region us-east-1 --name ci.dd-trace-java.signing.gpg_passphrase --with-decryption --query "Parameter.Value" --out text) - - ./gradlew publishToSonatype closeSonatypeStagingRepository -PskipTests $GRADLE_ARGS + # Preserve the `build` job artifacts (validated across the whole test chain) before Gradle + # rebuilds and overwrites them under workspace/**/build/libs. jardiff then compares the jars + # about to be published against these references (see https://app.datadoghq.com/incidents/50455). + - 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 compareToReferenceJar publishToSonatype closeSonatypeStagingRepository -PjardiffReferenceDir="$CI_PROJECT_DIR/reference-artifacts" -PskipTests $GRADLE_ARGS artifacts: + when: always paths: - 'workspace/dd-java-agent/build/libs/*.jar' - 'workspace/dd-trace-api/build/libs/*.jar' - 'workspace/dd-trace-ot/build/libs/*.jar' + - 'workspace/**/reports/jardiff/*.txt' deploy_snapshot_with_ddprof_snapshot: extends: .gradle_build @@ -1304,7 +1313,9 @@ deploy_snapshot_with_ddprof_snapshot: - export GPG_PRIVATE_KEY=$(aws ssm get-parameter --region us-east-1 --name ci.dd-trace-java.signing.gpg_private_key --with-decryption --query "Parameter.Value" --out text) - export GPG_PASSWORD=$(aws ssm get-parameter --region us-east-1 --name ci.dd-trace-java.signing.gpg_passphrase --with-decryption --query "Parameter.Value" --out text) - echo "Publishing dd-trace-java snapshot with ddprof snapshot dependency" - - ./gradlew -PbuildInfo.build.number=$CI_JOB_ID -PddprofUseSnapshot publishToSonatype -PskipTests $GRADLE_ARGS + # This snapshot bundles a ddprof SNAPSHOT, so its jar differs from the build-job artifact by + # design — exclude the reference comparison gate (there is no matching reference to compare to). + - ./gradlew -PbuildInfo.build.number=$CI_JOB_ID -PddprofUseSnapshot publishToSonatype -x compareToReferenceJar -PskipTests $GRADLE_ARGS artifacts: paths: - 'workspace/dd-java-agent/build/libs/*.jar' 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..255073966a8 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/jardiff/JardiffTask.kt @@ -0,0 +1,225 @@ +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 maybe rebuilt with faults in a later job) 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, refusing to publish.") + 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..6d4671c0338 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/jardiff/JardiffTaskIntegrationTest.kt @@ -0,0 +1,259 @@ +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 `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..930e8f589d0 100644 --- a/gradle/publish.gradle +++ b/gradle/publish.gradle @@ -1,6 +1,9 @@ +import org.gradle.api.publish.maven.tasks.PublishToMavenRepository + 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: @@ -76,7 +79,6 @@ tasks.withType(Sign).configureEach { /** * State assertions below... */ - gradle.taskGraph.whenReady { TaskExecutionGraph taskGraph -> if (taskGraph.hasTask(publish) || taskGraph.hasTask("publishToSonatype")) { assert project.findProperty("removeJarVersionNumbers") != true @@ -112,3 +114,18 @@ tasks.withType(AbstractPublishToMaven).configureEach { task -> task.mustRunAfter tasks.matching { it instanceof VerificationTask } } } + +/** + * Release jar comparison gate. + * + * Intercepts faulty artifacts that were rebuilt on re-execution of a task. + * + * This can happen when a build script issue silently changes it output, thus producing + * a different jar than the one from a previous task invocation. + * + * The effect is to gate remote publication only (`publishToMavenLocal` is a `PublishToMavenLocal` task) + * The comparison only fails the build if they are not equivalent according to `jardiff`. + */ +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn 'compareToReferenceJar' +} diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index def6a16da8c..6463bba8594 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -74,3 +74,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 +}