Henning Habighorst
05/15/2025, 12:23 PM* What went wrong:
Could not determine the dependencies of task ':projectVersionExport'.
> Could not resolve all dependencies for configuration ':projectVersionExportConfig'.
> Could not resolve project :test-kit.
Required by:
root project :
> A dependency was declared on configuration 'projectVersionExportSubConfig' of 'project :test-kit' but no variant with that configuration name exists.
Is there any way to find out what added the dependency for this configuration? Because it seems as if Gradle added the dependency by itself.Vampire
05/15/2025, 12:38 PMprojectVersionExportSubConfig
or projectVersionExport
, so it is quite unlikely that something outside your buildscript is adding that, especially not Gradle itself. š
Also depending on a named configuration in another project is considered bad practice, which also supports that it is quite unlikely that Gradle added it by itself.
Check the dependency declarations in your root project's build script.
If you don't find it, maybe one of your plugins is doing that, you could for example try to remove one by one to see where it comes from.Henning Habighorst
05/15/2025, 12:50 PMval subProjectTaskConfig = AbramsProjectVersionExport.createSubProjectTaskConfiguration(project)
val subProjectTask = AbramsProjectVersionExport.registerSubProjectTask(project)
artifacts {
add(subProjectTaskConfig.name, subProjectTask)
}
if (project == project.rootProject) {
val rootProjectConfig = AbramsProjectVersionExport.createRootProjectTaskConfiguration(project)
val subProjects = if (project.subprojects.isEmpty()) listOf(project) else project.subprojects
subProjects.forEach { subProject ->
val depProvider = project.provider {
val config = subProject.configurations.findByName(SUB_EXPORT_TASK_CONFIG)
if (config != null) {
project.dependencies.project(
mapOf(
"path" to subProject.path,
"configuration" to SUB_EXPORT_TASK_CONFIG,
),
)
} else {
null
}
}
depProvider.orNull?.let {
project.dependencies.addProvider(rootProjectConfig.name, project.provider { it })
}
}
AbramsProjectVersionExport.registerRootProjectTask(rootProjectConfig, project)
}
Is the project-version-export Plugin. It is added to all projects - except test-kit.
I tried to avoid adding the dependency to the root project if it doesn't exist in the child project...
But it seems I'm missing something. My problem is I cannot understand where or how the configuration is formed. It'd be great to know how the dependency was added. Is there a way to get Gradle to give more insight into the dependency / resolution process?Henning Habighorst
05/15/2025, 12:51 PMpackage zone.abrams.gradle.plugins.convention.internal
import org.gradle.api.NamedDomainObjectProvider
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.artifacts.Configuration
import org.gradle.api.file.FileCollection
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.TaskProvider
class AbramsProjectVersionExport {
companion object {
const val SUB_EXPORT_TASK_CONFIG: String = "projectVersionExportSubConfig"
private const val SUB_EXPORT_TASK_NAME: String = "projectVersionExportSub"
private const val SUB_EXPORT_FILE_NAME: String = "projectVersionExportSub.env"
private const val ROOT_EXPORT_FILE_NAME: String = "projectVersionExport.env"
private const val ROOT_EXPORT_TASK_CONFIG: String = "projectVersionExportConfig"
private const val ROOT_EXPORT_TASK_NAME: String = "projectVersionExport"
private const val EXPORT_TASK_GROUP: String = "Abrams Build"
private const val EXPORT_TASK_DESCRIPTION: String = "Export the project version to a bash env file."
fun createSubProjectTaskConfiguration(project: Project): NamedDomainObjectProvider<Configuration> =
project.configurations.register(SUB_EXPORT_TASK_CONFIG) {
isCanBeResolved = false
isTransitive = false
description = "Collects version export files from subprojects"
}
fun createRootProjectTaskConfiguration(project: Project): NamedDomainObjectProvider<Configuration> =
project.configurations.register(ROOT_EXPORT_TASK_CONFIG) {
isCanBeConsumed = false
isTransitive = false
description = "Aggregates version export files from subprojects"
}
fun registerRootProjectTask(
configuration: NamedDomainObjectProvider<Configuration>,
project: Project,
): TaskProvider<Task> = project.tasks.register(ROOT_EXPORT_TASK_NAME) {
val sharedFilesProvider: Provider<FileCollection> = configuration.map { it as FileCollection }
val versionExportFileProvider: Provider<RegularFile> =
project.layout.buildDirectory.file(ROOT_EXPORT_FILE_NAME)
inputs.files(sharedFilesProvider)
outputs.file(versionExportFileProvider)
group = EXPORT_TASK_GROUP
description = EXPORT_TASK_DESCRIPTION
doFirst {
val outputFile = versionExportFileProvider.get().asFile
val content = sharedFilesProvider
.get()
.filter { it.exists() && it.isFile }
.joinToString(separator = "\n") { file -> file.readText() }
outputFile.writeText(content)
logger.lifecycle("Written version file export: '{}'", outputFile.absolutePath)
}
}
fun registerSubProjectTask(project: Project): TaskProvider<Task> =
project.tasks.register(SUB_EXPORT_TASK_NAME) {
inputs.property("projectName", project.provider { project.name })
inputs.property("projectVersion", project.provider { project.version })
val versionExportFile = project.layout.buildDirectory.file(SUB_EXPORT_FILE_NAME)
outputs.file(versionExportFile)
group = EXPORT_TASK_GROUP
description = EXPORT_TASK_DESCRIPTION
doLast {
versionExportFile.get().asFile.writeText(
"${inputs.properties["projectName"]}=${inputs.properties["projectVersion"]}",
)
}
}
}
}
Just for full reference the tasksVampire
05/15/2025, 12:59 PMsubProject.configurations
, that is almost as bad as doing cross-project configuration.
Either list the projects you want to depend on manually, or if that is not an option you can for example make sure that all projects - including test-kit
- have such a variant even if it does not contain any artifacts, or if that is not an option maybe an artifact view would also be a possibility, as an artifact view on which you select different attributes is allowed to not find an artifact for all dependencies but only returns the ones that have it.
As I just mentioned, depending on a configuration of another project by name is also discouraged bad practice and you should use attribute-aware resolving like documented at https://docs.gradle.org/current/userguide/how_to_share_outputs_between_projects.html.Henning Habighorst
05/15/2025, 1:06 PMVampire
05/15/2025, 1:47 PMVampire
05/15/2025, 1:48 PMHenning Habighorst
05/15/2025, 1:48 PMVampire
05/15/2025, 1:49 PMVampire
05/15/2025, 1:50 PMHenning Habighorst
05/15/2025, 2:41 PMabstract class AbramsProjectVersionExportBuildService :
BuildService<BuildServiceParameters.None>,
AutoCloseable {
private val versions = ConcurrentHashMap<String, String>()
fun registerProject(name: String, version: String) {
versions[name] = version
}
fun getEntries(): Map<String, String> = versions.toSortedMap()
override fun close() {
// No-op
}
}
abstract class AbramsProjectVersionExportRootTask : DefaultTask() {
@get:OutputFile
abstract val outputFile: RegularFileProperty
@get:Internal
abstract val buildService: Property<AbramsProjectVersionExportBuildService>
@TaskAction
fun writeEnvFile() {
val entries = buildService.get().getEntries()
val content = entries.entries.joinToString("\n") { (name, version) -> "$name=$version" }
outputFile.get().asFile.writeText(content)
logger.lifecycle("Version export written to: ${outputFile.get().asFile.absolutePath}")
}
}
abstract class AbramsProjectVersionExportSubTask : DefaultTask() {
@get:Input
abstract val projectName: Property<String>
@get:Input
abstract val projectVersion: Property<String>
@get:Internal
abstract val buildService: Property<AbramsProjectVersionExportBuildService>
@TaskAction
fun export() {
buildService.get().registerProject(projectName.get(), projectVersion.get())
}
}
as classes.
convention-plugin code based on classes:
val serviceProvider = project.gradle.sharedServices.registerIfAbsent(
"abramsProjectVersionExportBuildService",
AbramsProjectVersionExportBuildService::class.java,
) {}
if (project == project.rootProject) {
project.tasks.register("projectVersionExport", AbramsProjectVersionExportRootTask::class.java) {
group = "Abrams Build"
description = "Export all registered project versions to a bash env file."
outputFile.set(project.layout.buildDirectory.file("projectVersionExport.env"))
buildService.set(serviceProvider)
project.subprojects.forEach { subProject ->
subProject.tasks.findByName("projectVersionExportSub")?.let {
dependsOn(it)
}
}
}
} else {
project.tasks.register("projectVersionExportSub", AbramsProjectVersionExportSubTask::class.java) {
group = "Abrams Build"
description = "Register project version in the shared export build service."
projectName.set(project.name)
projectVersion.set(project.version.toString())
buildService.set(serviceProvider)
}
}
That does work. Just to make sure that I didn't add yet another brainfart...
projectVersionExport depends on other tasks - as the subprojects have to run projectVersionExportSub.
Is that safe or is that yet another booby trap in the future?Vampire
05/15/2025, 3:08 PMbuildService
should not be @Internal
but @ServiceReference
, or alternatively you also need to manually declare usesService
for the tasks that use the service.
⢠With the @ServiceReference
you then also do not need to manually wire the service in, but Gradle will automatically inject it
⢠The AbramsProjectVersionExportSubTask
should not exist at all, just add those values to the build service when you configure the projects in your convention plugin
⢠Yes, you still evilly reach into the other projects' model by using subProject.tasks
, that is just as bad as using subProject.configurations
, luckily if you did like I originally suggested and now clarified, there should not be any task dependency necessary anyway, but just by applying the plugin and thus configuring the projects the build service should already have the necessary values
⢠tasks.findByName
is always a bad idea, even within the same project, as you break task-configuration avoidance by that, causing the task to always be realized and configured, no matter whether it is going to be executed or not
šHenning Habighorst
05/15/2025, 3:38 PMVampire
05/15/2025, 3:39 PM