Working with Files in Gradle

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Gradle 9.8.1 and was run on JDK 26 on 2026-10-16 (the daemon ran on JDK 26; no toolchain was involved, because no Java code is compiled). Every example was run through the wrapper in a scratch copy of a small tidepool-site project, once with Kotlin DSL build files and once with Groovy DSL build files, and the console output is identical apart from the timings and the few places called out in the text. The scratch copy pointed its wrapper at a local copy of the same distribution archive; the properties file that a real project commits is

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

The commands use --console=plain, and the closing "Consider enabling configuration cache" hint that Gradle prints when the cache is off is left out of the listings. Each example states whether it works with the configuration cache, and the final build ran with --configuration-cache. Features newer than Gradle 8.14 name their release and say whether they are incubating; Gradle 8.14.x was not installed here, so statements about it come from the official documentation and release notes and are labelled "not run here".

Almost every build copies, filters, packs or removes files: staging a web site, assembling a distribution, building a JAR, cleaning up. Gradle treats all of these as lazy, declared operations. Instead of a script that moves bytes around as it is read, you declare a task with its inputs (the from side) and its outputs (the into side), and Gradle decides whether it must run, runs it, and can skip it or load its result from the build cache next time (see Incremental Build). The same ideas exist in the other two tools: the Ant tasks are described in Ant File Operations and Ant Packaging Archives, and the Maven plugins in Maven Resources & Filtering and Maven Packaging JARs, WARs & Manifests. The tasks and the lazy properties that the examples use are explained in Tasks and Lazy Configuration & Providers.

The example project

All examples use one small project, tidepool-site, that stages a static site. Its files (the build file, the settings file, and the wrapper files are left out):

./data/stations.csv
./src/site/css/site.css
./src/site/img/wave.png
./src/site/index.html
./src/site/notes/draft.tmp
./src/site/notes/internal.txt
./src/site/run.sh
./src/site/tides-staging.html

index.html and tides-staging.html contain placeholders (${project}, ${stations} and @version@) that the build replaces, notes/internal.txt and notes/draft.tmp must never be published, run.sh must stay executable, and data/stations.csv is copied in from outside the site directory. The Kotlin DSL settings file is the single line rootProject.name = "tidepool-site" and the Groovy DSL one is rootProject.name = 'tidepool-site'. Both build files start with the following lines. The base plugin adds the archive naming conventions ([projectName]-[version].[type]) and the lifecycle tasks clean, assemble and build; nothing else is applied.

Kotlin DSL
// build.gradle.kts
import org.apache.tools.ant.filters.ReplaceTokens

plugins { base }

version = "1.4.0"
Groovy DSL
// build.gradle
import org.apache.tools.ant.filters.ReplaceTokens

plugins { id 'base' }

version = '1.4.0'

Locating files

Gradle resolves every relative path against the project directory (the directory of the build.gradle(.kts) file), never against the directory the command was typed in. The older, still common way to get a java.io.File is file("data/stations.csv") (a Project method). The lazy, type-safe way is ProjectLayout, available as layout in every build script, which hands out typed locations that can be used before the files exist:

API What it gives

layout.projectDirectory

The project directory as a Directory; .file("x") and .dir("x") give a RegularFile or Directory below it, and .asFile the java.io.File.

layout.buildDirectory

The build directory (build unless changed) as a DirectoryProperty, so it is lazy and can be wired into task properties; .file("x") and .dir("x") give providers.

layout.settingsDirectory

The directory that contains the settings file, shared by all projects of the build. Available since Gradle 8.13 (so also in 8.14).

file(path), files(paths), fileTree(dir)

The classic Project methods; they return File, ConfigurableFileCollection and ConfigurableFileTree and resolve relative to the project directory.

project.layout.files(...), objects.fileCollection()

Other ways to create file collections; the second is available in plugins that have no Project.

The task below prints how each notation resolves. It reads the locations at configuration time into local variables and only prints them in doLast, which is what makes it compatible with the configuration cache (the reasons are in the section on the configuration cache below). The example is compatible with the configuration cache.

Kotlin DSL
val dataFile = layout.projectDirectory.file("data/stations.csv")
val outDir = layout.buildDirectory.dir("site-out")

tasks.register("showPaths") {
    val csv = file("data/stations.csv")
    val outAsFile = layout.buildDirectory.get().asFile
    val settingsDir = layout.settingsDirectory.asFile
    val projectDir = layout.projectDirectory.asFile
    val dataName = dataFile.asFile.name
    doLast {
        println("file(\"data/stations.csv\") exists: ${csv.exists()}, absolute: ${csv.isAbsolute}")
        println("relative to project dir: ${csv.relativeTo(projectDir)}")
        println("build dir relative: ${outAsFile.relativeTo(projectDir)}")
        println("settings dir relative: '${settingsDir.relativeTo(projectDir)}'")
        println("dataFile.asFile name: ${dataName}")
    }
}
Groovy DSL
def dataFile = layout.projectDirectory.file('data/stations.csv')
def outDir = layout.buildDirectory.dir('site-out')

tasks.register('showPaths') {
    def csv = file('data/stations.csv')
    def outAsFile = layout.buildDirectory.get().asFile
    def settingsDir = layout.settingsDirectory.asFile
    def projectDir = layout.projectDirectory.asFile
    def dataName = dataFile.asFile.name
    doLast {
        println "file(\"data/stations.csv\") exists: ${csv.exists()}, absolute: ${csv.absolute}"
        println "relative to project dir: ${projectDir.toPath().relativize(csv.toPath())}"
        println "build dir relative: ${projectDir.toPath().relativize(outAsFile.toPath())}"
        println "settings dir relative: '${projectDir.toPath().relativize(settingsDir.toPath())}'"
        println "dataFile.asFile name: ${dataName}"
    }
}
> Task :showPaths
file("data/stations.csv") exists: true, absolute: true
relative to project dir: data/stations.csv
build dir relative: build
settings dir relative: ''
dataFile.asFile name: stations.csv

The settings directory is the project directory here, because tidepool-site has a single project; in a multi-project build (see Multi-Project Builds) a subproject’s layout.settingsDirectory is the root. The Maven equivalents are ${project.basedir} and ${project.build.directory} (see Maven Resources & Filtering), and the Ant ones the basedir property and ${build.dir} properties that the build file defines itself.

File collections and file trees

A file collection (FileCollection) is an ordered set of files that can be computed lazily. A file tree (FileTree) is a file collection that also keeps the directory hierarchy, so every element knows its path relative to the tree root; that is what makes include/exclude patterns, visit and copying with the structure preserved possible. fileTree(dir) and zipTree(archive) create trees; files(...) creates a flat collection. Collections combine with + and -, filter with filter { }, convert with asFileTree, and resolve lazily: a closure or provider { } handed to files(...) is only evaluated when the files are needed, and a task passed to it contributes its outputs and an automatic task dependency.

The patterns of a file tree are Ant-style: * matches any characters within one path segment, ? one character, and ** any number of directories (the same as in Ant filesets, see Ant Paths, Filesets & Patterns); a pattern ending in / matches the directory contents. Gradle’s default excludes (.git, .svn, *~, .DS_Store and similar) apply to every file tree.

The example builds a tree of the HTML and CSS files below src/site (without the -staging page), a flat collection of two files, and then combines them. It runs at execution time but captures the collections when the task is configured, so it works with the configuration cache.

Kotlin DSL
tasks.register("showCollections") {
    val sources = fileTree("src/site") {
        include("**/*.html", "**/*.css")
        exclude("**/*-staging.*")
    }
    val everything = files("data/stations.csv", "src/site/run.sh")
    val both = everything + sources
    val withoutCsv = both - files("data/stations.csv")
    val lazyOne = files(provider { listOf(file("data/stations.csv")) })
    val projectDir = layout.projectDirectory.asFile
    doLast {
        fun show(label: String, c: FileCollection) =
            println("$label: " + c.files.map { it.relativeTo(projectDir).path }.sorted())
        show("sources", sources)
        show("everything", everything)
        show("both", both)
        show("withoutCsv", withoutCsv)
        show("lazyOne", lazyOne)
        println("sources.isEmpty: ${sources.isEmpty}, singleFile of lazyOne: ${lazyOne.singleFile.name}")
        sources.visit {
            println("visited ${relativePath} (directory: ${isDirectory})")
        }
    }
}
Groovy DSL
tasks.register('showCollections') {
    def sources = fileTree('src/site') {
        include '**/*.html', '**/*.css'
        exclude '**/*-staging.*'
    }
    def everything = files('data/stations.csv', 'src/site/run.sh')
    def both = everything + sources
    def withoutCsv = both - files('data/stations.csv')
    def lazyOne = files(provider { [file('data/stations.csv')] })
    def projectDir = layout.projectDirectory.asFile
    doLast {
        def show = { String label, FileCollection c ->
            println "$label: " + c.files.collect { projectDir.toPath().relativize(it.toPath()).toString() }.sort()
        }
        show 'sources', sources
        show 'everything', everything
        show 'both', both
        show 'withoutCsv', withoutCsv
        show 'lazyOne', lazyOne
        println "sources.isEmpty: ${sources.isEmpty()}, singleFile of lazyOne: ${lazyOne.singleFile.name}"
        sources.visit { details ->
            println "visited ${details.relativePath} (directory: ${details.directory})"
        }
    }
}
> Task :showCollections
sources: [src/site/css/site.css, src/site/index.html]
everything: [data/stations.csv, src/site/run.sh]
both: [data/stations.csv, src/site/css/site.css, src/site/index.html, src/site/run.sh]
withoutCsv: [src/site/css/site.css, src/site/index.html, src/site/run.sh]
lazyOne: [data/stations.csv]
sources.isEmpty: false, singleFile of lazyOne: stations.csv
visited index.html (directory: false)
visited css (directory: true)
visited css/site.css (directory: false)
visited img (directory: true)
visited notes (directory: true)

The visit output shows that a file tree also reports the directories it walks (img and notes are listed although none of their files matched), which is why code that processes the visited elements checks isDirectory. The include patterns select files; they do not prune directories from the walk.

Copying files with Copy

Copy is the workhorse. It is configured through a copy specification (CopySpec): from(...) says what to copy, into(...) where, and everything else adjusts the copy. A spec can have child specs: each from(...) { ... } block creates one, and its include, exclude, rename, into and filter apply only to the files that child contributes. That is how the example below excludes notes/** from the site files only, and places the CSV under a data/ subdirectory of the output without touching the site files.

The accepted from arguments are the same as for files(...): strings and files (a directory is copied with its structure), file collections and trees, a provider, and a task (its outputs, with the task dependency). The example exercises renaming, content filtering, permissions and the duplicate check at once:

Kotlin DSL
val siteVersion = project.version.toString()

tasks.register<Copy>("stageSite") {
    val tokens = mapOf("version" to siteVersion)
    from("src/site") {
        exclude("notes/**", "*.tmp")
        rename("(.+)-staging(.+)", "$1$2")
    }
    from("data") { into("data") }
    destinationDirectory = outDir
    includeEmptyDirs = false
    duplicatesStrategy = DuplicatesStrategy.FAIL
    filesMatching("**/*.html") {
        expand("project" to "tidepool-site", "stations" to 3)
        filter<ReplaceTokens>("tokens" to tokens)
    }
    filePermissions { unix("rw-r--r--") }
    filesMatching("run.sh") { permissions { unix("rwxr-xr-x") } }
}
Groovy DSL
def siteVersion = project.version.toString()

tasks.register('stageSite', Copy) {
    def tokens = [version: siteVersion]
    from('src/site') {
        exclude 'notes/**', '*.tmp'
        rename '(.+)-staging(.+)', '$1$2'
    }
    from('data') { into 'data' }
    destinationDirectory = outDir
    includeEmptyDirs = false
    duplicatesStrategy = DuplicatesStrategy.FAIL
    filesMatching('**/*.html') {
        expand(project: 'tidepool-site', stations: 3)
        filter(ReplaceTokens, tokens: tokens)
    }
    filePermissions { unix('rw-r--r--') }
    filesMatching('run.sh') { permissions { unix('rwxr-xr-x') } }
}
> Task :stageSite

The result, listed with find, and the permissions that filePermissions and the per-file override produced:

build/site-out/css/site.css
build/site-out/data/stations.csv
build/site-out/img/wave.png
build/site-out/index.html
build/site-out/run.sh
build/site-out/tides.html
-rw-r--r--@ build/site-out/index.html
-rwxr-xr-x@ build/site-out/run.sh

tides-staging.html became tides.html through the rename regular expression (the capture groups are referred to as $1$2 in both DSLs; the Groovy DSL needs single quotes, '$1$2', to keep Groovy from treating $1 as a variable), notes/ and draft.tmp were excluded, and the generated index.html contains the values substituted by expand and ReplaceTokens:

<html><body><h1>Tidepool tidepool-site 1.4.0</h1><p>Stations: 3</p></body></html>

A second run finds nothing to do:

> Task :stageSite UP-TO-DATE

The example is compatible with the configuration cache and was run with --configuration-cache. The lines worth understanding:

  • destinationDirectory = outDir is the lazy destination introduced in Gradle 9.8.0 (incubating); it is assigned like any other property and accepts a provider such as layout.buildDirectory.dir("site-out") without resolving it at configuration time. into(...) and destinationDir still work (they will be deprecated only when destinationDirectory is promoted). On Gradle 8.14 use into(outDir); this was not run here.

  • includeEmptyDirs = false drops directories that end up with no files (here notes/, after the exclusion). The default is true.

  • duplicatesStrategy decides what happens when two sources produce the same destination path: INCLUDE (the last one wins), EXCLUDE (the first wins), WARN, FAIL, and INHERIT (the default, which means "not decided" and makes a duplicate an error). The section on classic mistakes shows both errors.

  • filesMatching(patterns) { } configures a FileCopyDetails only for the matching files; eachFile { } does it for all of them. FileCopyDetails can rename (name = "..."), relocate (path = "..."), exclude, and set permissions.

  • filePermissions { unix("rw-r--r--") } and dirPermissions { } (Gradle 8.3 and later) replace the old numeric fileMode and dirMode. The user guide notes that setting permissions explicitly for a file does not support up-to-date checks; in the runs above the second stageSite still reported UP-TO-DATE.

  • expand(map) runs each text file through Groovy’s SimpleTemplateEngine, so ${name} and $name are replaced by map entries, and a stray $ or a backslash in the file can make the template fail. filter<ReplaceTokens>("tokens" to map) (Kotlin DSL) and filter(ReplaceTokens, tokens: map) (Groovy DSL) replace @name@ tokens and is safer for files that contain $ (shell scripts, CSS and JavaScript templates, properties files).

  • The other CopySpec settings are caseSensitive, filteringCharset (the charset used by filter and expand; set it explicitly rather than relying on the platform default), exclude/include patterns and specs (Spec<FileTreeElement>), rename(from, to) with a regex or a Transformer, with(otherSpec) to apply a spec created with copySpec { }, and filter(Class) / filter(Transformer) for line-based filters, including the Ant filter classes such as ReplaceTokens, HeadFilter and TailFilter.

For a spec used by several tasks (for example a web assets spec shared by a Copy and a Zip), create it once with copySpec { } and attach it with with(spec); each task then applies it as its own child spec. The user guide has a worked example (not run here).

Sync: copy and remove what is no longer there

Copy only adds and overwrites files in the destination; it never deletes a file that is no longer part of the source. Sync is a Copy that also removes everything in the destination directory that it did not just copy, so the destination is an exact mirror of the specification. That is what you want for a staging or exploded-distribution directory; it is also why pointing Sync at a directory that holds other things is dangerous. A preserve { } block names files in the destination (with include and exclude patterns) that Sync must leave alone.

The check below creates a stray file with each task and a file under keep/ that is protected by preserve:

Kotlin DSL
tasks.register<Sync>("mirrorSite") {
    from("src/site") { exclude("notes/**", "*.tmp") }
    destinationDirectory = layout.buildDirectory.dir("mirror")
    preserve { include("keep/**") }
}
Groovy DSL
tasks.register('mirrorSite', Sync) {
    from('src/site') { exclude 'notes/**', '*.tmp' }
    destinationDirectory = layout.buildDirectory.dir('mirror')
    preserve { include 'keep/**' }
}

After a first run, stale.txt and keep/user.txt were created in build/mirror, mirrorSite was run again with --rerun-tasks, and the content of build/mirror was listed; stale.txt is gone and keep/user.txt is still there:

> Task :mirrorSite
./css/site.css
./img/wave.png
./index.html
./keep/user.txt
./run.sh
./tides-staging.html

The same experiment with stageSite (a Copy), run with --rerun-tasks after a stale.txt was added to its output, left the file in place:

> Task :stageSite
./css/site.css
./data/stations.csv
./img/wave.png
./index.html
./run.sh
./stale.txt
./tides.html

Both examples are compatible with the configuration cache. Sync also has the lazy destinationDirectory of Gradle 9.8.0 (used above). The Ant counterpart is the <sync> task and the Maven counterpart is the clean plugin followed by the resources plugin; neither has the per-task preserve list.

Archives: Zip, Tar and Jar

Zip, Tar, Jar, War and Ear are copy specifications whose destination is the archive itself, so everything in the previous sections (from, into for a subdirectory inside the archive, rename, filter, filesMatching) works unchanged. The task type decides the format; Tar also takes a compression (NONE, the default, GZIP or BZIP2) and derives its default extension from it (tar, tgz or tbz2, as a scratch build on 9.8.1 showed), so nothing has to be matched by hand; the examples below set archiveExtension = "tar.gz" only because that spelling is more common. Jar adds a manifest { } block and puts META-INF/MANIFEST.MF first.

The archive name is [appendix-]baseName[-version][-classifier].extension, taken from the task properties archiveAppendix, archiveBaseName, archiveVersion, archiveClassifier and archiveExtension. With the base plugin the base name defaults to the project name and the version to project.version, and the default output directory is build/distributions; archiveFileName and destinationDirectory override the conventions. The name never derives from the task name. archiveFile and archiveFileName are lazy and can be wired into other tasks.

Kotlin DSL
tasks.register<Zip>("siteZip") {
    from(tasks.named("stageSite"))
    destinationDirectory = layout.buildDirectory.dir("dist")
}

tasks.register<Tar>("siteTar") {
    from(tasks.named("stageSite"))
    archiveExtension = "tar.gz"
    compression = Compression.GZIP
    destinationDirectory = layout.buildDirectory.dir("dist")
}

tasks.register<Tar>("siteTarExec") {
    from(tasks.named("stageSite"))
    archiveAppendix = "exec"
    archiveExtension = "tar.gz"
    compression = Compression.GZIP
    destinationDirectory = layout.buildDirectory.dir("dist")
    useFileSystemPermissions()
}

tasks.register<Jar>("siteJar") {
    from(tasks.named("stageSite")) { into("META-INF/resources") }
    destinationDirectory = layout.buildDirectory.dir("dist")
    manifest { attributes("Implementation-Title" to "tidepool-site", "Implementation-Version" to siteVersion) }
}

tasks.register<Zip>("siteZipLoose") {
    from(tasks.named("stageSite"))
    archiveAppendix = "loose"
    destinationDirectory = layout.buildDirectory.dir("dist")
    isPreserveFileTimestamps = true
    isReproducibleFileOrder = false
}
Groovy DSL
tasks.register('siteZip', Zip) {
    from tasks.named('stageSite')
    destinationDirectory = layout.buildDirectory.dir('dist')
}

tasks.register('siteTar', Tar) {
    from tasks.named('stageSite')
    archiveExtension = 'tar.gz'
    compression = Compression.GZIP
    destinationDirectory = layout.buildDirectory.dir('dist')
}

tasks.register('siteTarExec', Tar) {
    from tasks.named('stageSite')
    archiveAppendix = 'exec'
    archiveExtension = 'tar.gz'
    compression = Compression.GZIP
    destinationDirectory = layout.buildDirectory.dir('dist')
    useFileSystemPermissions()
}

tasks.register('siteJar', Jar) {
    from(tasks.named('stageSite')) { into 'META-INF/resources' }
    destinationDirectory = layout.buildDirectory.dir('dist')
    manifest { attributes 'Implementation-Title': 'tidepool-site', 'Implementation-Version': siteVersion }
}

tasks.register('siteZipLoose', Zip) {
    from tasks.named('stageSite')
    archiveAppendix = 'loose'
    destinationDirectory = layout.buildDirectory.dir('dist')
    preserveFileTimestamps = true
    reproducibleFileOrder = false
}

With from(tasks.named("stageSite")) the archive tasks take the output of the staging task as their content and depend on it. After cleanStaging, running ./gradlew stageSite siteZip siteTar siteTarExec siteJar and listing build/dist gave:

> Task :stageSite
> Task :siteZip
> Task :siteTar
> Task :siteTarExec
> Task :siteJar
tidepool-site-1.4.0.jar
tidepool-site-1.4.0.tar.gz
tidepool-site-1.4.0.zip
tidepool-site-exec-1.4.0.tar.gz

The ZIP, as unzip -l lists it:

Archive:  build/dist/tidepool-site-1.4.0.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
       82  02-01-1980 00:00   index.html
       24  02-01-1980 00:00   run.sh
       57  02-01-1980 00:00   tides.html
        0  02-01-1980 00:00   css/
       22  02-01-1980 00:00   css/site.css
        0  02-01-1980 00:00   data/
      239  02-01-1980 00:00   data/stations.csv
        0  02-01-1980 00:00   img/
       16  02-01-1980 00:00   img/wave.png
---------                     -------
      440                     9 files

The manifest of the JAR (the standard Manifest-Version plus the attributes from the example), and the first entries, where the standard META-INF directory and manifest come first and the site is below META-INF/resources as configured:

Manifest-Version: 1.0
Implementation-Title: tidepool-site
Implementation-Version: 1.4.0
        0  02-01-1980 00:00   META-INF/
       93  02-01-1980 00:00   META-INF/MANIFEST.MF
        0  02-01-1980 00:00   META-INF/resources/
       82  02-01-1980 00:00   META-INF/resources/index.html
       24  02-01-1980 00:00   META-INF/resources/run.sh

All archive examples are compatible with the configuration cache. For JARs with a main class, dependencies and java-library conventions see Java Projects; for "uber" JARs the user guide shows zipTree plus the Jar task or the third-party Shadow plugin, the counterparts of Maven Uber JARs with Shade & Assembly and of the Ant <zipfileset>/<jar> combinations in Ant Packaging Archives.

Reproducible archives (the default since Gradle 9.0)

Gradle 9.0.0 changed the defaults of every archive task (AbstractArchiveTask): the file order is deterministic, the timestamps of all entries are a fixed value that depends on the format, directories get the permissions 0755 and files 0644. Archives built from the same inputs are therefore identical byte for byte, on any machine and at any time, which is what reproducible-builds tooling needs (see Maven Security & Reproducible Builds for the Maven way, with project.build.outputTimestamp). The listings above already show the effects: every ZIP entry has the date 02-01-1980 (1 February 1980, the constant Gradle uses), and the run.sh that was rwxr-xr-x on disk lost its executable bit in the TAR:

-rw-r--r--  0 0      0          24 Jan  2  1970 run.sh

The siteTarExec task calls useFileSystemPermissions(), which tells the archive task to use the permissions from the file system again, and its listing shows the bit back:

-rwxr-xr-x  0 0      0          24 Jan  2  1970 run.sh

The check that the archive really is reproducible: the SHA-256 of tidepool-site-1.4.0.zip (first 24 hexadecimal digits) was taken, the build directory outputs were deleted with cleanStaging, the modification times of three source files were changed with touch, the site was staged and zipped again, and the digest was taken again:

e0d47875c98776c9cc1a6057
> Task :cleanStaging
> Task :stageSite
> Task :siteZip
e0d47875c98776c9cc1a6057

siteZipLoose opts out of two of the three aspects (isPreserveFileTimestamps = true in the Kotlin DSL, preserveFileTimestamps = true in the Groovy DSL, and likewise isReproducibleFileOrder = false / reproducibleFileOrder = false). Built twice with the sources touched in between, its digest changed, and the entries carry the file system time stamp instead of 1980 (the listing shows the time of the build):

5a88be4eaa08ee7e98d8eaa6
       82  10-16-2026 19:54   index.html
e0d0a53ad21d4aaf9ee7275a
       82  10-16-2026 19:54   index.html

To restore the old behaviour for every archive task of a build, configure tasks.withType<AbstractArchiveTask>().configureEach { } with the three settings (the user guide shows the exact block, not run here), or set org.gradle.archives.use-file-system-permissions=true in gradle.properties for the permissions alone. An incubating property, reproducibleFileTimestamp, replaces the default constant with a timestamp of your choice (for example the time of the last commit, from SOURCE_DATE_EPOCH) while staying reproducible; it was not run here.

On Gradle 8.14 (not run here) archives are not reproducible by default; the same three properties exist and are set to the values above to make them so. Builds that used the old behaviour, for example scripts executable in a distribution ZIP that relied on the file system bit, now need useFileSystemPermissions() or an explicit filePermissions/permissions on the archive task.

Deleting files

The Delete task removes the files, directories and collections handed to delete(...). It has no include and exclude patterns of its own; to delete only some files pass a filtered fileTree. The clean task of the base plugin is a Delete of the build directory. The example removes the staging and mirror directories and the ZIP files below build/dist, and keeps the JAR and TAR files:

Kotlin DSL
tasks.register<Delete>("cleanStaging") {
    delete(layout.buildDirectory.dir("site-out"), layout.buildDirectory.dir("mirror"))
    delete(fileTree(layout.buildDirectory.dir("dist")) { include("**/*.zip") })
}
Groovy DSL
tasks.register('cleanStaging', Delete) {
    delete layout.buildDirectory.dir('site-out'), layout.buildDirectory.dir('mirror')
    delete fileTree(layout.buildDirectory.dir('dist')) { include '**/*.zip' }
}
> Task :cleanStaging
tidepool-site-1.4.0.jar
tidepool-site-1.4.0.tar.gz
tidepool-site-exec-1.4.0.tar.gz
build/site-out: No such file or directory

(After the task, the content of build/dist was listed, and then the removed build/site-out.) The example is compatible with the configuration cache. Files can be deleted from inside a task with FileSystemOperations.delete { }, shown below; the older Project.delete is unsupported by the configuration cache for the same reason as Project.copy. The Ant counterpart is <delete>, the Maven one the maven-clean-plugin.

Unpacking archives, and the services behind file operations

zipTree(archive) and tarTree(archive) expose an archive as a file tree that can be filtered, copied or used as an input; resources.gzip(file) and resources.bzip2(file) wrap a compressed TAR so tarTree can read it. A copy of a zipTree into a directory unpacks the archive.

Doing that from the action of a task, rather than from a Copy task, raises the question of which API may be used at execution time. Project.copy { }, Project.sync { }, Project.delete { } and Project.zipTree() all need the Project object, and using the Project while a task runs is incompatible with the configuration cache (the cache stores the task graph, not the project). The replacements are two injected services, both usable from a task class with @Inject or from a script through an injected holder:

  • FileSystemOperations: copy, sync, delete, copySpec, filePermissions, directoryPermissions and permissions.

  • ArchiveOperations: zipTree, tarTree, and gzip and bzip2, the counterparts of resources.gzip and resources.bzip2 that need no Project at execution time.

This task class unpacks a ZIP (the one the build made) without the data files, using both services, and writes a summary:

Kotlin DSL
abstract class UnpackAndRepack @Inject constructor(
    private val fs: FileSystemOperations,
    private val archives: ArchiveOperations
) : DefaultTask() {
    @get:InputFile abstract val archive: RegularFileProperty
    @get:OutputDirectory abstract val unpacked: DirectoryProperty
    @get:OutputFile abstract val summary: RegularFileProperty

    @TaskAction fun run() {
        fs.delete { delete(unpacked) }
        fs.copy {
            from(archives.zipTree(archive))
            exclude("data/**")
            into(unpacked)
        }
        val n = unpacked.get().asFileTree.files.size
        summary.get().asFile.writeText("unpacked $n files from ${archive.get().asFile.name}\n")
    }
}

tasks.register<UnpackAndRepack>("unpackSite") {
    archive = tasks.named<Zip>("siteZip").flatMap { it.archiveFile }
    unpacked = layout.buildDirectory.dir("unpacked")
    summary = layout.buildDirectory.file("unpacked-summary.txt")
}
apply(from = "labs.gradle.kts")
Groovy DSL
abstract class UnpackAndRepack extends DefaultTask {
    @Inject abstract FileSystemOperations getFs()
    @Inject abstract ArchiveOperations getArchives()

    @InputFile abstract RegularFileProperty getArchive()
    @OutputDirectory abstract DirectoryProperty getUnpacked()
    @OutputFile abstract RegularFileProperty getSummary()

    @TaskAction void run() {
        fs.delete { delete(unpacked) }
        fs.copy {
            from(archives.zipTree(archive))
            exclude 'data/**'
            into unpacked
        }
        def n = unpacked.get().asFileTree.files.size()
        summary.get().asFile.text = "unpacked $n files from ${archive.get().asFile.name}\n"
    }
}

tasks.register('unpackSite', UnpackAndRepack) {
    archive = tasks.named('siteZip', Zip).flatMap { it.archiveFile }
    unpacked = layout.buildDirectory.dir('unpacked')
    summary = layout.buildDirectory.file('unpacked-summary.txt')
}

apply from: 'labs.gradle'
> Task :stageSite
> Task :siteZip
> Task :unpackSite
unpacked 5 files from tidepool-site-1.4.0.zip

(The last line is the content of build/unpacked-summary.txt.) The five files are index.html, run.sh, tides.html, css/site.css and img/wave.png; the data directory was excluded in the copy. The task is compatible with the configuration cache, and it runs under --configuration-cache.

For one-off use in a script without writing a task class, the user guide’s pattern is an interface with an @Inject property, instantiated with objects.newInstance, captured when the task is configured, and used in doLast:

Kotlin DSL
interface InjectedServices {
    @get:Inject val fs: FileSystemOperations
}

tasks.register("injectedCopy") {
    val services = objects.newInstance<InjectedServices>()
    val target = layout.buildDirectory.dir("injected")
    doLast {
        services.fs.copy {
            from("data")
            into(target)
        }
    }
}
Groovy DSL
interface InjectedServices {
    @Inject FileSystemOperations getFs()
}

tasks.register('injectedCopy') {
    def services = objects.newInstance(InjectedServices)
    def target = layout.buildDirectory.dir('injected')
    doLast {
        services.fs.copy {
            from 'data'
            into target
        }
    }
}

Run twice with --configuration-cache, the second run reusing the stored configuration:

> Task :injectedCopy
> Task :stageSite UP-TO-DATE
> Task :siteZip UP-TO-DATE
> Task :unpackSite UP-TO-DATE

The Gradle 9.8.0 release notes also advertise built-in file, object and process services "directly in scripts and task actions"; the notes do not name the API, and nothing under the names fileSystemOperations or archiveOperations resolved in a Kotlin DSL build script on 9.8.1, so the injected holder above is the form that was run. Moving a file is done with Copy followed by Delete, or with java.nio.file.Files.move inside a task action; there is no Move task.

Configuration cache traps

File operations are where most builds first break under the configuration cache, because the natural code reaches back into the project from an action. Three traps, each run on Gradle 9.8.1 (see Configuration Cache & Isolated Projects for the cache itself).

Project.copy in a task action. This is the classic from the Gradle 1.x and 2.x books and still found in many scripts:

Kotlin DSL
tasks.register("legacyCopy") {
    doLast {
        project.copy {
            from("data")
            into(layout.buildDirectory.dir("legacy"))
        }
    }
}
Groovy DSL
tasks.register('legacyCopy') {
    doLast {
        project.copy {
            from 'data'
            into layout.buildDirectory.dir('legacy')
        }
    }
}

Run without the cache the task works but warns (the warning shown is the Kotlin DSL one; the Groovy DSL one adds the script name and line and a different stack frame); with --configuration-cache it fails:

> Task :legacyCopy
Invocation of Task.project at execution time has been deprecated. This will fail with an error in Gradle 10. This API is incompatible with the configuration cache, which will become the only mode supported by Gradle in a future release. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.1/userguide/upgrading_version_7.html#task_project
> Task :legacyCopy FAILED
Execution failed for task ':legacyCopy' (registered in script 'labs.gradle.kts').
> Invocation of 'Task.project' by task ':legacyCopy' at execution time is unsupported with the configuration cache.

The fix is the injected FileSystemOperations of the previous section, or a real Copy task.

project inside a filter { } closure. A line filter written as a closure runs at execution time, so a project.version in it is the same problem, and it is easy to miss because the build passes without the cache and the closure is only called when the file is copied:

Kotlin DSL
tasks.register<Copy>("badFilter") {
    from("src/site") { include("index.html") }
    into(layout.buildDirectory.dir("bad"))
    filter { line: String -> line.replace("@version@", project.version.toString()) }
}
Groovy DSL
tasks.register('badFilter', Copy) {
    from('src/site') { include 'index.html' }
    into layout.buildDirectory.dir('bad')
    filter { line -> line.replace('@version@', project.version.toString()) }
}
> Task :badFilter
Invocation of Task.project at execution time has been deprecated. This will fail with an error in Gradle 10. This API is incompatible with the configuration cache, which will become the only mode supported by Gradle in a future release. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.1/userguide/upgrading_version_7.html#task_project
> Task :badFilter FAILED
- Task `:badFilter` of type `org.gradle.api.tasks.Copy`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
> Could not copy file '/home/dev/tidepool-site/src/site/index.html' to '/home/dev/tidepool-site/build/bad/index.html'.
   > Invocation of 'Task.project' by task ':badFilter' at execution time is unsupported with the configuration cache.

(The first two lines are from the run without the cache; the cache run was started with --rerun-tasks so that the closure really ran.) Capture the value when the task is configured and use ReplaceTokens or expand, as stageSite does.

A script-level val used in an action (Kotlin DSL only). In a Kotlin DSL script a top-level val is a property of the script object, so a lambda that uses it holds a reference to the script, which cannot be stored:

Kotlin DSL
tasks.register("scriptRef") {
    doLast { println("hello from $greeting") }
}
Groovy DSL
tasks.register('scriptRef') {
    doLast { println "hello from $greeting" }
}
> Task :scriptRef
hello from tides
- Task `:scriptRef` of type `org.gradle.api.DefaultTask`: cannot serialize Gradle script object references as these are not supported with the configuration cache.

The Kotlin DSL run printed the message and then failed while storing the cache; the Groovy DSL run of the equivalent script (a def captured by the closure) was stored and reused without a problem. The remedy is a local val inside the register { } block, as the examples above do with val tokens in stageSite and val dataName in showPaths. Using the script-level siteVersion directly inside the filesMatching action of stageSite produced the same message.

The same operations in Ant and Maven

Operation Gradle Ant Maven

Copy with filtering

Copy, CopySpec.filter, expand

<copy> with <filterchain> (see Ant Filters & Mappers)

maven-resources-plugin with <filtering>true</filtering> (see Maven Resources & Filtering)

Mirror a directory

Sync with preserve

<sync>

No direct equivalent (maven-clean-plugin then copy)

Delete

Delete, clean from the base plugin

<delete>

maven-clean-plugin

Zip, tar, jar

Zip, Tar, Jar, War, Ear

<zip>, <tar>, <jar>, <war>

maven-jar-plugin, maven-assembly-plugin (see Maven Packaging JARs, WARs & Manifests)

Unpack

zipTree, tarTree, ArchiveOperations

<unzip>, <untar>

maven-dependency-plugin:unpack

Reproducible archives

The default since 9.0

Attributes of the archive tasks, see the Ant docs

project.build.outputTimestamp

Classic mistakes

  • Two sources, one destination. Since Gradle 7.0 a copy with a duplicate entry and no duplicatesStrategy fails. Two from blocks, one of which renames site.css to index.html, collide with index.html:

    > Task :clashingDefault FAILED
    Execution failed for task ':clashingDefault' (registered in script 'labs.gradle.kts').
    > Entry index.html is a duplicate but no duplicate handling strategy has been set. Please refer to https://docs.gradle.org/9.8.1/dsl/org.gradle.api.tasks.Copy.html#org.gradle.api.tasks.Copy:duplicatesStrategy for details.

    With the strategy set to FAIL explicitly the message names both files (the other strategies resolve the clash instead):

    Kotlin DSL
    tasks.register<Copy>("clashing") {
        from("src/site") { include("index.html") }
        from("src/site/css") { include("site.css"); rename("site.css", "index.html") }
        into(layout.buildDirectory.dir("clash"))
        duplicatesStrategy = DuplicatesStrategy.FAIL
    }
    Groovy DSL
    tasks.register('clashing', Copy) {
        from('src/site') { include 'index.html' }
        from('src/site/css') { include 'site.css'; rename 'site.css', 'index.html' }
        into layout.buildDirectory.dir('clash')
        duplicatesStrategy = DuplicatesStrategy.FAIL
    }
    > Task :clashing FAILED
    Execution failed for task ':clashing' (registered in script 'labs.gradle.kts').
    > Cannot copy file '/home/dev/tidepool-site/src/site/css/site.css' to '/home/dev/tidepool-site/build/clash/index.html' because file '/home/dev/tidepool-site/src/site/index.html' has already been copied there.
  • Expecting Copy to clean up. A Copy into a directory that already holds other files keeps them (the stale.txt run above); use Sync or delete first.

  • Hard-coded absolute or new File(...) paths. They resolve against the daemon’s working directory, not the project; use layout or file(...).

  • The lost executable bit. Since Gradle 9.0 an archive built from rwxr-xr-x scripts contains rw-r--r-- (see the TAR listings above); set useFileSystemPermissions() or per-file permissions.

  • expand on files that contain $. A shell or CSS file with $ or backslashes fails or is corrupted; use ReplaceTokens or limit expand to the files that need it with filesMatching.

  • Snapshot tasks that write into src. A Copy or Sync into a source directory is both an output of the task and an input of others, which defeats up-to-date checks; stage into the build directory (see Incremental Build for overlapping outputs).

Differences by Gradle release

Feature Gradle 8.14 (not run here) Gradle 9.8.1 (run here)

Archive defaults

Not reproducible by default; set preserveFileTimestamps = false, reproducibleFileOrder = true and fixed permissions to make them so

Reproducible by default since 9.0.0; the three aspects can be reverted individually

Lazy Copy/Sync destination

Only into(...) and destinationDir

destinationDirectory property (9.8.0, incubating); into(provider) stays lazy

layout.settingsDirectory

Available (8.13)

Available

filePermissions { }, dirPermissions { }

Available (8.3)

Available

Project.copy, Project.sync, Project.delete, Task.project in actions

Deprecated, still run without the configuration cache

Same, with the deprecation message above; the cache rejects them; scheduled to fail in Gradle 10

Where the books are outdated

The book Gradle in Action (Gradle 1.7) introduces Copy, Zip and the other built-in task types in the chapters on custom tasks and builds the distribution in the chapter on artifact assembly and publishing. The from, into, include, exclude, rename and filter calls shown there still work. What changed:

  • task copyDocs(type: Copy) << { ... } and other uses of the << operator were removed in Gradle 5.0; use tasks.register and doLast { }.

  • Project.copy { } in task actions (a common way to do a one-off copy) is unsupported by the configuration cache, preferred since 9.0 and stable since 8.1; use FileSystemOperations.

  • Archive tasks were not reproducible; since 9.0 they are, with the side effects described above.

  • from configurations.compile and compile/runtime dependencies in distribution tasks were removed with the legacy configurations in 7.0; use runtimeClasspath. The maven plugin and uploadArchives for publishing the archives were removed in 7.0 as well; use maven-publish (see Publishing).

Where to go next