From 70124febc41839d3789f943e9ff30fee817cb080 Mon Sep 17 00:00:00 2001 From: Nikolay Metchev Date: Sun, 1 Mar 2026 11:21:30 +0000 Subject: [PATCH 1/3] fix: enable build cache reuse across machines (#1083) Change PathSensitivity from ABSOLUTE to NONE on ArtifactsReportTask's input, so the cache key is based on file content rather than filesystem path. This enables cache hits between CI and local builds. Also remove unused absolute-path fields from intermediate outputs: - ExplodedJar.jarFile: never accessed after serialization - Dependency.files: explicitly marked as unused/speculative Co-Authored-By: Claude Opus 4.6 --- .../autonomousapps/model/internal/Dependency.kt | 16 ++++------------ .../intermediates/producer/ExplodedJar.kt | 7 +------ .../autonomousapps/tasks/ArtifactsReportTask.kt | 10 ++++------ .../tasks/SynthesizeDependenciesTask.kt | 13 +++++-------- 4 files changed, 14 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/com/autonomousapps/model/internal/Dependency.kt b/src/main/kotlin/com/autonomousapps/model/internal/Dependency.kt index 32c73235e..6c6c9e352 100644 --- a/src/main/kotlin/com/autonomousapps/model/internal/Dependency.kt +++ b/src/main/kotlin/com/autonomousapps/model/internal/Dependency.kt @@ -9,15 +9,11 @@ import com.autonomousapps.model.ModuleCoordinates import com.autonomousapps.model.ProjectCoordinates import com.squareup.moshi.JsonClass import dev.zacsweers.moshix.sealed.annotations.TypeLabel -import java.io.File @JsonClass(generateAdapter = false, generator = "sealed:type") internal sealed class Dependency( open val coordinates: Coordinates, open val capabilities: Map, - // Can be empty because we don't get file for annotation processor dependencies. - // This property is also unused and was only added speculatively, so maybe it doesn't matter - open val files: Set ) : Comparable { override fun compareTo(other: Dependency): Int = coordinates.compareTo(other.coordinates) @@ -32,29 +28,25 @@ internal data class ProjectDependency( override val coordinates: ProjectCoordinates, /** Map of [Capability] canonicalName to the capability. */ override val capabilities: Map, - override val files: Set -) : Dependency(coordinates, capabilities, files) +) : Dependency(coordinates, capabilities) @TypeLabel("module") @JsonClass(generateAdapter = false) internal data class ModuleDependency( override val coordinates: ModuleCoordinates, override val capabilities: Map, - override val files: Set -) : Dependency(coordinates, capabilities, files) +) : Dependency(coordinates, capabilities) @TypeLabel("flat") @JsonClass(generateAdapter = false) internal data class FlatDependency( override val coordinates: FlatCoordinates, override val capabilities: Map, - override val files: Set -) : Dependency(coordinates, capabilities, files) +) : Dependency(coordinates, capabilities) @TypeLabel("included_build") @JsonClass(generateAdapter = false) internal data class IncludedBuildDependency( override val coordinates: IncludedBuildCoordinates, override val capabilities: Map, - override val files: Set -) : Dependency(coordinates, capabilities, files) +) : Dependency(coordinates, capabilities) diff --git a/src/main/kotlin/com/autonomousapps/model/internal/intermediates/producer/ExplodedJar.kt b/src/main/kotlin/com/autonomousapps/model/internal/intermediates/producer/ExplodedJar.kt index 38c583b43..5f1efe3ff 100644 --- a/src/main/kotlin/com/autonomousapps/model/internal/intermediates/producer/ExplodedJar.kt +++ b/src/main/kotlin/com/autonomousapps/model/internal/intermediates/producer/ExplodedJar.kt @@ -7,7 +7,6 @@ import com.autonomousapps.model.Coordinates import com.autonomousapps.model.internal.* import com.autonomousapps.model.internal.intermediates.ExplodingJar import com.squareup.moshi.JsonClass -import java.io.File /** * A library or project, along with the set of classes declared by, and other information contained within, this @@ -16,7 +15,6 @@ import java.io.File @JsonClass(generateAdapter = false) internal data class ExplodedJar( override val coordinates: Coordinates, - val jarFile: File, /** * True if this dependency contains only annotations. False otherwise. @@ -58,7 +56,6 @@ internal data class ExplodedJar( exploding: ExplodingJar, ) : this( coordinates = artifact.coordinates, - jarFile = artifact.file, isAnnotations = exploding.isCompileOnlyCandidate, securityProviders = exploding.securityProviders, androidLintRegistry = exploding.androidLintRegistry, @@ -70,9 +67,7 @@ internal data class ExplodedJar( ) override fun compareTo(other: ExplodedJar): Int { - return coordinates.compareTo(other.coordinates).let { - if (it == 0) jarFile.compareTo(other.jarFile) else it - } + return coordinates.compareTo(other.coordinates) } init { diff --git a/src/main/kotlin/com/autonomousapps/tasks/ArtifactsReportTask.kt b/src/main/kotlin/com/autonomousapps/tasks/ArtifactsReportTask.kt index 48f6605c2..4158acdd7 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/ArtifactsReportTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/ArtifactsReportTask.kt @@ -38,13 +38,11 @@ public abstract class ArtifactsReportTask : DefaultTask() { public abstract val artifacts: Property /** - * This is the "official" input for wiring task dependencies correctly, but is otherwise - * unused. This needs to use [InputFiles] and [PathSensitivity.ABSOLUTE] because the path to the - * jars really does matter here. Using [Classpath] is an error, as it looks only at content and - * not name or path, and we really do need to know the actual path to the artifact, even if its - * contents haven't changed. + * This is the "official" input for wiring task dependencies correctly, but is otherwise unused. + * [PathSensitivity.NONE] means only file content matters for caching, not the filesystem path. + * This enables cache reuse across machines with different Gradle home locations. */ - @PathSensitive(PathSensitivity.ABSOLUTE) + @PathSensitive(PathSensitivity.NONE) @InputFiles // TODO(tsr): can I avoid using `get()`? public fun getClasspathArtifactFiles(): FileCollection = artifacts.get().artifactFiles diff --git a/src/main/kotlin/com/autonomousapps/tasks/SynthesizeDependenciesTask.kt b/src/main/kotlin/com/autonomousapps/tasks/SynthesizeDependenciesTask.kt index cb6021d19..4f7abe32a 100644 --- a/src/main/kotlin/com/autonomousapps/tasks/SynthesizeDependenciesTask.kt +++ b/src/main/kotlin/com/autonomousapps/tasks/SynthesizeDependenciesTask.kt @@ -25,7 +25,6 @@ import org.gradle.api.tasks.* import org.gradle.workers.WorkAction import org.gradle.workers.WorkParameters import org.gradle.workers.WorkerExecutor -import java.io.File import javax.inject.Inject @CacheableTask @@ -159,7 +158,7 @@ public abstract class SynthesizeDependenciesTask @Inject constructor( physicalArtifacts.forEach { artifact -> builders.merge( artifact.coordinates, - DependencyBuilder(artifact.coordinates).apply { files.add(artifact.file) }, + DependencyBuilder(artifact.coordinates), DependencyBuilder::concat ) } @@ -254,10 +253,8 @@ public abstract class SynthesizeDependenciesTask @Inject constructor( private class DependencyBuilder(val coordinates: Coordinates) { val capabilities: MutableList = mutableListOf() - val files: MutableSet = sortedSetOf() fun concat(other: DependencyBuilder): DependencyBuilder { - files.addAll(other.files) other.capabilities.forEach { otherCapability -> val existing = capabilities.find { it.javaClass.canonicalName == otherCapability.javaClass.canonicalName } if (existing != null) { @@ -274,10 +271,10 @@ public abstract class SynthesizeDependenciesTask @Inject constructor( fun build(): Dependency { val capabilities: Map = capabilities.associateBy { it.javaClass.canonicalName }.toSortedMap() return when (coordinates) { - is ProjectCoordinates -> ProjectDependency(coordinates, capabilities, files) - is ModuleCoordinates -> ModuleDependency(coordinates, capabilities, files) - is FlatCoordinates -> FlatDependency(coordinates, capabilities, files) - is IncludedBuildCoordinates -> IncludedBuildDependency(coordinates, capabilities, files) + is ProjectCoordinates -> ProjectDependency(coordinates, capabilities) + is ModuleCoordinates -> ModuleDependency(coordinates, capabilities) + is FlatCoordinates -> FlatDependency(coordinates, capabilities) + is IncludedBuildCoordinates -> IncludedBuildDependency(coordinates, capabilities) } } } From e8241300f68fb3d253dce52005959eec1ccd7745 Mon Sep 17 00:00:00 2001 From: Nikolay Metchev Date: Sun, 1 Mar 2026 11:45:08 +0000 Subject: [PATCH 2/3] test: add regression test for no absolute paths in intermediate outputs Guard against re-introducing absolute file paths into cached intermediate JSON outputs (exploded-jars, dependencies), which would break cross-machine build cache reuse. Co-Authored-By: Claude Opus 4.6 --- .../jvm/NoAbsolutePathsInOutputsSpec.groovy | 70 +++++++++++++++++++ .../projects/NoAbsolutePathsProject.groovy | 44 ++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/functionalTest/groovy/com/autonomousapps/jvm/NoAbsolutePathsInOutputsSpec.groovy create mode 100644 src/functionalTest/groovy/com/autonomousapps/jvm/projects/NoAbsolutePathsProject.groovy diff --git a/src/functionalTest/groovy/com/autonomousapps/jvm/NoAbsolutePathsInOutputsSpec.groovy b/src/functionalTest/groovy/com/autonomousapps/jvm/NoAbsolutePathsInOutputsSpec.groovy new file mode 100644 index 000000000..686ed1205 --- /dev/null +++ b/src/functionalTest/groovy/com/autonomousapps/jvm/NoAbsolutePathsInOutputsSpec.groovy @@ -0,0 +1,70 @@ +// Copyright (c) 2025. Tony Robalik. +// SPDX-License-Identifier: Apache-2.0 +package com.autonomousapps.jvm + +import com.autonomousapps.jvm.projects.NoAbsolutePathsProject +import groovy.json.JsonSlurper + +import java.util.zip.GZIPInputStream + +import static com.autonomousapps.utils.Runner.build + +final class NoAbsolutePathsInOutputsSpec extends AbstractJvmSpec { + + def "intermediate outputs do not contain absolute file paths (#gradleVersion)"() { + given: + def project = new NoAbsolutePathsProject() + gradleProject = project.gradleProject + + when: + build(gradleVersion, gradleProject.rootDir, 'buildHealth') + + then: 'exploded-jars.json.gz does not contain jarFile key' + def explodedJarsFile = gradleProject + .singleArtifact('proj', 'reports/dependency-analysis/main/intermediates/exploded-jars.json.gz') + def explodedJarsJson = decompress(explodedJarsFile.asFile) + !explodedJarsJson.contains('"jarFile"') + + and: 'dependency files do not contain files key with absolute paths' + def dependenciesDir = gradleProject.buildDir('proj').resolve('reports/dependency-analysis/main/dependencies') + dependenciesDir.toFile().exists() + def dependencyFiles = dependenciesDir.toFile().listFiles({ File f -> f.name.endsWith('.json') } as FileFilter) + dependencyFiles != null + dependencyFiles.length > 0 + dependencyFiles.every { File f -> + def parsed = new JsonSlurper().parseText(f.text) + !hasFilesWithAbsolutePaths(parsed) + } + + and: 'artifacts.json exists and is valid JSON' + def artifactsFile = gradleProject + .singleArtifact('proj', 'reports/dependency-analysis/main/intermediates/artifacts.json') + def artifacts = new JsonSlurper().parseText(artifactsFile.asFile.text) + artifacts != null + + where: + gradleVersion << gradleVersions() + } + + private static String decompress(File gzFile) { + new GZIPInputStream(new FileInputStream(gzFile)).withStream { gis -> + return gis.text + } + } + + private static boolean hasFilesWithAbsolutePaths(Object parsed) { + if (parsed instanceof Map) { + def map = (Map) parsed + if (map.containsKey('files')) { + def files = map['files'] + if (files instanceof Collection) { + return files.any { it instanceof String && (it.startsWith('/') || it.contains(':\\')) } + } + } + return map.values().any { hasFilesWithAbsolutePaths(it) } + } else if (parsed instanceof Collection) { + return ((Collection) parsed).any { hasFilesWithAbsolutePaths(it) } + } + return false + } +} diff --git a/src/functionalTest/groovy/com/autonomousapps/jvm/projects/NoAbsolutePathsProject.groovy b/src/functionalTest/groovy/com/autonomousapps/jvm/projects/NoAbsolutePathsProject.groovy new file mode 100644 index 000000000..bd00f54ce --- /dev/null +++ b/src/functionalTest/groovy/com/autonomousapps/jvm/projects/NoAbsolutePathsProject.groovy @@ -0,0 +1,44 @@ +// Copyright (c) 2025. Tony Robalik. +// SPDX-License-Identifier: Apache-2.0 +package com.autonomousapps.jvm.projects + +import com.autonomousapps.AbstractProject +import com.autonomousapps.kit.GradleProject +import com.autonomousapps.kit.Source +import com.autonomousapps.kit.SourceType +import com.autonomousapps.kit.gradle.Dependency + +final class NoAbsolutePathsProject extends AbstractProject { + + final GradleProject gradleProject + + NoAbsolutePathsProject() { + this.gradleProject = build() + } + + private GradleProject build() { + return newGradleProjectBuilder() + .withSubproject('proj') { s -> + s.sources = [KOTLIN_SOURCE] + s.withBuildScript { bs -> + bs.plugins = kotlin + bs.dependencies = [ + new Dependency('implementation', 'org.apache.commons:commons-lang3:3.14.0'), + ] + } + } + .write() + } + + private static final Source KOTLIN_SOURCE = new Source( + SourceType.KOTLIN, 'Main', 'com/example', + """\ + package com.example + + import org.apache.commons.lang3.StringUtils + + class Main { + fun greet(name: String): String = StringUtils.capitalize(name) + }""".stripIndent() + ) +} From 3facfa75f35d595b627842259c222e9b403ed879 Mon Sep 17 00:00:00 2001 From: Nikolay Metchev Date: Tue, 3 Mar 2026 09:19:52 +0000 Subject: [PATCH 3/3] fix: use content-based tiebreaker in ExplodedJar.compareTo() The removal of jarFile left compareTo() comparing only by coordinates, causing TreeSet to silently drop duplicate jars with the same coordinates but different content (classifiers, transforms). Use binaryClasses as a content-based tiebreaker to preserve all jars. Co-Authored-By: Claude Opus 4.6 --- .../model/internal/intermediates/producer/ExplodedJar.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/autonomousapps/model/internal/intermediates/producer/ExplodedJar.kt b/src/main/kotlin/com/autonomousapps/model/internal/intermediates/producer/ExplodedJar.kt index 5f1efe3ff..e2e245f5b 100644 --- a/src/main/kotlin/com/autonomousapps/model/internal/intermediates/producer/ExplodedJar.kt +++ b/src/main/kotlin/com/autonomousapps/model/internal/intermediates/producer/ExplodedJar.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.autonomousapps.model.internal.intermediates.producer +import com.autonomousapps.internal.utils.LexicographicIterableComparator import com.autonomousapps.internal.utils.ifNotEmpty import com.autonomousapps.model.Coordinates import com.autonomousapps.model.internal.* @@ -67,7 +68,9 @@ internal data class ExplodedJar( ) override fun compareTo(other: ExplodedJar): Int { - return coordinates.compareTo(other.coordinates) + return compareBy(ExplodedJar::coordinates) + .thenBy(LexicographicIterableComparator()) { it.binaryClasses } + .compare(this, other) } init {