Skip to content

fix(sbom): mix projectPath into deterministic UUID seed#15614

Open
jamesfredley wants to merge 4 commits intoapache:8.0.xfrom
jamesfredley:fix/sbom-uuid-collision-gradle-9
Open

fix(sbom): mix projectPath into deterministic UUID seed#15614
jamesfredley wants to merge 4 commits intoapache:8.0.xfrom
jamesfredley:fix/sbom-uuid-collision-gradle-9

Conversation

@jamesfredley
Copy link
Copy Markdown
Contributor

Summary

Fixes duplicate urn:uuid serialNumber values 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 serialNumber deterministically via UUID.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 platforms grails-bom, grails-base-bom, grails-hibernate5-bom, grails-micronaut-bom). Hashing identical content gives identical UUIDs, so the resulting SBOMs share the same serialNumber - which violates the CycloneDX 1.6 spec for serialNumber (must be unique per BOM).

Fix

Mix the captured projectPath into the hash input:

groovy def uuidSeed = "\n" def uuid = UUID.nameUUIDFromBytes(uuidSeed.getBytes(StandardCharsets.UTF_8.name()))

  • Different modules always seed the hash with a different prefix → unique serialNumber per BOM.
  • The same module + same content still yields the same UUID across rebuilds → reproducible builds preserved.
  • projectPath is already captured at configuration time (line 217), so no new Task.project access at execution time and no configuration-cache regression.

Verification

Ran cyclonedxDirectBom on six modules on Gradle 9.4.1:

Module serialNumber
:grails-base-bom urn:uuid:1f5bfc59-8a1d-380b-9a65-69aac07e18b0
:grails-bom urn:uuid:70b08f03-ec8b-3b7b-9cb4-d76a6a21ad90
:grails-hibernate5-bom urn:uuid:f0a86eec-77f6-3236-aed3-d99f00933967
:grails-micronaut-bom urn:uuid:23919eca-e83e-3e5a-a27a-1a7b1ce5cc31
:grails-bootstrap urn:uuid:3fe6e19d-48e4-3097-810e-1b504da46be6
:grails-encoder urn:uuid:7c15d8fa-2a54-39a5-8b14-c87cfa0b012b
  • Uniqueness: all six serialNumber values are distinct.
  • Determinism: re-running --rerun-tasks produces the exact same six values.

Reproducible-build verification via etc/bin/test-reproducible-builds.sh inside the etc/bin/Dockerfile container is being run separately to confirm jar checksums match across two clean builds.

Notes

  • build-logic/plugins does 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.
  • No public API changes; only the build-tooling plugin org.apache.grails.buildsrc.sbom.

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
Copilot AI review requested due to automatic review settings April 30, 2026 22:24
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 projectPath to ensure uniqueness per module.
  • Expands inline documentation explaining the collision scenario and the CycloneDX serialNumber requirement.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy Outdated
Comment thread build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy Outdated
Comment thread build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy Outdated
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
@jamesfredley
Copy link
Copy Markdown
Contributor Author

Pushed 4d1a78f1e2 - second-order fix for com.gradleup.shadow fat jars.

Why a follow-up commit

The projectPath-seed fix in d6a80e0c9e made each project's own cyclonedxDirectBom output unique and reproducible, but it did not change how the shadow plugin merges sibling jars. When I extracted META-INF/sbom.json from the fat jars produced by :grails-cli and :grails-cli-shadow after the first fix, both still had:

Jar serialNumber metadata.component.name
grails-cli-8.0.0-SNAPSHOT-all.jar urn:uuid:631bbcd5-... grails-shell-cli
grails-cli-shadow-8.0.0-SNAPSHOT-all.jar urn:uuid:631bbcd5-... grails-shell-cli

Two distinct fat jars sharing one UUID, both attributed to the wrong module - because shadow's first-wins merge picked up grails-shell-cli's META-INF/sbom.json (a transitive dep) before either fat-jar project's own SBOM. Same CycloneDX 1.6 violation, different cause.

What changed

  1. SbomPlugin.publishSbomForShadowJarProjects (new) - symmetrical to publishSbomForJarProjects. When a project applies com.gradleup.shadow, its shadowJar task now excludes incoming META-INF/sbom.json from the merge and froms the project's own cyclonedxDirectBom output, plus sets the same Sbom-Location / Sbom-Format manifest attributes the regular jar already gets. Uses the broad Task type with a cast to Jar so build-logic does not need a compile-time dep on the shadow plugin types.

  2. grails-forge/grails-cli-shadow/build.gradle - that module 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.

Verification (Gradle 9.4.1, SOURCE_DATE_EPOCH=git log -1 --pretty=%ct, --no-build-cache --rerun-tasks)

After the second fix, META-INF/sbom.json in each CLI jar:

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.zip extracts cleanly, LICENSE and NOTICE present, 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 ShellApp generates a complete Grails app, exit 0.
  • grails-forge-cli create-app -x -g mongodb -f gradle-settings-file ForgeApp generates a complete Grails app, exit 0.
  • java -jar grails-cli-8.0.0-SNAPSHOT-all.jar --version still 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.

@jamesfredley jamesfredley requested a review from jdaugherty May 1, 2026 01:05
@jamesfredley jamesfredley self-assigned this May 1, 2026
@jamesfredley jamesfredley moved this to In Progress in Apache Grails May 1, 2026
@jamesfredley jamesfredley added this to the grails:8.0.0-M1 milestone May 1, 2026
* 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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 -> Jar cast - grails-cli/build.gradle already imports com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar and now also imports org.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 than from(taskProvider), which would copy any future task outputs verbatim).
  • The skipJavaComponent guard is preserved to match the convention used by publishSbomForJarProjects.
  • :grails-cli-shadow/build.gradle is left as-is - it already excludes META-INF/sbom.json directly because it does not apply org.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
Copy link
Copy Markdown
Contributor

@jdaugherty jdaugherty May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@jamesfredley
Copy link
Copy Markdown
Contributor Author

Pushed 23f586d4f6 addressing @jdaugherty's review.

What changed

Moved the shadow jar SBOM wiring out of the generic SbomPlugin and into grails-forge/grails-cli/build.gradle, the only project that actually combines com.gradleup.shadow with org.apache.grails.buildsrc.sbom.

  • SbomPlugin lost publishSbomForShadowJarProjects and the raw Task -> Jar cast it required, plus the now-unused import org.gradle.api.Task. The plugin's only responsibility is back to wiring cyclonedxDirectBom into the regular jar via publishSbomForJarProjects.
  • grails-cli/build.gradle now uses a typed TaskProvider<CyclonedxDirectTask> and cyclonedxDirectBomTask.flatMap { it.jsonOutput } inside the existing shadowJarTask.configure { ShadowJar it -> ... } block. ShadowJar, CyclonedxDirectTask, CopySpec, and Manifest are all on the buildscript classpath at that point, so the wiring is fully type-safe with no compile dep added to build-logic.
  • skipJavaComponent guard preserved to match the convention used by publishSbomForJarProjects.
  • :grails-cli-shadow/build.gradle is unchanged - it already excludes META-INF/sbom.json directly because it doesn't apply the SBOM plugin.

Verification (Gradle 9.4.1, --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
  • :grails-cli's regular and -all jar share a serialNumber by design (same project).
  • The fat jar's metadata.component.name is grails-cli (not the leaked grails-shell-cli from before the fix).
  • :grails-cli-shadow correctly has no META-INF/sbom.json in its fat jar.
  • :grails-forge-cli has a distinct serialNumber describing 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this added by the cyclonedx plugin already? Did you check the manifest files? Do we do this anywhere else?

@testlens-app
Copy link
Copy Markdown

testlens-app Bot commented May 1, 2026

✅ All tests passed ✅

🏷️ Commit: 23f586d
▶️ Tests: 19783 executed
⚪️ Checks: 35/35 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants