Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions api/shadow.api
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,24 @@ public class com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFile
public fun transform (Lcom/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext;)V
}

public class com/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformer : com/github/jengelman/gradle/plugins/shadow/transformers/PatternFilterableResourceTransformer {
public static final field Companion Lcom/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformer$Companion;
public static final field PATH_SPRING_AUTOCONFIGURE_METADATA Ljava/lang/String;
public static final field PATH_SPRING_FACTORIES Ljava/lang/String;
public static final field PATH_SPRING_HANDLERS Ljava/lang/String;
public static final field PATH_SPRING_SCHEMAS Ljava/lang/String;
public static final field PATH_SPRING_TOOLING Ljava/lang/String;
public fun <init> ()V
public fun <init> (Lorg/gradle/api/tasks/util/PatternSet;)V
public synthetic fun <init> (Lorg/gradle/api/tasks/util/PatternSet;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun hasTransformedResource ()Z
public fun modifyOutputStream (Lorg/apache/tools/zip/ZipOutputStream;Z)V
public fun transform (Lcom/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext;)V
}

public final class com/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformer$Companion {
}

public final class com/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext {
public static final field Companion Lcom/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext$Companion;
public fun <init> (Ljava/lang/String;Ljava/io/InputStream;)V
Expand Down
4 changes: 4 additions & 0 deletions docs/changes/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# Change Log


## [Unreleased](https://github.com/GradleUp/shadow/compare/9.3.3...HEAD) - 2026-xx-xx

Check failure on line 4 in docs/changes/README.md

View workflow job for this annotation

GitHub Actions / Linkspector

[linkspector] docs/changes/README.md#L4

Raw output
message:"Cannot reach https://github.com/GradleUp/shadow/compare/9.3.3...HEAD Status: 404"  location:{path:"docs/changes/README.md"  range:{start:{line:4  column:4}  end:{line:4  column:73}}}  severity:ERROR  source:{name:"linkspector"  url:"https://github.com/UmbrellaDocs/linkspector"}

### Added

- Add `SpringBootTransformer` to handle Spring Boot configuration files merging. ([#1936](https://github.com/GradleUp/shadow/pull/1936))


## [9.3.2](https://github.com/GradleUp/shadow/releases/tag/9.3.2) - 2026-02-27

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package com.github.jengelman.gradle.plugins.shadow.transformers

import assertk.assertThat
import assertk.assertions.isEqualTo
import com.github.jengelman.gradle.plugins.shadow.testkit.getContent
import com.github.jengelman.gradle.plugins.shadow.testkit.invariantEolString
import com.github.jengelman.gradle.plugins.shadow.transformers.SpringBootTransformer.Companion.PATH_SPRING_FACTORIES
import com.github.jengelman.gradle.plugins.shadow.transformers.SpringBootTransformer.Companion.PATH_SPRING_HANDLERS
import kotlin.io.path.appendText
import org.junit.jupiter.api.Test

class SpringBootTransformerTest : BaseTransformerTest() {

@Test
fun mergeSpringFactories() {
val one = buildJarOne {
insert(
PATH_SPRING_FACTORIES,
"org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.FooAutoConfiguration",
)
}
val two = buildJarTwo {
insert(
PATH_SPRING_FACTORIES,
"org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.BarAutoConfiguration",
)
}
projectScript.appendText(
transform<SpringBootTransformer>(dependenciesBlock = implementationFiles(one, two))
)

runWithSuccess(shadowJarPath)

val content = outputShadowedJar.use { it.getContent(PATH_SPRING_FACTORIES) }
assertThat(content.invariantEolString)
.isEqualTo(
"org.springframework.boot.autoconfigure.EnableAutoConfiguration=" +
"com.example.FooAutoConfiguration,com.example.BarAutoConfiguration\n"
)
}

@Test
fun mergeSpringFactoriesWithMultipleKeys() {
val one = buildJarOne {
insert(
PATH_SPRING_FACTORIES,
"org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.FooAutoConfiguration\n" +
"org.springframework.context.ApplicationListener=com.example.FooListener",
)
}
val two = buildJarTwo {
insert(
PATH_SPRING_FACTORIES,
"org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.BarAutoConfiguration\n" +
"org.springframework.context.ApplicationListener=com.example.BarListener",
)
}
projectScript.appendText(
transform<SpringBootTransformer>(dependenciesBlock = implementationFiles(one, two))
)

runWithSuccess(shadowJarPath)

val content = outputShadowedJar.use { it.getContent(PATH_SPRING_FACTORIES) }
assertThat(content.invariantEolString)
.isEqualTo(
"org.springframework.boot.autoconfigure.EnableAutoConfiguration=" +
"com.example.FooAutoConfiguration,com.example.BarAutoConfiguration\n" +
"org.springframework.context.ApplicationListener=" +
"com.example.FooListener,com.example.BarListener\n"
)
}

@Test
fun mergeSpringImports() {
val one = buildJarOne {
insert(
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
"com.example.FooAutoConfiguration",
)
}
val two = buildJarTwo {
insert(
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
"com.example.BarAutoConfiguration",
)
}
projectScript.appendText(
transform<SpringBootTransformer>(dependenciesBlock = implementationFiles(one, two))
)

runWithSuccess(shadowJarPath)

val content =
outputShadowedJar.use {
it.getContent(
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports"
)
}
assertThat(content.invariantEolString)
.isEqualTo("com.example.FooAutoConfiguration\ncom.example.BarAutoConfiguration")
}

@Test
fun deduplicateSpringImports() {
val one = buildJarOne {
insert(
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
"com.example.FooAutoConfiguration\ncom.example.BarAutoConfiguration",
)
}
val two = buildJarTwo {
insert(
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
"com.example.BarAutoConfiguration\ncom.example.BazAutoConfiguration",
)
}
projectScript.appendText(
transform<SpringBootTransformer>(dependenciesBlock = implementationFiles(one, two))
)

runWithSuccess(shadowJarPath)

val content =
outputShadowedJar.use {
it.getContent(
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports"
)
}
assertThat(content.invariantEolString)
.isEqualTo(
"com.example.FooAutoConfiguration\ncom.example.BarAutoConfiguration\ncom.example.BazAutoConfiguration"
)
}

@Test
fun mergeSpringHandlers() {
val one = buildJarOne {
insert(
PATH_SPRING_HANDLERS,
"http\\://www.example.com/schema/foo=com.example.FooNamespaceHandler",
)
}
val two = buildJarTwo {
insert(
PATH_SPRING_HANDLERS,
"http\\://www.example.com/schema/bar=com.example.BarNamespaceHandler",
)
}
projectScript.appendText(
transform<SpringBootTransformer>(dependenciesBlock = implementationFiles(one, two))
)

runWithSuccess(shadowJarPath)

val content = outputShadowedJar.use { it.getContent(PATH_SPRING_HANDLERS) }
assertThat(content.invariantEolString)
.isEqualTo(
"http\\://www.example.com/schema/bar=com.example.BarNamespaceHandler\n" +
"http\\://www.example.com/schema/foo=com.example.FooNamespaceHandler\n"
)
}

@Test
fun relocateClassesInSpringFactories() {
val one = buildJarOne {
insert(PATH_SPRING_FACTORIES, "com.example.SomeInterface=com.example.SomeImplementation")
}
projectScript.appendText(
"""
dependencies {
${implementationFiles(one)}
}
$shadowJarTask {
transform(${SpringBootTransformer::class.java.name})
relocate('com.example', 'shadow.example')
}
"""
.trimIndent()
)

runWithSuccess(shadowJarPath)

val content = outputShadowedJar.use { it.getContent(PATH_SPRING_FACTORIES) }
assertThat(content.invariantEolString)
.isEqualTo("shadow.example.SomeInterface=shadow.example.SomeImplementation\n")
}

@Test
fun relocateClassesInSpringImports() {
val one = buildJarOne {
insert(
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
"com.example.FooAutoConfiguration",
)
}
projectScript.appendText(
"""
dependencies {
${implementationFiles(one)}
}
$shadowJarTask {
transform(${SpringBootTransformer::class.java.name})
relocate('com.example', 'shadow.example')
}
"""
.trimIndent()
)

runWithSuccess(shadowJarPath)

val content =
outputShadowedJar.use {
it.getContent(
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports"
)
}
assertThat(content.invariantEolString).isEqualTo("shadow.example.FooAutoConfiguration")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.github.jengelman.gradle.plugins.shadow.transformers

import com.github.jengelman.gradle.plugins.shadow.internal.ReproducibleProperties
import com.github.jengelman.gradle.plugins.shadow.internal.zipEntry
import com.github.jengelman.gradle.plugins.shadow.relocation.Relocator
import com.github.jengelman.gradle.plugins.shadow.relocation.relocateClass
import com.github.jengelman.gradle.plugins.shadow.relocation.relocatePath
import java.util.Properties
import org.apache.tools.zip.ZipOutputStream
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.util.PatternSet

/**
* A resource transformer that handles Spring Boot configuration files to enable proper merging when
* creating a shadow JAR.
*
* The following Spring Boot resource files are handled:
* - `META-INF/spring.factories`: Properties file with comma-separated class name values, merged by
* appending values with a comma separator.
* - `META-INF/spring.handlers`: Properties file with class name values, merged by appending with a
* comma separator.
* - `META-INF/spring.schemas`: Properties file with schema URL-to-path mappings, merged by
* appending with a comma separator.
* - `META-INF/spring.tooling`: Properties file with tooling metadata, merged by appending with a
* comma separator.
* - `META-INF/spring-autoconfigure-metadata.properties`: Properties file with autoconfiguration
* metadata, merged by appending with a comma separator.
* - `META-INF/spring/`*`.imports`: Line-based files where each line is a fully qualified class
* name; lines are deduplicated and merged across JAR files.
*
* Class relocation is applied to both the keys and values of properties files (using path-based
* relocation for slash-notation values, and class-based relocation for dot-notation values), as
* well as to each line of `.imports` files.
*
* @see <a href="https://github.com/GradleUp/shadow/issues/1489">Issue #1489</a>
*/
@CacheableTransformer
public open class SpringBootTransformer
@JvmOverloads
constructor(
patternSet: PatternSet =
PatternSet()
.include(PATH_SPRING_FACTORIES)
.include(PATH_SPRING_HANDLERS)
.include(PATH_SPRING_SCHEMAS)
.include(PATH_SPRING_TOOLING)
.include(PATH_SPRING_AUTOCONFIGURE_METADATA)
.include("$SPRING_IMPORTS_PREFIX*.imports")
) : PatternFilterableResourceTransformer(patternSet = patternSet) {
@get:Internal internal val propertiesEntries = mutableMapOf<String, ReproducibleProperties>()

@get:Internal internal val importsEntries = mutableMapOf<String, LinkedHashSet<String>>()

override fun transform(context: TransformerContext) {
val path = context.path
if (isImportsFile(path)) {
val entries = importsEntries.getOrPut(path) { linkedSetOf() }
context.inputStream
.bufferedReader()
.lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.map { context.relocators.relocateClass(it) }
.forEach { entries.add(it) }
} else {
val props = propertiesEntries.getOrPut(path) { ReproducibleProperties() }
val incoming =
Properties().apply { load(context.inputStream.bufferedReader(PROPERTIES_CHARSET)) }
incoming.forEach { rawKey, rawValue ->
val key = context.relocators.relocateClass(rawKey as String)
val value =
(rawValue as String).splitToSequence(",").joinToString(",") { part ->
context.relocators.relocateValue(part.trim())
}
val existing = props.getProperty(key)
if (existing != null) {
props[key] = "$existing,$value"
} else {
props[key] = value
}
}
}
}

override fun hasTransformedResource(): Boolean =
propertiesEntries.isNotEmpty() || importsEntries.isNotEmpty()

override fun modifyOutputStream(os: ZipOutputStream, preserveFileTimestamps: Boolean) {
propertiesEntries.forEach { (path, props) ->
os.putNextEntry(zipEntry(path, preserveFileTimestamps))
props.writeWithoutComments(PROPERTIES_CHARSET, os)
os.closeEntry()
}
importsEntries.forEach { (path, entries) ->
os.putNextEntry(zipEntry(path, preserveFileTimestamps))
os.write(entries.joinToString("\n").toByteArray())
os.closeEntry()
}
}

public companion object {
public const val PATH_SPRING_FACTORIES: String = "META-INF/spring.factories"
public const val PATH_SPRING_HANDLERS: String = "META-INF/spring.handlers"
public const val PATH_SPRING_SCHEMAS: String = "META-INF/spring.schemas"
public const val PATH_SPRING_TOOLING: String = "META-INF/spring.tooling"
public const val PATH_SPRING_AUTOCONFIGURE_METADATA: String =
"META-INF/spring-autoconfigure-metadata.properties"

private const val SPRING_IMPORTS_PREFIX = "META-INF/spring/"

private val PROPERTIES_CHARSET = Charsets.ISO_8859_1

internal fun isImportsFile(path: String): Boolean =
path.startsWith(SPRING_IMPORTS_PREFIX) && path.endsWith(".imports")

/**
* Relocates a value that may be either a dot-notation class name (e.g., `com.example.MyClass`)
* or a slash-notation resource path (e.g., `com/example/schema.xsd`). Path-notation values are
* relocated using [Iterable.relocatePath], and class-notation values using
* [Iterable.relocateClass].
*/
private fun Iterable<Relocator>.relocateValue(value: String): String {
return if (value.contains('/')) relocatePath(value) else relocateClass(value)
}
}
}
Loading