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/D25376885
diff --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()
               }