[2.x] fix: Reject java.io.File as cached task output type (#8766)

Change the behavior when a cached task's output type contains
java.io.File: instead of silently skipping the cache, throw a
compile-time error with a message recommending xsbti.VirtualFileRef,
xsbti.HashedVirtualFileRef, or xsbti.VirtualFile, and linking to the
caching documentation.

Internal sbt tasks that return File types are wrapped with
Def.uncached to opt out of caching.

Fixes https://github.com/sbt/sbt/issues/8762

Generated-by: GitHub Copilot (Claude)

* [2.x] fix: Reject java.io.File as cached task output type

Use @transient on File-returning keys in Keys.scala instead of
wrapping tasks with Def.uncached in Defaults.scala. This ensures
build users who rewire these tasks also get caching skipped
automatically.

- Add @transient to 19 File-returning taskKey definitions in Keys.scala
- Revert Def.uncached wrappers from Defaults.scala
- Error at compile time when File is used as cached task output type
- Update error message to recommend @transient and link to docs
- Update scripted tests to use @transient approach

Fixes #8762

Generated-by: GitHub Copilot (Claude Opus 4.6)
This commit is contained in:
Douglas Ma 2026-02-21 01:35:46 -05:00 committed by GitHub
parent 4a5701cb8e
commit a7d5f45515
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 106 additions and 42 deletions

View File

@ -178,6 +178,16 @@ trait Cont:
case Left(l) => (l, TypeRepr.of[Effect[A]])
case Right(r) => (r, faTpe)
def containsFileType[A1: Type]: Boolean =
val fileRepr = TypeRepr.of[java.io.File]
def containsFile(tpe: TypeRepr): Boolean =
if tpe =:= fileRepr then true
else
tpe.dealias match
case AppliedType(_, args) => args.exists(containsFile)
case _ => false
containsFile(TypeRepr.of[A1])
val inputBuf = ListBuffer[Input]()
val outputBuf = ListBuffer[Output]()
@ -342,38 +352,49 @@ trait Cont:
cacheConfigExpr: Expr[BuildWideCacheConfiguration],
tags: List[CacheLevelTag],
)(body: Expr[A1], input: Expr[A2]): Expr[A1] =
val codeContentHash =
try Expr[Long](body.show.##)
catch
case e: Throwable =>
Expr[Long](Printer.TreeStructure.show(body.asTerm).##)
val extraHash = Expr[Long](0L)
val aJsonFormat = summonJsonFormat[A1]
val aClassTag = summonClassTag[A1]
val inputHashWriter =
if TypeRepr.of[A2] =:= TypeRepr.of[Unit] then
'{
import BasicJsonProtocol.*
summon[HashWriter[Unit]]
}.asExprOf[HashWriter[A2]]
else summonHashWriter[A2]
val tagsExpr = '{ List(${ Varargs(tags.map(Expr[CacheLevelTag](_))) }*) }
val block = letOutput(outputs, cacheConfigExpr)(body)
'{
given HashWriter[A2] = $inputHashWriter
given JsonFormat[A1] = $aJsonFormat
given ClassTag[A1] = $aClassTag
ActionCache
.cache(
$input,
codeContentHash = Digest.dummy($codeContentHash),
extraHash = Digest.dummy($extraHash),
tags = $tagsExpr,
config = $cacheConfigExpr,
)({ _ =>
$block
})
}
if containsFileType[A1] then
report.errorAndAbort(
s"""java.io.File is not a valid output type for a cached task.
|Consider using one of the following alternatives:
| - xsbti.HashedVirtualFileRef
| - xsbti.VirtualFileRef
| - xsbti.VirtualFile
|If caching is not needed, annotate the key with @transient, or wrap the task in Def.uncached.
|See https://www.scala-sbt.org/2.x/docs/en/concepts/caching.html#caching-files""".stripMargin
)
else
val codeContentHash =
try Expr[Long](body.show.##)
catch
case e: Throwable =>
Expr[Long](Printer.TreeStructure.show(body.asTerm).##)
val extraHash = Expr[Long](0L)
val aJsonFormat = summonJsonFormat[A1]
val aClassTag = summonClassTag[A1]
val inputHashWriter =
if TypeRepr.of[A2] =:= TypeRepr.of[Unit] then
'{
import BasicJsonProtocol.*
summon[HashWriter[Unit]]
}.asExprOf[HashWriter[A2]]
else summonHashWriter[A2]
val tagsExpr = '{ List(${ Varargs(tags.map(Expr[CacheLevelTag](_))) }*) }
val block = letOutput(outputs, cacheConfigExpr)(body)
'{
given HashWriter[A2] = $inputHashWriter
given JsonFormat[A1] = $aJsonFormat
given ClassTag[A1] = $aClassTag
ActionCache
.cache(
$input,
codeContentHash = Digest.dummy($codeContentHash),
extraHash = Digest.dummy($extraHash),
tags = $tagsExpr,
config = $cacheConfigExpr,
)({ _ =>
$block
})
}
// This will generate following code for Def.declareOutput(...):
// var $o1: VirtualFile = null

View File

@ -157,9 +157,12 @@ object Keys {
val javaSource = settingKey[File]("Default Java source directory.").withRank(ASetting)
val sourceDirectories = settingKey[Seq[File]]("List of all source directories, both managed and unmanaged.").withRank(AMinusSetting)
val unmanagedSourceDirectories = settingKey[Seq[File]]("Unmanaged source directories, which contain manually created sources.").withRank(ASetting)
@transient
val unmanagedSources = taskKey[Seq[File]]("Unmanaged sources, which are manually created.").withRank(BPlusTask)
val managedSourceDirectories = settingKey[Seq[File]]("Managed source directories, which contain sources generated by the build.").withRank(BSetting)
@transient
val managedSources = taskKey[Seq[File]]("Sources generated by the build.").withRank(BTask)
@transient
val sources = taskKey[Seq[File]]("All sources, both managed and unmanaged.").withRank(BTask)
val sourcesInBase = settingKey[Boolean]("If true, sources from the project's base directory are included as main sources.")
@ -171,10 +174,13 @@ object Keys {
val resourceDirectory = settingKey[File]("Default unmanaged resource directory, used for user-defined resources.").withRank(ASetting)
val resourceManaged = settingKey[File]("Default managed resource directory, used when generating resources.").withRank(BSetting)
val unmanagedResourceDirectories = settingKey[Seq[File]]("Unmanaged resource directories, containing resources manually created by the user.").withRank(AMinusSetting)
@transient
val unmanagedResources = taskKey[Seq[File]]("Unmanaged resources, which are manually created.").withRank(BPlusTask)
val managedResourceDirectories = settingKey[Seq[File]]("List of managed resource directories.").withRank(AMinusSetting)
@transient
val managedResources = taskKey[Seq[File]]("Resources generated by the build.").withRank(BTask)
val resourceDirectories = settingKey[Seq[File]]("List of all resource directories, both managed and unmanaged.").withRank(BPlusSetting)
@transient
val resources = taskKey[Seq[File]]("All resource files, both managed and unmanaged.").withRank(BTask)
private[sbt] val resourceDigests = taskKey[Seq[Digest]]("All resource files, both managed and unmanaged.").withRank(BTask)
@ -257,6 +263,7 @@ object Keys {
val manipulateBytecode = taskKey[CompileResult]("Manipulates generated bytecode").withRank(BTask)
val compileIncremental = taskKey[(Boolean, VirtualFileRef, HashedVirtualFileRef)]("Actually runs the incremental compilation").withRank(DTask)
val previousCompile = taskKey[PreviousResult]("Read the incremental compiler analysis from disk").withRank(DTask)
@transient
val tastyFiles = taskKey[Seq[File]]("Returns the TASTy files produced by compilation").withRank(DTask)
private[sbt] val compileScalaBackend = taskKey[CompileResult]("Compiles only Scala sources if pipelining is enabled. Compiles both Scala and Java sources otherwise").withRank(Invisible)
private[sbt] val compileEarly = taskKey[CompileAnalysis]("Compiles only Scala sources if pipelining is enabled, and produce an early output (pickle JAR)").withRank(Invisible)
@ -273,17 +280,21 @@ object Keys {
val earlyCompileAnalysisTargetRoot = settingKey[File]("The output directory to produce Zinc Analysis files").withRank(DSetting)
@transient
val compileAnalysisFile = taskKey[File]("Zinc analysis storage.").withRank(DSetting)
@transient
val earlyCompileAnalysisFile = taskKey[File]("Zinc analysis storage for early compilation").withRank(DSetting)
@transient
val compileIncSetup = taskKey[Setup]("Configures aspects of incremental compilation.").withRank(DTask)
val compilerCache = taskKey[GlobalsCache]("Cache of scala.tools.nsc.Global instances. This should typically be cached so that it isn't recreated every task run.").withRank(DTask)
val stateCompilerCache = AttributeKey[GlobalsCache]("stateCompilerCache", "Internal use: Global cache.")
@transient
val classpathEntryDefinesClass = taskKey[File => DefinesClass]("Internal use: provides a function that determines whether the provided file contains a given class.").withRank(Invisible)
private[sbt] val classpathDefinesClassCache = settingKey[VirtualFileValueCache[DefinesClass]]("Internal use: a cache of jar classpath entries that persists across command evaluations.").withRank(Invisible)
val persistJarClasspath = settingKey[Boolean]("Toggles whether or not to cache jar classpath entries between command evaluations")
val classpathEntryDefinesClassVF = taskKey[VirtualFile => DefinesClass]("Internal use: provides a function that determines whether the provided file contains a given class.").withRank(Invisible)
@transient
val doc = taskKey[File]("Generates API documentation.").withRank(AMinusTask)
@transient
val copyResources = taskKey[Seq[(File, File)]]("Copies resources to the output directory.").withRank(AMinusTask)
val aggregate = settingKey[Boolean]("Configures task aggregation.").withRank(BMinusSetting)
val sourcePositionMappers = taskKey[Seq[xsbti.Position => Option[xsbti.Position]]]("Maps positions in generated source files to the original source it was generated from").withRank(DTask)
@ -407,7 +418,9 @@ object Keys {
val projectInfo = settingKey[ModuleInfo]("Addition project information like formal name, homepage, licenses etc.").withRank(CSetting)
val defaultConfiguration = settingKey[Option[Configuration]]("Defines the configuration used when none is specified for a dependency in ivyXML.").withRank(CSetting)
@transient
val products = taskKey[Seq[File]]("Build products that get packaged.").withRank(BMinusTask)
@transient
val productDirectories = taskKey[Seq[File]]("Base directories of build products.").withRank(CTask)
val exportJars = settingKey[Boolean]("Determines whether the exported classpath for this project contains classes (false) or a packaged jar (true).").withRank(BSetting)
val exportedProducts = taskKey[Classpath]("Build products that go on the exported classpath.").withRank(CTask)
@ -539,6 +552,7 @@ object Keys {
val updateClassifiers = TaskKey[UpdateReport]("updateClassifiers", "Resolves and optionally retrieves classified artifacts, such as javadocs and sources, for dependency definitions, transitively.", BPlusTask, update)
val transitiveClassifiers = settingKey[Seq[String]]("List of classifiers used for transitively obtaining extra artifacts for sbt or declared dependencies.").withRank(BSetting)
val updateSbtClassifiers = TaskKey[UpdateReport]("updateSbtClassifiers", "Resolves and optionally retrieves classifiers, such as javadocs and sources, for sbt, transitively.", BPlusTask, updateClassifiers)
@transient
val dependencyLock = taskKey[File]("Generates a dependency lock file from the current resolution.").withRank(BTask)
val dependencyLockCheck = taskKey[Unit]("Checks if the dependency lock file is up-to-date.").withRank(BTask)
val dependencyLockFile = settingKey[File]("The location of the dependency lock file.").withRank(CSetting)
@ -562,9 +576,12 @@ object Keys {
val allCredentials = taskKey[Seq[Credentials]]("Aggregated credentials across current and root subprojects. Do not rewire this task.").withRank(DTask)
val makePom = taskKey[HashedVirtualFileRef]("Generates a pom for publishing when publishing Maven-style.").withRank(BPlusTask)
@transient
val deliver = taskKey[File]("Generates the Ivy file for publishing to a repository.").withRank(BTask)
@transient
val deliverLocal = taskKey[File]("Generates the Ivy file for publishing to the local repository.").withRank(BTask)
// makeIvyXml is currently identical to the confusingly-named "deliver", which may be deprecated in the future
@transient
val makeIvyXml = taskKey[File]("Generates the Ivy file for publishing to a repository.").withRank(BTask)
/** BOM-resolved ModuleIDs for deps that had \"*\" (version from BOM); emitted as forced deps in published ivy.xml (sbt#4531). */
val resolvedDependencies = taskKey[Seq[ModuleID]]("")
@ -605,6 +622,7 @@ object Keys {
val retrieveConfiguration = settingKey[Option[RetrieveConfiguration]]("Configures retrieving dependencies to the current build.").withRank(DSetting)
val offline = settingKey[Boolean]("Configures sbt to work without a network connection where possible.").withRank(ASetting)
val ivyPaths = settingKey[IvyPaths]("Configures paths used by Ivy for dependency management.").withRank(DSetting)
@transient
val dependencyCacheDirectory = taskKey[File]("The base directory for cached dependencies.").withRank(DTask)
val libraryDependencies = settingKey[Seq[ModuleID]]("Declares managed dependencies.").withRank(APlusSetting)
val dependencyOverrides = settingKey[Seq[ModuleID]]("Declares managed dependency overrides.").withRank(BSetting)
@ -635,6 +653,7 @@ object Keys {
val libraryDependencySchemes = settingKey[Seq[ModuleID]]("""Version scheme to use for specific modules set as "org" %% "name" % "<scheme>": Supported values are "early-semver", "pvp", "semver-spec", "always", and "strict".""").withRank(BSetting)
@transient
val stagingDirectory = settingKey[File]("Local staging directory for Sonatype publishing").withRank(CSetting)
@transient
val sonaBundle = taskKey[File]("Local bundle for Sonatype publishing").withRank(DTask)
val localStaging = settingKey[Option[Resolver]]("Local staging resolver for Sonatype publishing").withRank(CSetting)
val sonaDeploymentName = settingKey[String]("The name used for deployment").withRank(DSetting)

View File

@ -1,3 +1,4 @@
@transient
val buildInfo = taskKey[Seq[File]]("generates the build info")
ThisBuild / scalaVersion := "2.12.21"

View File

@ -1,36 +1,32 @@
@transient
val taskA = taskKey[File]("")
@transient
val taskB = taskKey[File]("")
@transient
val taskE = taskKey[File]("")
@transient
val taskF = taskKey[File]("")
scalaVersion := "3.3.1"
name := "task-map"
taskA := {
val c = fileConverter.value
touch(target.value / "a")
Def.declareOutput(c.toVirtualFile((target.value / "a").toPath()))
target.value / "a"
}
taskB := {
val c = fileConverter.value
touch(target.value / "b")
Def.declareOutput(c.toVirtualFile((target.value / "b").toPath()))
target.value / "b"
}
taskE := {
val c = fileConverter.value
touch(target.value / "e")
Def.declareOutput(c.toVirtualFile((target.value / "e").toPath()))
target.value / "e"
}
taskF := {
val c = fileConverter.value
touch(target.value / "f")
Def.declareOutput(c.toVirtualFile((target.value / "f").toPath()))
target.value / "f"
}
@ -38,13 +34,13 @@ taskF := {
// means "a" will be triggered by "b"
// said differently, invoking "b" will run "b" and then run "a"
taskA := Def.uncached(taskA.triggeredBy(taskB).value)
taskA := taskA.triggeredBy(taskB).value
// e <<= e runBefore f
// means "e" will be run before running "f"
// said differently, invoking "f" will run "e" and then run "f"
taskE := Def.uncached(taskE.runBefore(taskF).value)
taskE := taskE.runBefore(taskF).value
// test utils
def touch(f: File): File = { IO.touch(f); f }

View File

@ -0,0 +1,16 @@
import java.io.File
@transient
val myFileTask = taskKey[File]("task that returns File")
val badFileTask = taskKey[File]("task without @transient that should fail to cache")
val checkFileTask = taskKey[Unit]("verifies file task returns correct value")
myFileTask := {
new File(scalaVersion.value)
}
checkFileTask := {
val f = myFileTask.value
val expected = new File(scalaVersion.value)
assert(f == expected, s"Expected $expected but got $f")
}

View File

@ -0,0 +1,8 @@
# @transient File-returning tasks should not be cached, so
# changing scalaVersion should immediately be reflected.
> checkFileTask
> set scalaVersion := "2.13.18"
> checkFileTask
# Using File as the return type of tasks without @transient should fail compilation.
-> set badFileTask := { new java.io.File(scalaVersion.value) }

View File

@ -2,6 +2,7 @@ import sbt.internal.FileChangesMacro.inputFiles
// The project contains two files: { Foo.txt, Bar.md } in the subdirector base/subdir/nested-subdir
// Check that we can correctly extract Foo.txt with a recursive source
@transient
val foo = taskKey[Seq[File]]("Retrieve Foo.txt")
foo / fileInputs += baseDirectory.value.toGlob / ** / "*.txt"
@ -13,6 +14,7 @@ val checkFoo = taskKey[Unit]("Check that the Foo.txt file is retrieved")
checkFoo := assert(foo.value == Seq(baseDirectory.value / "base/subdir/nested-subdir/Foo.txt"))
// Check that we can correctly extract Bar.md with a non-recursive source
@transient
val bar = taskKey[Seq[File]]("Retrieve Bar.md")
bar / fileInputs += baseDirectory.value.toGlob / "base" / "subdir" / "nested-subdir" / "*.md"

View File

@ -8,6 +8,7 @@ val uTest = "com.lihaoyi" %% "utest" % "0.5.3"
val foo = taskKey[Int]("")
val bar = taskKey[Int]("")
val baz = inputKey[Unit]("")
@transient
val buildInfo = taskKey[Seq[File]]("The task that generates the build info.")
lazy val root = (project in file("."))