Skip to content

Commit 6c8dfc8

Browse files
authored
Catch build issue by comparing release artifacts with jardiff after build job (#11959)
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. ci: Reword and task api tweaks feat: skip jardiff when jar hashes match feat: write jardiff reports per jar style: Tweak log messages chore(gradle): PR review Co-authored-by: brice.dutheil <brice.dutheil@datadoghq.com>
1 parent 461d3e8 commit 6c8dfc8

12 files changed

Lines changed: 1123 additions & 0 deletions

File tree

.gitlab-ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,33 @@ test_published_artifacts:
496496
paths:
497497
- ./check_reports
498498

499+
validate_build:
500+
extends: .gradle_build
501+
stage: tests
502+
needs: [ build ]
503+
variables:
504+
CACHE_TYPE: "lib"
505+
script:
506+
# Preserve the `build` job artifacts before Gradle rebuilds and overwrites them under
507+
# workspace/**/build/libs, so jardiff can compare the rebuilt jars against them.
508+
- mkdir -p reference-artifacts
509+
- cp workspace/dd-java-agent/build/libs/*.jar reference-artifacts/
510+
- cp workspace/dd-trace-api/build/libs/*.jar reference-artifacts/
511+
- cp workspace/dd-trace-ot/build/libs/*.jar reference-artifacts/
512+
- ./gradlew --version
513+
# This will run the shadowJar task and exercise the build cache, allowing to identify build-cache issues
514+
- ./gradlew compareToReferenceJar -PjardiffReferenceDir="$CI_PROJECT_DIR/reference-artifacts" -PskipTests $GRADLE_ARGS
515+
after_script:
516+
- source .gitlab/gitlab-utils.sh
517+
- gitlab_section_start "collect-reports" "Collecting reports"
518+
- .gitlab/collect_reports.sh --destination ./check_reports
519+
- gitlab_section_end "collect-reports"
520+
artifacts:
521+
when: always
522+
paths:
523+
- ./check_reports
524+
- '.gradle/daemon/*/*.out.log'
525+
499526
.check_job:
500527
extends: .gradle_build
501528
needs: [ build ]

buildSrc/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ gradlePlugin {
7373
id = "dd-trace-java.sca-enrichments"
7474
implementationClass = "datadog.gradle.plugin.sca.ScaEnrichmentsPlugin"
7575
}
76+
77+
create("jardiff-plugin") {
78+
id = "dd-trace-java.jardiff"
79+
implementationClass = "datadog.gradle.plugin.jardiff.JardiffPlugin"
80+
}
7681
}
7782
}
7883

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package datadog.gradle.plugin.jardiff
2+
3+
import java.io.File
4+
5+
/**
6+
* Pure, Gradle-free helpers for driving the [jardiff](https://github.com/bric3/jardiff) CLI.
7+
*
8+
* Kept separate from [JardiffTask] so the argument construction and exit-code interpretation can
9+
* be unit-tested without spinning up a Gradle build or resolving the jardiff jar.
10+
*/
11+
object JardiffComparison {
12+
/** Outcome of a jardiff run, derived from its process exit value. */
13+
enum class Outcome {
14+
/** Exit 0 — the two jars are identical (for the selected include/exclude set). */
15+
IDENTICAL,
16+
17+
/** Exit 1 — jardiff reported differences (behaves like `diff(1)` under `--exit-code`). */
18+
DIFFERENT,
19+
20+
/** Any other exit value — jardiff itself failed (bad arguments, unreadable jar, ...). */
21+
ERROR,
22+
}
23+
24+
/**
25+
* Builds the jardiff argument list comparing [reference] (left) against [candidate] (right).
26+
*
27+
* [mode] selects the output mode (e.g. `--stat` for a `git diff --stat`-like summary, `--status`,
28+
* or blank for the default full diff). `--exit-code` is always added.
29+
* The [JardiffTask] relies on the process exit value.
30+
* [includes]/[excludes] are comma-joined glob patterns (empty means comparing every entry), and
31+
* [additionalOptions] are passed through verbatim, right before the two jars.
32+
*/
33+
fun buildArguments(
34+
reference: File,
35+
candidate: File,
36+
mode: String,
37+
includes: List<String> = emptyList(),
38+
excludes: List<String> = emptyList(),
39+
additionalOptions: List<String> = emptyList(),
40+
): List<String> = buildList {
41+
if (mode.isNotBlank()) {
42+
add(mode)
43+
}
44+
add("--exit-code")
45+
add("--color=never")
46+
if (includes.isNotEmpty()) {
47+
add("--include=" + includes.joinToString(","))
48+
}
49+
if (excludes.isNotEmpty()) {
50+
add("--exclude=" + excludes.joinToString(","))
51+
}
52+
addAll(additionalOptions)
53+
// jardiff positional arguments: <left> <right>.
54+
// The reference (the artifact validated by the build job) is the left/baseline side,
55+
// the freshly built candidate is the right side.
56+
add(reference.absolutePath)
57+
add(candidate.absolutePath)
58+
}
59+
60+
/** Maps a jardiff process exit value to an [Outcome]. */
61+
fun outcomeOf(exitValue: Int): Outcome = when (exitValue) {
62+
0 -> Outcome.IDENTICAL
63+
1 -> Outcome.DIFFERENT
64+
else -> Outcome.ERROR
65+
}
66+
67+
/**
68+
* Renders the equivalent `java -cp … <mainClass> <arguments>` shell command, so the comparison
69+
* can be copy-pasted and reproduced outside Gradle. Tokens containing shell-significant
70+
* characters (spaces, globs, commas, ...) are single-quoted.
71+
*/
72+
fun shellCommandLine(classpath: Iterable<File>, mainClass: String, arguments: List<String>): String {
73+
val classpathValue = classpath.joinToString(File.pathSeparator) { it.absolutePath }
74+
return (listOf("java", "-cp", classpathValue, mainClass) + arguments)
75+
.joinToString(" ", transform = ::shellQuote)
76+
}
77+
78+
private fun shellQuote(token: String): String =
79+
if (token.isNotEmpty() && token.all { it.isLetterOrDigit() || it in "/._-=:" }) {
80+
token
81+
} else {
82+
"'" + token.replace("'", "'\\''") + "'"
83+
}
84+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package datadog.gradle.plugin.jardiff
2+
3+
import org.gradle.api.file.DirectoryProperty
4+
import org.gradle.api.provider.ListProperty
5+
import org.gradle.api.provider.Property
6+
7+
/** Configuration for the [JardiffPlugin]. */
8+
interface JardiffExtension {
9+
/**
10+
* Maven coordinate of the jardiff CLI resolved to run the comparison.
11+
* Defaults to [JardiffPlugin.DEFAULT_TOOL_COORDINATE].
12+
*/
13+
val toolCoordinate: Property<String>
14+
15+
/**
16+
* Fully qualified main class of the jardiff CLI.
17+
* Defaults to [JardiffPlugin.DEFAULT_MAIN_CLASS].
18+
*/
19+
val mainClass: Property<String>
20+
21+
/**
22+
* jardiff output mode flag, e.g. `--stat` or `--status`; blank selects the default full diff.
23+
* Defaults to [JardiffPlugin.DEFAULT_MODE].
24+
*/
25+
val mode: Property<String>
26+
27+
/**
28+
* Extra jardiff options passed verbatim, right before the two jars (e.g. `--ignore-member-order`
29+
* or `--class-text-producer=javap`). Empty by default.
30+
*/
31+
val additionalOptions: ListProperty<String>
32+
33+
/**
34+
* Directory receiving jardiff reports. Defaults to `build/reports/jardiff` in the target project.
35+
*/
36+
val reportDir: DirectoryProperty
37+
38+
/**
39+
* When true, tolerate mismatching candidate and reference jar hashes if jardiff reports no
40+
* differences. Defaults to false.
41+
*/
42+
val ignoreHashCheck: Property<Boolean>
43+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package datadog.gradle.plugin.jardiff
2+
3+
import org.gradle.api.Plugin
4+
import org.gradle.api.Project
5+
import org.gradle.api.tasks.bundling.AbstractArchiveTask
6+
import org.gradle.kotlin.dsl.create
7+
import org.gradle.kotlin.dsl.named
8+
import org.gradle.kotlin.dsl.register
9+
10+
/**
11+
* Registers the `compareToReferenceJar` task ([JardiffTask]) and wires its reusable inputs:
12+
* - the jardiff CLI classpath (resolved from [JardiffExtension.toolCoordinate]),
13+
* - the candidate archive — the module's main publishable jar (the shadow jar when the shadow
14+
* plugin is applied, otherwise the plain jar),
15+
* - the reference jar, resolved from the `-PjardiffReferenceDir` project property by matching the
16+
* candidate's file name inside that directory.
17+
*
18+
* Apply it with `id("dd-trace-java.jardiff")`.
19+
*/
20+
class JardiffPlugin : Plugin<Project> {
21+
override fun apply(project: Project) {
22+
val extension = project.extensions.create<JardiffExtension>("jardiff")
23+
extension.toolCoordinate.convention(DEFAULT_TOOL_COORDINATE)
24+
extension.mainClass.convention(DEFAULT_MAIN_CLASS)
25+
extension.mode.convention(DEFAULT_MODE)
26+
extension.additionalOptions.convention(emptyList())
27+
extension.reportDir.convention(project.layout.buildDirectory.dir("reports/jardiff"))
28+
extension.ignoreHashCheck.convention(false)
29+
30+
// Use a detached configuration (created here, resolved only when the task runs)
31+
//
32+
// This keeps the jardiff artifact out of `lockAllConfigurations()` dependency locking.
33+
// The dependency is added lazily, so overriding `jardiff.toolCoordinate` still takes effect.
34+
// The `@jar` requests the artifact only, because jardiff ships a self-contained "fat" CLI jar
35+
// under a non-default Gradle Module Metadata variant, the default resolution misses it.
36+
// Appending `@jar` ignores metadata and fetches that jar.
37+
val toolClasspath = project.configurations.detachedConfiguration().apply {
38+
dependencies.addLater(
39+
extension.toolCoordinate.map { coordinate ->
40+
val artifactOnly = if ('@' in coordinate) coordinate else "$coordinate@jar"
41+
project.dependencies.create(artifactOnly)
42+
},
43+
)
44+
}
45+
46+
val projectDirectory = project.layout.projectDirectory
47+
val referenceDirProperty =
48+
project.providers.gradleProperty("jardiffReferenceDir").filter { it.isNotBlank() }
49+
50+
val compare = project.tasks.register<JardiffTask>(COMPARE_TASK_NAME) {
51+
group = "verification"
52+
description = "Compares the built jar against a reference jar (typically the CI `build` " +
53+
"job artifact) using jardiff, failing if they differ. Set the reference with " +
54+
"--reference-jar=<path> or -PjardiffReferenceDir=<dir>."
55+
jardiffClasspath.convention(toolClasspath)
56+
mainClass.convention(extension.mainClass)
57+
mode.convention(extension.mode)
58+
additionalOptions.convention(extension.additionalOptions)
59+
reportDir.convention(extension.reportDir)
60+
ignoreHashCheck.convention(extension.ignoreHashCheck)
61+
// Ignore **/*.version by default, except under CI where the build and deploy
62+
// jobs share the same commit.
63+
ignoreVersionFiles.convention(
64+
project.providers.environmentVariable("CI").map { false }.orElse(true),
65+
)
66+
referenceJar.convention(
67+
// Use the same name as the candidate jar
68+
referenceDirProperty.flatMap { dir ->
69+
candidateJar.map { candidate -> projectDirectory.dir(dir).file(candidate.asFile.name) }
70+
},
71+
)
72+
}
73+
74+
// candidateJar = the module's main publishable archive
75+
project.pluginManager.withPlugin("java") {
76+
compare.configure {
77+
candidateJar.convention(
78+
project.tasks.named<AbstractArchiveTask>("jar").flatMap { it.archiveFile },
79+
)
80+
}
81+
}
82+
project.pluginManager.withPlugin("com.gradleup.shadow") {
83+
compare.configure {
84+
candidateJar.set(
85+
project.tasks.named<AbstractArchiveTask>("shadowJar").flatMap { it.archiveFile },
86+
)
87+
}
88+
}
89+
}
90+
91+
companion object {
92+
const val DEFAULT_TOOL_COORDINATE = "io.github.bric3.jardiff:jardiff-cli:0.2.0"
93+
94+
const val DEFAULT_MAIN_CLASS = "io.github.bric3.jardiff.app.Main"
95+
96+
const val DEFAULT_MODE = "--stat"
97+
98+
const val COMPARE_TASK_NAME = "compareToReferenceJar"
99+
}
100+
}

0 commit comments

Comments
 (0)