fix(sbom): mix projectPath into deterministic UUID seed#15614
fix(sbom): mix projectPath into deterministic UUID seed#15614jamesfredley wants to merge 4 commits intoapache:8.0.xfrom
Conversation
The SbomPlugin generates the BOM serialNumber by hashing the post-processed JSON with UUID.nameUUIDFromBytes() so rebuilds produce identical SBOMs. After upgrading to Gradle 9 and CycloneDX gradle plugin 3.0.0, multiple modules now produce JSON whose post-processed body collides (notably the empty BOM platforms), causing duplicate urn:uuid serialNumbers and violating the CycloneDX 1.6 specification. Mix the captured projectPath into the hash input. Different modules now seed the hash with a different prefix while the same module + same content keeps yielding the same UUID across rebuilds, so the reproducible-build behavior is preserved. Verified locally on Gradle 9.4.1: cyclonedxDirectBom on six modules (grails-bom, grails-base-bom, grails-hibernate5-bom, grails-micronaut-bom, grails-bootstrap, grails-encoder) produces six distinct serialNumbers that are stable across rebuilds. Assisted-by: claude-code:claude-opus-4
There was a problem hiding this comment.
Pull request overview
Updates the build-logic SBOM post-processing to prevent serialNumber UUID collisions across modules while keeping SBOMs deterministic for reproducible builds after the Gradle 9 / CycloneDX 3.0.0 upgrade.
Changes:
- Prefixes the deterministic UUID seed with
projectPathto ensure uniqueness per module. - Expands inline documentation explaining the collision scenario and the CycloneDX
serialNumberrequirement.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Pure cleanup of the prior commit on this branch: - Comment now says `clearing it` instead of `removing it` to match the actual behavior of `bom['serialNumber'] = ''` (the key is blanked, not deleted from the map). - Renamed local `withOutSerial` to `withoutSerial` (proper camelCase). - Use `StandardCharsets.UTF_8` directly instead of `StandardCharsets.UTF_8.name()` so Java picks the `Charset` overload of `String.getBytes` and skips the runtime charset name lookup. Re-verified locally on Gradle 9.4.1 against the same six modules: every serialNumber is byte-identical to the prior commit, confirming the behavior is unchanged. Assisted-by: claude-code:claude-opus-4
Discovered while running the verify-cli/verify-wrapper smoke tests
against the build outputs of :grails-cli, :grails-cli-shadow,
:grails-shell-cli, :grails-forge-cli and :grails-wrapper.
Both :grails-cli (-all.jar) and :grails-cli-shadow (-all.jar) bundled
grails-shell-cli as a transitive dep, and com.gradleup.shadow's
first-wins merge of META-INF/sbom.json ended up putting
grails-shell-cli's SBOM into both fat jars. Result: two distinct fat
jars shared a single serialNumber and both reported
metadata.component.name="grails-shell-cli" - a CycloneDX 1.6
violation that the previous projectPath-seed fix did not catch
because that fix only changed how each project's own SBOM JSON is
generated, not how shadow merges other modules' SBOMs into a fat jar.
This commit applies a fix symmetrical to publishSbomForJarProjects:
* SbomPlugin gains publishSbomForShadowJarProjects, which, for any
project that applies com.gradleup.shadow, excludes incoming
META-INF/sbom.json from the shadow merge and re-introduces the
project's own SBOM (whose serialNumber is now project-path-seeded
and unique). Manifest gets the same Sbom-Location / Sbom-Format
attributes the regular jar already has. Uses Task + cast to Jar so
build-logic does not need a compile-time dependency on the shadow
plugin types.
* grails-forge/grails-cli-shadow does not apply
org.apache.grails.buildsrc.sbom (it is an intermediate build
artifact, not published), so the SbomPlugin hook does not fire for
it. Its shadowJar exclude list now drops META-INF/sbom.json
directly so the intermediate fat jar - which feeds :grails-cli's
shadowCombined configuration - cannot smuggle a wrong SBOM back in.
Verified on Gradle 9.4.1 with SOURCE_DATE_EPOCH set, --no-build-cache,
--rerun-tasks (the same flags test-reproducible-builds.sh uses):
jar serialNumber
:grails-shell-cli (regular) 631bbcd5...
:grails-wrapper (regular) 9c5f6980...
:grails-cli (regular) 3e4ea827...
:grails-cli (-all FAT) 3e4ea827... *
:grails-cli-shadow (regular) (no SBOM - empty)
:grails-cli-shadow (-all FAT) (no SBOM - excluded)
:grails-forge-cli (regular) 890372ec...
*) :grails-cli's regular and FAT jar share a serialNumber by design:
they ship the same project's SBOM, the FAT jar just adds bundled
classes. The CycloneDX uniqueness constraint applies across BOMs
describing different artifacts, not across two jars whose embedded
BOM happens to describe the same project.
Smoke-tested both fat-jar CLIs against verify-cli-distribution.sh
flow:
apache-grails-8.0.0-SNAPSHOT-bin.zip extracts cleanly with LICENSE
and NOTICE present, bin/grails-shell-cli --version and
bin/grails-forge-cli --version both return version + JVM info,
bin/grails-shell-cli create-app ShellApp generates a complete
Grails app, bin/grails-forge-cli create-app -x -g mongodb
-f gradle-settings-file ForgeApp generates a complete Grails app,
java -jar grails-cli-8.0.0-SNAPSHOT-all.jar --version still works
with the new SBOM in place.
Assisted-by: claude-code:claude-opus-4
|
Pushed Why a follow-up commitThe
Two distinct fat jars sharing one UUID, both attributed to the wrong module - because shadow's first-wins merge picked up What changed
Verification (Gradle 9.4.1,
|
| Jar | serialNumber |
metadata.component.name |
|---|---|---|
:grails-shell-cli (regular) |
631bbcd5-... |
grails-shell-cli |
:grails-wrapper (regular) |
9c5f6980-... |
grails-wrapper |
:grails-cli (regular) |
3e4ea827-... |
grails-cli |
:grails-cli (-all FAT) |
3e4ea827-... |
grails-cli |
:grails-cli-shadow (regular, empty) |
(no SBOM) | (no SBOM) |
:grails-cli-shadow (-all FAT) |
(no SBOM - excluded) | (no SBOM) |
:grails-forge-cli (regular) |
890372ec-... |
grails-forge-cli |
Four distinct serialNumber values across four user-facing jars; zero cross-module collisions. :grails-cli's regular and -all jar share a serialNumber by design - they ship the same project's SBOM, the -all jar just adds bundled classes; CycloneDX uniqueness applies across BOMs describing different artifacts, not across two jars whose embedded BOM describes the same project.
Smoke tests against the same flow verify-cli-distribution.sh runs:
apache-grails-8.0.0-SNAPSHOT-bin.zipextracts cleanly,LICENSEandNOTICEpresent,bin/populated.grails-shell-cli --version=>Grails Version: 8.0.0-SNAPSHOT, exit 0.grails-forge-cli --version=>Grails Version: 8.0.0-SNAPSHOT, exit 0.grails-shell-cli create-app ShellAppgenerates a complete Grails app, exit 0.grails-forge-cli create-app -x -g mongodb -f gradle-settings-file ForgeAppgenerates a complete Grails app, exit 0.java -jar grails-cli-8.0.0-SNAPSHOT-all.jar --versionstill launches the CLI with the new SBOM in place, exit 0.
apache-grails-wrapper-8.0.0-SNAPSHOT-bin.zip also extracts cleanly with LICENSE, NOTICE, grailsw, grailsw.bat, grails-wrapper.jar present.
| * com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar from | ||
| * build-logic; the cast is safe because ShadowJar extends Jar. | ||
| */ | ||
| private static void publishSbomForShadowJarProjects(Project project, Provider<RegularFile> sbomOutputLocation) { |
There was a problem hiding this comment.
I don't think ti's correct configure these is the sbom plugin. Why aren't we using a transform on the project in question?
There was a problem hiding this comment.
Good call - moved the entire shadow jar SBOM wiring out of SbomPlugin and into grails-forge/grails-cli/build.gradle in 23f586d. The generic plugin no longer knows about com.gradleup.shadow at all; its only responsibility is wiring cyclonedxDirectBom into the regular jar via publishSbomForJarProjects.
Where the logic lives now (inside the existing shadowJarTask.configure { ShadowJar it -> ... } block):
TaskProvider<CyclonedxDirectTask> cyclonedxDirectBomTask = tasks.named('cyclonedxDirectBom', CyclonedxDirectTask)
shadowJarTask.configure { ShadowJar it ->
// ...existing transforms / mergeServiceFiles / excludes...
it.exclude(
// ...
'META-INF/sbom.json',
// ...
)
if (!project.findProperty('skipJavaComponent')) {
it.from(cyclonedxDirectBomTask.flatMap { CyclonedxDirectTask t -> t.jsonOutput }) { CopySpec spec ->
spec.into('META-INF')
spec.rename { 'sbom.json' }
}
it.manifest { Manifest manifest ->
manifest.attributes('Sbom-Location': 'META-INF/sbom.json')
manifest.attributes('Sbom-Format': 'CycloneDX')
}
}
}Why this fits better than a hook in SbomPlugin:
- No more raw
Task -> Jarcast -grails-cli/build.gradlealready importscom.github.jengelman.gradle.plugins.shadow.tasks.ShadowJarand now also importsorg.cyclonedx.gradle.CyclonedxDirectTask, so the wiring is fully type-safe at the consumer. cyclonedxDirectBomTask.flatMap { it.jsonOutput }ties the shadow jar to exactly the post-processed JSON output (rather thanfrom(taskProvider), which would copy any future task outputs verbatim).- The
skipJavaComponentguard is preserved to match the convention used bypublishSbomForJarProjects. :grails-cli-shadow/build.gradleis left as-is - it already excludesMETA-INF/sbom.jsondirectly because it does not applyorg.apache.grails.buildsrc.sbom.
Verified on Gradle 9.4.1 with --rerun-tasks:
| Jar | serialNumber |
metadata.component.name |
|---|---|---|
:grails-cli (regular) |
urn:uuid:beabd2c0-... |
grails-cli |
:grails-cli (-all FAT) |
urn:uuid:beabd2c0-... |
grails-cli |
:grails-cli-shadow (-all FAT) |
(no SBOM - excluded) | (no SBOM) |
:grails-forge-cli (regular) |
urn:uuid:401e573c-... |
grails-forge-cli |
Distinct serialNumber values across distinct projects, the fat jar's metadata.component.name is grails-cli (not the leaked grails-shell-cli from before), and the :grails-cli regular and -all jar deliberately share a serialNumber because they describe the same project.
| * serialNumber is project-path-seeded and unique per fix(sbom): mix | ||
| * projectPath into deterministic UUID seed). | ||
| * | ||
| * Uses the broad Task type to avoid a compile-time dependency on |
There was a problem hiding this comment.
We had a compile dependency before that was removed as part of the gradle 9 update, this should remain. We're complicated a generic plugin specifically to work around one project instead of configuring the individual gradle project
There was a problem hiding this comment.
On the missing-compile-dependency angle: I went back through build-logic/plugins/build.gradle and the git history (git log -S 'shadow' -- build-logic/) and could not find a prior commit where build-logic had a compile dependency on com.gradleup.shadow. The only build-logic implementation deps that have ever been there are grails-publish-plugin, org.gradle.crypto.checksum, and org.cyclonedx.bom.
Either way, the new approach side-steps that question entirely: grails-cli/build.gradle already pulls in com.gradleup.shadow via its own plugins block, so the shadow types are on the buildscript classpath at the point where they are actually used, with no need to add anything to build-logic.
If a second module ever ends up needing both com.gradleup.shadow and org.apache.grails.buildsrc.sbom, the natural next step would be a small dedicated convention plugin (e.g. org.apache.grails.buildsrc.shadow-sbom) that explicitly depends on shadow, rather than re-adding shadow knowledge to the generic SBOM plugin. Today there is exactly one such project, so a per-project configuration is the simplest fit.
Per review feedback on PR apache#15614, configure the fat-jar SBOM logic directly in the project that uses com.gradleup.shadow rather than adding a shadow-aware hook to the generic SbomPlugin convention plugin. * Remove publishSbomForShadowJarProjects from SbomPlugin and the raw Task -> Jar cast it required. The plugin no longer needs to know about com.gradleup.shadow at all; its only responsibility is wiring cyclonedxDirectBom into the regular jar via publishSbomForJarProjects. * Wire the same exclude / from / manifest logic into the existing shadowJarTask.configure block in grails-forge/grails-cli/build.gradle, using a typed TaskProvider<CyclonedxDirectTask> and cyclonedxDirectBomTask.flatMap { it.jsonOutput } so the shadow jar picks up only the post-processed sbom.json output (and not any future task outputs). The skipJavaComponent guard is preserved to match the convention used by publishSbomForJarProjects. * :grails-cli-shadow/build.gradle is unchanged; it already excludes META-INF/sbom.json directly because that module does not apply org.apache.grails.buildsrc.sbom. Verified on Gradle 9.4.1 with --rerun-tasks: the regular and fat :grails-cli jars share a serialNumber (by design, same project), the fat jar's metadata.component.name is grails-cli (not the leaked grails-shell-cli), and :grails-forge-cli has a distinct serialNumber. :grails-cli-shadow's fat jar correctly contains no META-INF/sbom.json. Assisted-by: claude-code:claude-opus-4
|
Pushed What changedMoved the shadow jar SBOM wiring out of the generic
Verification (Gradle 9.4.1,
|
| Jar | serialNumber |
metadata.component.name |
|---|---|---|
:grails-cli (regular) |
urn:uuid:beabd2c0-... |
grails-cli |
:grails-cli (-all FAT) |
urn:uuid:beabd2c0-... |
grails-cli |
:grails-cli-shadow (-all FAT) |
(no SBOM - excluded) | (no SBOM) |
:grails-forge-cli (regular) |
urn:uuid:401e573c-... |
grails-forge-cli |
:grails-cli's regular and-alljar share aserialNumberby design (same project).- The fat jar's
metadata.component.nameisgrails-cli(not the leakedgrails-shell-clifrom before the fix). :grails-cli-shadowcorrectly has noMETA-INF/sbom.jsonin its fat jar.:grails-forge-clihas a distinctserialNumberdescribing the right module.
Replied to both review threads inline.
| ensureLicensesValidated(project) | ||
|
|
||
| // sboms are only published to Grails jar files at this time | ||
| // sboms are only published to Grails jar files at this time. Projects that produce a fat |
There was a problem hiding this comment.
We should restore the original comment and remove this verbosity
| it.exclude( | ||
| 'META-INF/DEPENDENCIES', // until we publish our own SBOM, this won't be correct so exclude | ||
| // This module does not apply org.apache.grails.buildsrc.sbom (it's an intermediate build | ||
| // artifact, not published). Without this exclude, shadow's first-wins merge picks one of |
There was a problem hiding this comment.
we dont need such a long comment:
this is an intermediate build, exclude conflicting filed
| // this project's own SBOM (whose serialNumber is project-path-seeded and unique). This keeps fat-jar | ||
| // packaging concerns local to this project rather than leaking shadow knowledge into the generic | ||
| // org.apache.grails.buildsrc.sbom plugin. See: https://cyclonedx.org/docs/1.6/json/#serialNumber | ||
| TaskProvider<CyclonedxDirectTask> cyclonedxDirectBomTask = tasks.named('cyclonedxDirectBom', CyclonedxDirectTask) |
There was a problem hiding this comment.
Move this into the configure since it doesn't appear to be used outside of it
| // the org.apache.grails.buildsrc.sbom plugin). Mirrored only when skipJavaComponent is unset to | ||
| // match the convention used elsewhere in the build for projects that opt out of jar publication. | ||
| if (!project.findProperty('skipJavaComponent')) { | ||
| it.from(cyclonedxDirectBomTask.flatMap { CyclonedxDirectTask t -> t.jsonOutput }) { CopySpec spec -> |
There was a problem hiding this comment.
flatMap is non lazy; isnt the json output the only output? If so just pass the task here
| } | ||
| } | ||
| it.manifest { Manifest manifest -> | ||
| manifest.attributes('Sbom-Location': 'META-INF/sbom.json') |
There was a problem hiding this comment.
Is this added by the cyclonedx plugin already? Did you check the manifest files? Do we do this anywhere else?
✅ All tests passed ✅🏷️ Commit: 23f586d Learn more about TestLens at testlens.app. |
Summary
Fixes duplicate
urn:uuidserialNumbervalues across SBOMs after the upgrade to Gradle 9 + CycloneDX gradle plugin 3.0.0.Problem
SbomPlugin post-processes each module's BOM JSON and rewrites the
serialNumberdeterministically viaUUID.nameUUIDFromBytes(<json bytes>)so rebuilds yield identical SBOMs (and therefore identical jar checksums). After moving to Gradle 9.4.1 and CycloneDX gradle plugin 3.0.0, several modules now produce post-processed JSON whose body collides between modules (most visibly the empty BOM platformsgrails-bom,grails-base-bom,grails-hibernate5-bom,grails-micronaut-bom). Hashing identical content gives identical UUIDs, so the resulting SBOMs share the sameserialNumber- which violates the CycloneDX 1.6 spec forserialNumber(must be unique per BOM).Fix
Mix the captured
projectPathinto the hash input:groovy def uuidSeed = "\n" def uuid = UUID.nameUUIDFromBytes(uuidSeed.getBytes(StandardCharsets.UTF_8.name()))serialNumberper BOM.projectPathis already captured at configuration time (line 217), so no newTask.projectaccess at execution time and no configuration-cache regression.Verification
Ran
cyclonedxDirectBomon six modules on Gradle 9.4.1:serialNumber:grails-base-bomurn:uuid:1f5bfc59-8a1d-380b-9a65-69aac07e18b0:grails-bomurn:uuid:70b08f03-ec8b-3b7b-9cb4-d76a6a21ad90:grails-hibernate5-bomurn:uuid:f0a86eec-77f6-3236-aed3-d99f00933967:grails-micronaut-bomurn:uuid:23919eca-e83e-3e5a-a27a-1a7b1ce5cc31:grails-bootstrapurn:uuid:3fe6e19d-48e4-3097-810e-1b504da46be6:grails-encoderurn:uuid:7c15d8fa-2a54-39a5-8b14-c87cfa0b012bserialNumbervalues are distinct.--rerun-tasksproduces the exact same six values.Reproducible-build verification via
etc/bin/test-reproducible-builds.shinside theetc/bin/Dockerfilecontainer is being run separately to confirm jar checksums match across two clean builds.Notes
build-logic/pluginsdoes not have a Spock test sourceset wired up today, matching the pattern of recent SBOM fixes (54b718b526,8c5d1d182d). Adding one would be a separate, larger change.org.apache.grails.buildsrc.sbom.