How to Share Artifacts Between Projects with Gradle
Gradle provides a mechanism to share artifacts between projects in a flexible and maintainable way using variant-aware sharing. This allows consuming projects to select the appropriate artifact based on defined attributes, ensuring compatibility and correctness.
Why Use Variant-Aware Sharing?
Unlike simple project dependencies, variant-aware sharing provides:
-
Better encapsulation by exposing only intended artifacts.
-
Fine-grained control over artifact selection.
-
Support for multiple variants of the same artifact (e.g., debug vs. release builds).
Step 1: Configuring the Producer Project
Let’s take a producer project that produces a Java library:
plugins {
id("java-library")
}
plugins {
id("java-library")
}
To get started, you should check the existing variants of the producer project by running the outgoingVariants
task.
Our Java library produces the following variant for the runtimeElements
configuration:
$ ./gradlew :producer:outgoingVariants --variant runtimeElements
> Task :producer:outgoingVariants
--------------------------------------------------
Variant runtimeElements
--------------------------------------------------
Runtime elements for the 'main' feature.
Capabilities
- variant-sharing-example:producer:unspecified (default capability)
Attributes
- org.gradle.category = library
- org.gradle.dependency.bundling = external
- org.gradle.jvm.version = 17
- org.gradle.libraryelements = jar
- org.gradle.usage = java-runtime
Artifacts
- build/libs/producer.jar (artifactType = jar)
Specifically, the Java library produces a variant runtimeElements
with 5 attributes:
-
org.gradle.category
- this variant represents a library -
org.gradle.dependency.bundling
- the dependencies of this variant are found as jars (they are not, for example, repackaged inside the jar) -
org.gradle.jvm.version
- the minimum Java version this library supports is Java 11 -
org.gradle.libraryelements
- this variant contains all elements found in a jar (classes and resources) -
org.gradle.usage
- this variant is a Java runtime, therefore suitable for a Java compiler but also at runtime
Let’s say that the producer project defines an artifact (e.g., an instrumented JAR) that other projects can consume based on attributes. If we want the instrumented classes to be used in place of the existing variant when executing tests, we need to attach similar attributes to this variant.
In this case, the attribute we care about is org.gradle.libraryelements
.
This attribute explains what the variant contains.
The following task in the producer build file defines a custom JAR artifact with an instrumented classifier (e.g., producer-instrumented.jar)
:
// Register a custom JAR task that packages the output of the 'main' source set.
// This JAR will have a classifier of 'instrumented' to distinguish it from the default artifact.
val instrumentedJar by tasks.registering(Jar::class) {
archiveClassifier.set("instrumented")
from(sourceSets.main.get().output)
// Additional instrumentation processing could go here
}
// Register a custom JAR task that packages the output of the 'main' source set.
// This JAR will have a classifier of 'instrumented' to distinguish it from the default artifact.
def instrumentedJar = tasks.register("instrumentedJar", Jar) {
archiveClassifier.set("instrumented")
from(sourceSets.main.output)
// Additional instrumentation processing could go here
}
The producer build file also creates a new configuration which:
-
Provides an artifact variant for consumption.
-
This variant is a
runtime-library
artifact with the element typeinstrumented-jar
.
This allows other projects (consumers) to discover and request this specific artifact based on attributes:
configurations {
// Create a custom consumable configuration named 'instrumentedJars'
// This allows the producer to supply the instrumented JAR variant to other projects
consumable("instrumentedJars") {
// Assign attributes so that consuming projects can match on these
attributes {
// The unique attribute allows targeted selection
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named("instrumented-jar"))
}
}
}
// Add the custom JAR artifact to the 'instrumentedJars' configuration
artifacts {
add("instrumentedJars", instrumentedJar)
}
configurations {
// Create a custom consumable configuration named 'instrumentedJars'
// This allows the producer to supply the instrumented JAR variant to other projects
consumable("instrumentedJars") {
// Assign attributes so that consuming projects can match on these
attributes {
// The unique attribute allows targeted selection
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, "instrumented-jar"))
}
}
}
// Add the custom JAR artifact to the 'instrumentedJars' configuration
artifacts {
add("instrumentedJars", instrumentedJar)
}
This configuration ensures that only the correct artifacts are exposed and prevents accidental dependencies on internal tasks.
Step 2: Configuring the consumer project
In Gradle, it’s recommended to separate the configuration where you declare dependencies from the one where you resolve artifacts.
First, the consumer project creates two configurations:
-
instrumentedRuntimeDependencies
is used to declare a dependency on the producer. -
instrumentedRuntime
is a resolvable configuration that resolves all the dependencies declared ininstrumentedRuntimeDependencies
, includinginstrumentedJars
.
plugins {
id("application")
}
// This configuration is used to declare dependencies only.
// It is neither resolvable nor consumable.
val instrumentedRuntimeDependencies by configurations.dependencyScope("instrumentedRuntimeDependencies")
// This resolvable configuration is used to resolve the instrumented JAR files.
// It extends from the dependency-declaring configuration above.
val instrumentedRuntime by configurations.resolvable("instrumentedRuntime") {
// Wire the dependency declarations
extendsFrom(instrumentedRuntimeDependencies)
// These attributes must be compatible with the producer
attributes {
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named("instrumented-jar"))
}
}
plugins {
id("application")
}
// This configuration is used to declare dependencies only.
def instrumentedRuntimeDependencies = configurations.dependencyScope("instrumentedRuntimeDependencies")
// This resolvable configuration is used to resolve the instrumented JAR files.
def instrumentedRuntime = configurations.resolvable("instrumentedRuntime") {
// Wire the dependency declarations
extendsFrom(instrumentedRuntimeDependencies.get())
// These attributes must be compatible with the producer
attributes {
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, "instrumented-jar"))
}
}
Now, the consumer project declares a dependency on the producer and aligns its configuration with the desired variant by defining attributes. When the configuration is resolved, Gradle uses these attributes to select a compatible variant published by the producer.
Second, a dependency is added on the producer on instrumentedRuntimeDependencies
:
dependencies {
// Declare a project dependency on the producer's instrumented output
instrumentedRuntimeDependencies(project(":producer"))
}
dependencies {
// Declare a project dependency on the producer's instrumented output
add("instrumentedRuntimeDependencies", project(":producer"))
}
Finally, we use the artifact variant in a task.
The runWithInstrumentation
task runs the application using the resolved instrumented JAR from the producer:
tasks.register<JavaExec>("runWithInstrumentation") {
// Use the resolved instrumented classpath
classpath = instrumentedRuntime
mainClass.set("com.example.Main")
}
tasks.register("runWithInstrumentation",JavaExec) {
// Use the resolved instrumented classpath
classpath = configurations["instrumentedRuntime"]
mainClass.set("com.example.Main")
}
This setup ensures that the consumer resolves the correct variant without requiring knowledge of the producer’s implementation details.
A great way to check everything is working is by running the resolvableConfigurations
task on the consumer side:
$ ./gradlew consumer:resolvableConfigurations --configuration instrumentedRuntime
> Task :consumer:resolvableConfigurations
--------------------------------------------------
Configuration instrumentedRuntime
--------------------------------------------------
Attributes
- org.gradle.libraryelements = instrumented-jar
- org.gradle.usage = java-runtime
Step 3: Setting up Defaults
To ensure that Gradle doesn’t fail when resolving dependencies without an instrumented variant, we need to define a fallback. Without this fallback, Gradle would complain about missing variants for dependencies that do not provide instrumented classes. The fallback explicitly tells Gradle that it’s acceptable to use the regular JAR when an instrumented variant isn’t available.
This is done using a compatibility rule:
abstract class InstrumentedJarsRule: AttributeCompatibilityRule<LibraryElements> {
override fun execute(details: CompatibilityCheckDetails<LibraryElements>) = details.run {
if (consumerValue?.name == "instrumented-jar" && producerValue?.name == "jar") {
compatible()
}
}
}
abstract class InstrumentedJarsRule implements AttributeCompatibilityRule<LibraryElements> {
@Override
void execute(CompatibilityCheckDetails<LibraryElements> details) {
if (details.consumerValue.name == 'instrumented-jar' && details.producerValue.name == 'jar') {
details.compatible()
}
}
}
Which we declare on the attributes schema:
dependencies {
attributesSchema {
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE) {
compatibilityRules.add(InstrumentedJarsRule::class.java)
}
}
}
dependencies {
attributesSchema {
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE) {
compatibilityRules.add(InstrumentedJarsRule)
}
}
}
Step 4: Troubleshooting
If the consumer fails to resolve the artifact, check:
-
The attributes in the consumer are compatible with those in the producer.
-
The producer project properly declares the artifact.
-
There are no conflicting configurations with different attributes.
Summary
There are two perspectives to keep in mind:
-
Configuring a project: You configure configurations, which declare how your build consumes or produces artifacts.
-
Referencing a project: Gradle resolves variants published by other projects, selecting the best match based on compatibility with the consuming configuration’s attributes.
Variant-aware sharing enables clean and flexible artifact sharing between projects. It avoids hardcoded task dependencies and improves build maintainability.