Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import org.cyclonedx.model.OrganizationalEntity
import org.gradle.api.GradleException
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.file.CopySpec
import org.gradle.api.file.RegularFile
import org.gradle.api.java.archives.Manifest
Expand Down Expand Up @@ -142,6 +143,7 @@ class SbomPlugin implements Plugin<Project> {

// sboms are only published to Grails jar files at this time
publishSbomForJarProjects(project, sbomOutputLocation)
publishSbomForShadowJarProjects(project, sbomOutputLocation)
}

private static void configureSbomTask(Project project, Provider<RegularFile> sbomOutputLocation) {
Expand Down Expand Up @@ -261,10 +263,20 @@ class SbomPlugin implements Plugin<Project> {
}
}

// force the serialNumber to be reproducible by removing it & recalculating
// force the serialNumber to be reproducible by clearing it & recalculating.
// Mix the projectPath into the UUID seed so two modules whose post-processed
// BOM JSON happens to be identical (for example, empty BOM platforms with no
// runtime dependencies, or modules whose metadata.component is filled in
// identically by the CycloneDX plugin) still receive distinct serialNumbers
// as required by the CycloneDX specification. Including projectPath preserves
// reproducibility because the same project path + same content always yields
// the same UUID across rebuilds. This guards against collisions introduced by
// CycloneDX 3.0.0 / Gradle 9 metadata changes.
// See: https://cyclonedx.org/docs/1.6/json/#serialNumber
bom['serialNumber'] = ''
def withOutSerial = JsonOutput.prettyPrint(JsonOutput.toJson(bom))
def uuid = UUID.nameUUIDFromBytes(withOutSerial.getBytes(StandardCharsets.UTF_8.name()))
def withoutSerial = JsonOutput.prettyPrint(JsonOutput.toJson(bom))
def uuidSeed = "${projectPath}\n${withoutSerial}"
def uuid = UUID.nameUUIDFromBytes(uuidSeed.getBytes(StandardCharsets.UTF_8))
bom['serialNumber'] = "urn:uuid:$uuid".toString()

f.setText(JsonOutput.prettyPrint(JsonOutput.toJson(bom)), StandardCharsets.UTF_8.name())
Expand Down Expand Up @@ -384,4 +396,53 @@ class SbomPlugin implements Plugin<Project> {
}
}
}

/**
* Wires this project's own SBOM into the shadow jar produced by the
* com.gradleup.shadow plugin.
*
* Without this, shadow's first-wins merge picks up a META-INF/sbom.json
* from one of the bundled transitive jars (typically grails-shell-cli's),
* giving the fat jar the wrong serialNumber and metadata.component
* (it ends up describing grails-shell-cli rather than the fat jar's own
* project). Two fat jars that both bundle grails-shell-cli (e.g.
* :grails-cli and :grails-cli-shadow) then end up with byte-identical
* META-INF/sbom.json entries and identical urn:uuid serialNumbers,
* which violates the CycloneDX 1.6 specification.
*
* The fix is symmetrical with publishSbomForJarProjects: we exclude any
* META-INF/sbom.json that arrives from transitive dependencies during
* the shadow merge, then re-introduce this project's own SBOM (whose
* 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.

* 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.

project.plugins.withId('com.gradleup.shadow') {
project.afterEvaluate {
if (!project.findProperty('skipJavaComponent')) {
project.tasks.named('shadowJar').configure { Task t ->
Jar shadowJar = (Jar) t
shadowJar.dependsOn('cyclonedxDirectBom')
// Drop any META-INF/sbom.json that comes in via transitive jars during the merge.
shadowJar.exclude('META-INF/sbom.json')
// Re-introduce this project's own SBOM after the merge.
shadowJar.from(sbomOutputLocation) { CopySpec spec ->
spec.into('META-INF')
spec.rename {
'sbom.json'
}
}
shadowJar.manifest { Manifest manifest ->
manifest.attributes('Sbom-Location': 'META-INF/sbom.json')
manifest.attributes('Sbom-Format': 'CycloneDX')
}
}
}
}
}
}
}
6 changes: 6 additions & 0 deletions grails-forge/grails-cli-shadow/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ shadowJarTask.configure { ShadowJar it ->

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

// the bundled transitive META-INF/sbom.json files (typically grails-shell-cli's),
// producing a fat jar whose SBOM describes the wrong module and shares its serialNumber
// with whichever sibling jar happens to win the merge - violating CycloneDX 1.6.
'META-INF/sbom.json',
'about.html' // restatement of the Eclipse Distribution License - Version 1.0 for jakarta
)
}
Loading