Normalize KSP2 output jar timestamps for build determinism (#1657)
Summary:
KSP2 output jars (ksp-kt-gensrc.jar, ksp-genclasses.jar) were
non-deterministic across builds: every entry was stamped with the
wall-clock time at the moment of execution, even with identical inputs.
Root cause: packageDirectoriesToJar creates JarEntry objects and calls
putNextEntry() without ever calling setTime(). Java's JarOutputStream
then defaults each entry's timestamp to System.currentTimeMillis(). A
second issue is the JarOutputStream(stream, manifest) constructor, which
internally writes META-INF/MANIFEST.MF with the same wall-clock stamp
before the caller has any opportunity to intervene.
Because Bazel's action cache key is computed from the hashes of all
action inputs, a target whose KSP output jars change every build will
always miss cache for its downstream KotlinCompile action on the next
build — even with no source changes. This cascaded through the entire
reverse dependency graph of any target using KSP processors, causing
hundreds of unnecessary recompilations per build.
Fix:
- Add a FIXED_JAR_TIMESTAMP constant (1980-01-01 00:00:00 UTC),
matching the epoch used by Bazel's JarHelper.DEFAULT_TIMESTAMP for
reproducible builds.
- Add a jarEntry() helper that creates a JarEntry with the fixed
timestamp already set, used at every putNextEntry() call site.
- Switch from JarOutputStream(stream, manifest) to JarOutputStream(stream)
and write META-INF/MANIFEST.MF manually via jarEntry(), so the manifest
entry is also stamped with the fixed epoch rather than wall-clock time.
JIRA Issues: MOBDROID-2959
Differential Revision: https://code.uberinternal.com/D25376885diff --git a/src/main/kotlin/io/bazel/kotlin/builder/tasks/jvm/Ksp2Task.kt b/src/main/kotlin/io/bazel/kotlin/builder/tasks/jvm/Ksp2Task.kt
index 5ffe261..0429a52 100644
--- a/src/main/kotlin/io/bazel/kotlin/builder/tasks/jvm/Ksp2Task.kt
+++ b/src/main/kotlin/io/bazel/kotlin/builder/tasks/jvm/Ksp2Task.kt
@@ -30,6 +30,7 @@
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
+import java.util.GregorianCalendar
import java.util.concurrent.ConcurrentHashMap
import java.util.jar.JarEntry
import java.util.jar.JarOutputStream
@@ -94,6 +95,12 @@
fun clearKspClassLoaderCacheForTesting() {
kspClassLoaderCache.clear()
}
+
+ // Fixed epoch (1980-01-01 00:00:00 UTC) for reproducible jar timestamps.
+ // Matches Bazel's JarHelper.DEFAULT_TIMESTAMP convention.
+ private val FIXED_JAR_TIMESTAMP = GregorianCalendar(1980, 0, 1, 0, 0, 0).timeInMillis
+
+ private fun jarEntry(name: String) = JarEntry(name).also { it.time = FIXED_JAR_TIMESTAMP }
}
override fun invoke(
@@ -302,8 +309,17 @@
mainAttributes.putValue("Created-By", "rules_kotlin KSP2")
}
- JarOutputStream(FileOutputStream(outputPath), manifest).use { jar ->
- val addedEntries = mutableSetOf<String>()
+ JarOutputStream(FileOutputStream(outputPath)).use { jar ->
+ // Write META-INF/MANIFEST.MF manually so we control the timestamp.
+ // The JarOutputStream(stream, manifest) constructor stamps it with
+ // System.currentTimeMillis(), breaking build determinism.
+ jar.putNextEntry(jarEntry("META-INF/"))
+ jar.closeEntry()
+ jar.putNextEntry(jarEntry("META-INF/MANIFEST.MF"))
+ manifest.write(jar)
+ jar.closeEntry()
+
+ val addedEntries = mutableSetOf("META-INF/", "META-INF/MANIFEST.MF")
for (dir in directories) {
if (!Files.exists(dir)) continue
@@ -318,7 +334,7 @@
val dirEntry = "$relativePath/"
if (dirEntry !in addedEntries) {
addedEntries.add(dirEntry)
- jar.putNextEntry(JarEntry(dirEntry))
+ jar.putNextEntry(jarEntry(dirEntry))
jar.closeEntry()
}
} else if (Files.isRegularFile(path)) {
@@ -329,7 +345,7 @@
parentPath += parts[i] + "/"
if (parentPath !in addedEntries) {
addedEntries.add(parentPath)
- jar.putNextEntry(JarEntry(parentPath))
+ jar.putNextEntry(jarEntry(parentPath))
jar.closeEntry()
}
}
@@ -337,7 +353,7 @@
// Add file entry
if (relativePath !in addedEntries) {
addedEntries.add(relativePath)
- jar.putNextEntry(JarEntry(relativePath))
+ jar.putNextEntry(jarEntry(relativePath))
Files.copy(path, jar)
jar.closeEntry()
}