Cross-subproject dependency on JavaCompile task outputs

My project has an :individuals subproject with a custom SourceSet named testData, compiled via the corresponding JavaCompile task named compileTestDataJava. A second subproject, :aggregate, has a Jar task named testDataJar that should include all bytecode files produced by the :individuals:compileTestDataJava task.

Gradle’s documentation sternly discourages referencing tasks from other subprojects, so I am trying to use configurations instead:

  • individuals/build.gradle.kts

    plugins { java }
    
    val testData by sourceSets.creating {}
    
    val provide by
        configurations.creating {
          isCanBeConsumed = true
          isCanBeResolved = false
        }
    
    artifacts { add(provide.name, tasks.named("compileTestDataJava")) }
    
  • aggregate/build.gradle.kts

    plugins { java }
    
    val consume by
        configurations.creating {
          isCanBeConsumed = false
          isCanBeResolved = true
        }
    
    dependencies { consume(project(mapOf("path" to ":individuals", "configuration" to "provide"))) }
    
    tasks.register<Jar>("testDataJar") {
      group = "build"
      archiveBaseName.set("testData")
      from(consume)
    }
    

Unfortunately, this approach fails when trying to create the :individuals:assemble task:

FAILURE: Build failed with an exception.

* What went wrong:
Could not determine the dependencies of task ':individuals:build'.
> Could not create task ':individuals:assemble'.
   > Expected task 'compileTestDataJava' output files to contain exactly one file, however, it contains more than one file.

I changed the artifacts block to use just the single destinationDirectory of the JavaCompile task:

artifacts {
  add(provide.name, tasks.named<JavaCompile>("compileTestDataJava").map { it.destinationDirectory })
}

This approach runs without failure, but the generated aggregate.jar archive contains no bytecode files, just a bare-bones META-INF/MANIFEST.MF.


What is the right way for a subproject to create a consumable configuration containing the multiple bytecode files created by a JavaCompile task, and then to consume that configuration in some other subproject?

testData sounds like you might simply want to use the java-test-fixtures plugin: Testing in Java & JVM projects

The complete project is a code analysis tool. The testData classes truly are test data, not test-supporting fixtures. The byte code files produced by compileTestDataJava should not even be added to the class path when running tests. Thus, the java-test-fixtures plugin is not a suitable solution.

If you really want to add the single files, iirc you have to iterate through the files and add each as single artifact or something like that. You could probably look at the java-library plugin that adds the classes secondary variant.

Other than that, you could add the jar as artifact to your configuration and then unpack them on the consumer side if you need them unpacked.

You can also have a look at the java-test-fixtures plugin and add a feature variant like it does it for your use-case.

@Vampire suggested:

Other than that, you could add the jar as artifact to your configuration and then unpack them on the consumer side if you need them unpacked.

I like the simplicity of this idea, and I don’t mind putting things into a jar just to copy them out again later. Nice. Thank you for this suggestion!

On the consuming side, I tried using the configuration to create a zipTree in the obvious way:

val consume by
    configurations.creating {
      isCanBeConsumed = false
      isCanBeResolved = true
    }

...

tasks.register<Jar>("testDataJar") {
  group = "build"
  archiveBaseName.set("testData")
  from(zipTree(consume)) { include("**/*.class") }
}

Sadly, this approach fails:

Execution failed for task ':aggregate:testDataJar'.
> Cannot fingerprint input file property 'rootSpec$1$1': Cannot convert the provided notation to a File or URI: configuration ':aggregate:consume'.
  The following types/formats are supported:
    - A String or CharSequence path, for example 'src/main/java' or '/usr/include'.
    - A String or CharSequence URI, for example 'file:/usr/include'.
    - A File instance.
    - A Path instance.
    - A Directory instance.
    - A RegularFile instance.
    - A URI or URL instance.
    - A TextResource instance.

Everything works if I explicitly extract the configuration’s single file, and also add the inputs.files dependency explicitly:

tasks.register<Jar>("testDataJar") {
  group = "build"
  archiveBaseName.set("testData")
  from(zipTree(consume.files.single())) { include("**/*.class") }
  inputs.files(consume)
}

However, this feels inelegant. Is there a more concise approach that gets all of its dependencies from from without needing help from an explicit inputs.files?

The elegant way would be an atrifact transform that unpacks that zips, that way you then again get it unpacked from the configuration already and it would also work if you declare multiple such dependencies on one configuration: Transforming dependency artifacts on resolution

@Vampire’s suggested “elegant way” is a bit more involved than I want to take on right now, but I may revisit it in the future. Until then, my version of his earlier suggestion is working quite nicely.

Big thanks, @Vampire. This was the last puzzle I needed to solve in order to complete a big revamp, with 2700 lines of Kotlin build logic replacing 2400 lines of Groovy build logic. Your suggestions also forced me to finally learn more about how Gradle Configurations work: knowledge I’m already putting to good use elsewhere. Gold star to @Vampire! :star2:

1 Like