From 2b9f9a7f64221814500c6e5765ffa90e69d79660 Mon Sep 17 00:00:00 2001 From: David O'Sullivan Date: Tue, 1 Sep 2026 13:24:13 +0100 Subject: [PATCH 1/2] Fix java-cfenv-all uber jar broken by Shadow 9 upgrade Fixes #470. The Shadow 8.3.9 -> 9.6.1 upgrade changed two behaviours that the build was not updated for, producing broken `java-cfenv-all` artifacts in 3.5.2 and 4.0.1. Shadow 9 defaults `duplicatesStrategy` to EXCLUDE, and duplicates are dropped before transformers run. `java-cfenv-all` ships its own `META-INF/spring.factories`, so it won that race and every dependency's copy was discarded before `PropertiesFileTransformer` could merge them. The published jar therefore only registered `CloudProfileApplicationListener`, and both `CfDataSourceEnvironmentPostProcessor` and `CfEnvironmentPostProcessor` were missing, so `spring.datasource.url` was never derived from `VCAP_SERVICES`. Setting `duplicatesStrategy` to INCLUDE lets the transformer see and merge every copy. Shadow 9 also no longer treats empty name/version segments in the `dependency(String)` notation as wildcards, so `dependency('org.springframework.boot::')` matched nothing and Spring Boot and Spring Framework were bundled into the uber jar. On Spring Boot 3+ that causes a classloader identity conflict. Using the explicit `:.*:.*` form restores the intended exclusion. The resulting jar drops from 7.9M to 1.7M, contains no `org/springframework` classes, and its `spring.factories` matches the last good release (4.0.0) exactly. Co-Authored-By: Claude Opus 5 (1M context) --- java-cfenv-all/build.gradle | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/java-cfenv-all/build.gradle b/java-cfenv-all/build.gradle index 3cbf40b..4a40f95 100644 --- a/java-cfenv-all/build.gradle +++ b/java-cfenv-all/build.gradle @@ -23,6 +23,7 @@ dependencies { shadowJar { archiveClassifier.set('') + duplicatesStrategy = DuplicatesStrategy.INCLUDE mergeServiceFiles() transform(PropertiesFileTransformer) { paths = ['META-INF/spring.factories'] @@ -79,8 +80,8 @@ shadowJar { } }) dependencies { - exclude(dependency('org.springframework.boot::')) - exclude(dependency('org.springframework::')) + exclude(dependency('org.springframework.boot:.*:.*')) + exclude(dependency('org.springframework:.*:.*')) } relocate 'com.cedarsoftware.io', 'io.pivotal.cfenv.shaded.com.cedarsoftware.io' } From b4c3ed94c26ab2e53202932746f6321bd49c37af Mon Sep 17 00:00:00 2001 From: David O'Sullivan Date: Tue, 1 Sep 2026 15:33:28 +0100 Subject: [PATCH 2/2] Verify the uber jar contents as part of check The Shadow 9 breakage in #470 shipped in two releases while every unit test passed, because nothing inspected the packaged artifact. Add a `verifyUberJar` task, wired into `check`, that opens the uber jar and asserts what actually has to hold: - no bundled `org/springframework` classes - `com.cedarsoftware.io` relocated, with the shaded classes present - `spring.factories` contains the entries contributed by each module, including both EnvironmentPostProcessors - `spring.factories` does not re-register Spring Boot's own post-processors, which happens if the factories are merged while Spring Boot is still bundled Reverting either half of the fix fails the task, as does reverting both (the state that shipped in 3.5.2 and 4.0.1). Also pin `shadowJar` to run after `jar`. Both write the same archive name because `archiveClassifier` is empty, so which one survives on disk was left to task scheduling; Gradle flags this as an implicit dependency once another task reads the artifact. Note java-util (`com.cedarsoftware.util`) is deliberately not relocated, matching the last good releases, so the check is scoped to `com.cedarsoftware.io`. --- java-cfenv-all/build.gradle | 108 ++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/java-cfenv-all/build.gradle b/java-cfenv-all/build.gradle index 4a40f95..7e9d315 100644 --- a/java-cfenv-all/build.gradle +++ b/java-cfenv-all/build.gradle @@ -86,6 +86,114 @@ shadowJar { relocate 'com.cedarsoftware.io', 'io.pivotal.cfenv.shaded.com.cedarsoftware.io' } +// Guards #470: the Shadow 9 upgrade silently produced an uber jar with an +// unmerged spring.factories and bundled Spring classes. No unit test could +// see it, so verify the packaged artifact itself. +// shadowJar and jar share one archive name (archiveClassifier is ''), so pin +// the order to guarantee the uber jar is what ends up on disk rather than +// leaving it to task scheduling. +tasks.shadowJar.mustRunAfter(tasks.jar) + +def uberJar = tasks.shadowJar.archiveFile + +def verifyUberJar = tasks.register('verifyUberJar') { + group = 'verification' + description = 'Checks the uber jar merges spring.factories and bundles no Spring classes' + + dependsOn tasks.jar, tasks.shadowJar + inputs.file(uberJar).withPropertyName('uberJar') + def stamp = layout.buildDirectory.file('verifyUberJar/passed.txt') + outputs.file(stamp) + + // Spring Boot 3 registers post-processors under + // org.springframework.boot.env; Spring Boot 4 moved the interface up to + // org.springframework.boot. Accept whichever this branch targets so the + // same check works on 3.x and main. + def eppKeys = ['org.springframework.boot.EnvironmentPostProcessor', + 'org.springframework.boot.env.EnvironmentPostProcessor'] + def requiredEpps = ['io.pivotal.cfenv.spring.boot.CfDataSourceEnvironmentPostProcessor', + 'io.pivotal.cfenv.spring.boot.CfEnvironmentPostProcessor'] + + def required = [ + 'org.springframework.context.ApplicationListener': [ + 'io.pivotal.cfenv.profile.CloudProfileApplicationListener'], + 'io.pivotal.cfenv.spring.boot.CfEnvProcessor' : [ + 'io.pivotal.cfenv.spring.boot.RedisCfEnvProcessor', + 'io.pivotal.cfenv.boot.scs.CfConfigClientProcessor', + 'io.pivotal.cfenv.boot.sso.CfSingleSignOnProcessor'], + ] + + doLast { + def jarFile = uberJar.get().asFile + def problems = [] + + new java.util.zip.ZipFile(jarFile).withCloseable { zip -> + def names = Collections.list(zip.entries())*.name + + // Spring must stay out of the uber jar; bundling it causes a + // classloader identity conflict on Spring Boot 3+. + def bundledSpring = names.findAll { it.startsWith('org/springframework/') && it.endsWith('.class') } + if (!bundledSpring.isEmpty()) { + problems << "bundles ${bundledSpring.size()} Spring class(es), e.g. ${bundledSpring.take(3)}" + } + + // Fix #275: json-io must be relocated, not shipped under its own + // package. Only com.cedarsoftware.io is relocated; java-util + // (com.cedarsoftware.util) is deliberately left alone. + def unrelocated = names.findAll { it.startsWith('com/cedarsoftware/io/') } + if (!unrelocated.isEmpty()) { + problems << "ships ${unrelocated.size()} unrelocated json-io class(es)" + } + if (!names.any { it.startsWith('io/pivotal/cfenv/shaded/com/cedarsoftware/io/') }) { + problems << 'is missing the relocated json-io classes' + } + + // Every module's spring.factories must be merged in, not overwritten. + def entry = zip.getEntry('META-INF/spring.factories') + if (entry == null) { + problems << 'has no META-INF/spring.factories' + } + else { + def props = new Properties() + zip.getInputStream(entry).withCloseable { props.load(it) } + required.each { key, values -> + def actual = (props.getProperty(key) ?: '').split(',')*.trim() as Set + def missing = values.findAll { !actual.contains(it) } + if (!missing.isEmpty()) { + problems << "spring.factories '${key}' is missing ${missing}" + } + } + def eppKey = eppKeys.find { props.getProperty(it) != null } + if (eppKey == null) { + problems << "spring.factories registers no EnvironmentPostProcessor (looked for ${eppKeys})" + } + else { + def registered = props.getProperty(eppKey).split(',')*.trim() as Set + def missingEpps = requiredEpps.findAll { !registered.contains(it) } + if (!missingEpps.isEmpty()) { + problems << "spring.factories '${eppKey}' is missing ${missingEpps}" + } + // Merging must not pull in Spring Boot's own registrations. + def leaked = registered.findAll { it.startsWith('org.springframework.') } + if (!leaked.isEmpty()) { + problems << "spring.factories re-registers Spring Boot's own post-processors ${leaked.sort()}" + } + } + } + } + + if (!problems.isEmpty()) { + throw new GradleException("${jarFile.name} is not a valid uber jar:\n - " + problems.join('\n - ')) + } + + def out = stamp.get().asFile + out.parentFile.mkdirs() + out.text = 'passed' + } +} + +tasks.named('check') { dependsOn verifyUberJar } + publishing { publications { shadow(MavenPublication) { publication ->