The build lifecycle is the sequence of phases Gradle executes to turn your build scripts into completed work, from initializing the build environment to configuring projects and finally executing tasks.

Build Phases

A build has three distinct phases. Gradle runs these phases in order:

Phase 1. Initialization Phase 2. Configuration Phase 3. Execution

Detects projects and included builds

Configures projects and builds a task graph

Schedules and executes the selected tasks

build lifecycle example

Phase 1. Initialization

In the initialization phase, Gradle detects the set of projects (root and subprojects) and included builds participating in the build.

Gradle first runs any init scripts, then evaluates the settings file, settings.gradle(.kts), and instantiates a Settings object.

Then, Gradle instantiates Project object instances for each project included in the build (using includeBuild() or include() in the settings file).

Phase 2. Configuration

In the configuration phase, Gradle registers tasks and other properties to the projects found by the initialization phase.

Gradle evaluates the build files of every participating project, then constructs the task graph by analyzing the input and output dependencies of tasks.

When the Configuration Cache is enabled, the configuration phase also includes a serialization sub-phase, where Gradle stores the task graph while simultaneously precomputing some of the task state and resolving dependencies. This is work that would otherwise be deferred to execution. See Configuration Cache - How It Works for more details.

Phase 3. Execution

In the execution phase, Gradle runs the selected tasks.

Gradle uses the task execution graph generated by the configuration phase to determine which tasks to execute and in what order.

Gradle can execute tasks that don’t depend on each other, in the same project, in parallel.

The Phases in Build Scripts

The following example shows which parts of settings and build files correspond to various build phases:

settings.gradle.kts
rootProject.name = "basic"
println("This is executed during the initialization phase.")
build.gradle.kts
// Configuration Phase
println("This is executed during the configuration phase.")

tasks.register("configured") {
    println("This is also executed during the configuration phase.")
}

// Execution Phase
tasks.register("test") {
    doLast {
        println("This is executed during the execution phase.")
    }
}

// Configurations AND Execution Phase
tasks.register("testBoth") {
    println("This is executed during the configuration phase as well.")
    doFirst {
        println("This is executed first during the execution phase.")
    }
    doLast {
        println("This is executed last during the execution phase.")
    }
}
settings.gradle
rootProject.name = 'basic'
println 'This is executed during the initialization phase.'
build.gradle
// Configuration Phase
println 'This is executed during the configuration phase.'

tasks.register('configured') {
    println 'This is also executed during the configuration phase.'
}

// Execution Phase
tasks.register('test') {
    doLast {
        println 'This is executed during the execution phase.'
    }
}

// Configurations AND Execution Phase
tasks.register('testBoth') {
    println 'This is executed during the configuration phase as well.'
    doFirst {
	  println 'This is executed first during the execution phase.'
	}
	doLast {
	  println 'This is executed last during the execution phase.'
	}
}

The following command executes the test and testBoth tasks specified above:

$ ./gradlew test testBoth
This is executed during the initialization phase.

> Configure project :
This is executed during the configuration phase.
This is executed during the configuration phase as well.

> Task :test
This is executed during the execution phase.

> Task :testBoth
This is executed first during the execution phase.
This is executed last during the execution phase.

BUILD SUCCESSFUL in 0s
2 actionable tasks: 2 executed
$ ./gradlew test testBoth
This is executed during the initialization phase.

> Configure project :
This is executed during the configuration phase.
This is executed during the configuration phase as well.

> Task :test
This is executed during the execution phase.

> Task :testBoth
This is executed first during the execution phase.
This is executed last during the execution phase.

BUILD SUCCESSFUL in 0s
2 actionable tasks: 2 executed

Because Gradle only configures requested tasks and their prerequisites, the configured task never configures.

Build Lifecycle Timeline

Understanding the precise sequence in which Gradle evaluates scripts and triggers hooks is the key to mastering build logic. This timeline provides a comprehensive overview of the Gradle build flow when the Configuration Cache and Isolated Projects are disabled:

Phase Seq. Hook / Block Location Purpose

Initialization

1

Init Scripts

~/.gradle/init.d/*.gradle

Global environment setup (e.g., enterprise repos).

2

gradle.beforeSettings

init script or settings.gradle(.kts)

Runs before the settings file is even parsed.

3

pluginManagement { …​ }

settings.gradle(.kts)

Must be first. Defines plugin repos and version rules.

4

plugins { …​ }

settings.gradle(.kts)

Applies plugins to the Settings object (e.g., Build Scans).

5

Settings Script Body

settings.gradle(.kts)

Evaluates include(":project") to define build structure.

6

gradle.settingsEvaluated

init script or settings.gradle(.kts)

Settings are fully processed; Project objects are created.

7

gradle.projectsLoaded

init script or settings.gradle(.kts)

All project instances exist but aren’t configured yet.

Configuration

8

gradle.lifecycle.beforeProject

init script or build.gradle(.kts)

Not recommended. Fires immediately before each project starts evaluating.

9

project.beforeEvaluate

init script or parent project’s build.gradle(.kts)

Runs before a specific project’s build script is evaluated.

10

buildscript { …​ }

build.gradle(.kts)

Sets the classpath for the build script itself.

11

plugins { …​ }

build.gradle(.kts)

Applies plugins and adds DSL/Tasks.

12

Build Script Body

build.gradle(.kts)

Registers tasks (tasks.register) and configures properties.

13

project.afterEvaluate

build.gradle(.kts)

Not recommended. Runs after a specific project is evaluated; useful for reacting to another project’s configuration.

14

gradle.lifecycle.afterProject

init script or root build.gradle(.kts)

Fires immediately after each project finishes evaluating.

15

gradle.projectsEvaluated

init script or root build.gradle(.kts)

All project scripts have run; final chance to tweak the graph.

16

Task Graph Construction

Internal (TaskExecutionGraph, TaskExecutionGraphListener, TaskExecutionListener)

Gradle calculates the DAG based on requested tasks.

Execution

17a

Task Input Snapshotting

Internal (onlyIf{}, upToDateWhen{})

Evaluated per task to determine whether it should be skipped or executed.

17b

Dependency Graph Resolution*

Internal (ResolutionResult, ResolvedComponentResult, ResolvedVariantResult)

Constructs a dependency graph based on declared dependencies.

17c

Artifact Resolution*

Internal (ArtifactCollection, ResolvedArtifact)

Dependencies are downloaded from repositories.

18

Task Execution

Console / CLI (@TaskAction,doFirst{},doLast{})

Tasks run.

* Dependency Graph Resolution and Artifact Resolution are normally deferred until the Execution Phase. However, any build logic that eagerly accesses a configuration’s resolved state, such as calling configuration.files, configuration.resolvedConfiguration, or iterating a configuration directly, will trigger resolution immediately at that point in the build lifecycle.

When the Configuration Cache is enabled, Dependency Graph Resolution and Artifact Resolution must be completed during the Configuration Phase rather than at execution time, since they are required to fully describe the task graph before the result can be serialized. You can refer to Configuration Cache - How It Works to learn more.

Task Graphs

As a build author, you write build logic by defining tasks and declaring how they depend on one another. Gradle uses this information to construct a task graph during the configuration phase that models the relationships between these tasks.

For example, if your project includes tasks such as build, assemble, and createDocs, and you declare that assemble depends on build, and createDocs depends on assemble, Gradle constructs a graph with this order: buildassemblecreateDocs.

Gradle builds the task graph before executing any task(s).

Across all projects in the build, tasks form a Directed Acyclic Graph (DAG).

This diagram shows two example task graphs, one abstract and the other concrete, with dependencies between tasks represented as arrows:

task dag examples

Hooks into the Gradle Build Lifecycle

Gradle exposes several APIs that let you listen to or react to key points in the build lifecycle.

Some APIs let you hook into those phases in init scripts, settings.gradle(.kts) or build.gradle(.kts) to customize or inspect the build as Gradle configures it. These hooks let you:

  • react to the structure of the build (root + subprojects),

  • configure all projects before their own build scripts are evaluated,

  • debug or collect information during configuration.

Lifecycle API

The GradleLifecycle API, accessible the gradle object, can be used to register actions to be executed at certain points in the build lifecycle:

buildStarted -> DEPRECATED in Gradle 6
 └─ beforeSettings
     └── settingsEvaluated
          └── projectsLoaded
               ├── beforeProject
               ├── afterProject
               └── projectsEvaluated
                     └── buildFinished -> DEPRECATED in Gradle 7

Here is a quick overview:

# Hook Best for

1

gradle.beforeSettings

Early wiring from an init script: global logging/metrics, tweaking gradle.startParameter, choosing different settings files, or setting values on the Settings object before it’s evaluated.

2

gradle.settingsEvaluated

Adjusting repositories, enabling build scans, reading environment variables, or changing build layout.

3

gradle.projectsLoaded

Applying configuration to every project (for example, adding a plugin to all subprojects).

4

gradle.lifecycle.beforeProject

Applying defaults, logging project configuration start.

5

gradle.lifecycle.afterProject

Validating configuration or detecting missing plugins.

6

gradle.projectsEvaluated

Performing cross-project validation, configuration summaries, or creating aggregated tasks.

beforeSettings

Where: init script.
When: By the time settings.gradle(.kts) is executing, beforeSettings has already fired.

callbacks.init.gradle.kts
// 1. beforeSettings: tweak start parameters / log early info
// before the build settings have been loaded and evaluated.
gradle.beforeSettings {
    println("[beforeSettings] gradleUserHome = ${gradle.gradleUserHomeDir}")

    // Example: default to --parallel if an env var is set
    if (System.getenv("CI") == "true") {
        println("[beforeSettings] Enabling parallel execution on CI")
        gradle.startParameter.isParallelProjectExecutionEnabled = true
    } else {
        println("[beforeSettings] Disabling parallel execution, not on CI")
        gradle.startParameter.isParallelProjectExecutionEnabled = false
    }
}
callbacks.init.gradle
// 1. beforeSettings: tweak start parameters / log early info
// before the build settings have been loaded and evaluated.
gradle.beforeSettings {
    println("[beforeSettings] gradleUserHome = ${gradle.gradleUserHomeDir}")

    // Example: default to --parallel if an env var is set
    if (System.getenv("CI") == "true") {
        println("[beforeSettings] Enabling parallel execution on CI")
        gradle.startParameter.parallelProjectExecutionEnabled = true
    } else {
        println("[beforeSettings] Disabling parallel execution, not on CI")
        gradle.startParameter.parallelProjectExecutionEnabled = false
    }

    println("")
}

settingsEvaluated

Where: settings.gradle(.kts).
When: After the settings file finishes evaluating.

callbacks.init.gradle.kts
// 2. settingsEvaluated: adjust build layout / repositories / scan config
// when the build settings have been loaded and evaluated.
gradle.settingsEvaluated {
    println("[settingsEvaluated] rootProject = ${rootProject.name}")

    // Example: enforce a company-wide pluginManagement repo
    pluginManagement.repositories.apply {
        println("[settingsEvaluated] Ensuring company plugin repo is configured")
        mavenCentral()
    }
}
callbacks.init.gradle
// 2. settingsEvaluated: adjust build layout / repositories / scan config
// when the build settings have been loaded and evaluated.
gradle.settingsEvaluated { settings ->
    println("[settingsEvaluated] rootProject = ${settings.rootProject.name}")

    // Example: enforce a company-wide pluginManagement repo
    settings.pluginManagement.repositories.with {
        println("[settingsEvaluated] Ensuring company plugin repo is configured")
        mavenCentral()
    }

    println("")
}

projectsLoaded

Where: settings.gradle(.kts).
When: All projects have been discovered but not yet configured.

callbacks.init.gradle.kts
// 3. projectsLoaded: we know the full project graph, but nothing configured yet
// to be called when the projects for the build have been created from the settings.
gradle.projectsLoaded {
    println("[projectsLoaded] Projects discovered: " + rootProject.allprojects.joinToString { it.name })

    // Example: Add a custom property (using the extra properties extension)
    allprojects {
        println("[projectsLoaded] Setting extra property on ${name}")
        extensions.extraProperties["isInitScriptConfigured"] = true
    }
}
callbacks.init.gradle
// 3. projectsLoaded: we know the full project graph, but nothing configured yet
// to be called when the projects for the build have been created from the settings.
gradle.projectsLoaded {
    println "[projectsLoaded] Projects discovered: " + rootProject.allprojects.collect { it.name }.join(', ')

    // Example: Add a custom property (using the extra properties extension)
    allprojects {
        println("[projectsLoaded] Setting extra property on ${name}")
        extensions.extraProperties["isInitScriptConfigured"] = true
    }
}

beforeProject

Where: build.gradle(.kts) or anywhere you have access to the Gradle instance.
When: For each project as its build script is evaluated.

callbacks.init.gradle.kts
// to be called immediately before a project is evaluated.
gradle.lifecycle.beforeProject {
    println("[lifecycle.beforeProject] Started configuring ${path}")
}

// 4. beforeProject: runs before each build.gradle(.kts) is evaluated
// to be called immediately before a project is evaluated.
gradle.beforeProject {
    println("[beforeProject] Started configuring ${path}")

    println("[beforeProject] Setup a global build directory for ${name}")
    layout.buildDirectory.set(
        layout.projectDirectory.dir("build")
    )
}
callbacks.init.gradle
// to be called immediately before a project is evaluated.
gradle.lifecycle.beforeProject {
    println("[lifecycle.beforeProject] Started configuring ${path}")
}

// 4. beforeProject: runs before each build.gradle(.kts) is evaluated
// to be called immediately before a project is evaluated.
gradle.beforeProject { project ->
    println("[beforeProject] Started configuring ${project.path}")

    println("[beforeProject] Setup a global build directory for ${project.name}")
    project.layout.buildDirectory.set(
        project.layout.projectDirectory.dir("build")
    )
}

afterProject

Where: build.gradle(.kts) or anywhere you have access to the Gradle instance.
When: For each project as its build script is evaluated.

callbacks.init.gradle.kts
// 5. afterProject: runs after each build.gradle(.kts) is evaluated
// to be called immediately after a project is evaluated.
gradle.afterProject {
    println("[afterProject] Finished configuring ${path}")

    // Example: apply the Java plugin to all projects that don’t have any plugin yet
    if (plugins.hasPlugin("java")) {
        println("[afterProject] ${path} already has the java plugin")
    } else {
        println("[afterProject] Applying java plugin to ${path}")
        apply(plugin = "java")
    }
}

// to be called immediately after a project is evaluated.
gradle.lifecycle.afterProject {
    println("[lifecycle.afterProject] Finished configuring ${path}")
}
callbacks.init.gradle
// 5. afterProject: runs after each build.gradle(.kts) is evaluated
// to be called immediately after a project is evaluated.
gradle.afterProject { project ->
    println("[afterProject] Finished configuring ${project.path}")

    // Example: apply the Java plugin to all projects that don’t have any plugin yet
    if (project.plugins.hasPlugin("java")) {
        println("[afterProject] ${project.path} already has the java plugin")
    } else {
        println("[afterProject] Applying java plugin to ${project.path}")
        project.apply(plugin: "java")
    }
}

// to be called immediately after a project is evaluated.
gradle.lifecycle.afterProject {
    println("[lifecycle.afterProject] Finished configuring ${path}")
}

projectsEvaluated

Where: build.gradle(.kts) or anywhere you have access to the Gradle instance.
When: After all projects have been evaluated (i.e., all build.gradle(.kts) files are read and configuration is complete), but before task graph is finalized and before execution starts.

callbacks.init.gradle.kts
// 6. projectsEvaluated: all projects are fully configured, safe for cross-project checks
// to be called when all projects for the build have been evaluated.
gradle.projectsEvaluated {
    println("[projectsEvaluated] All projects evaluated")

    // Example: globally configure the java plugin
    allprojects {
        extensions.findByType<JavaPluginExtension>()?.let { javaExtension ->
            if (javaExtension.toolchain.languageVersion.isPresent) {
                println("[projectsEvaluated] ${path} uses Java plugin with toolchain ${javaExtension.toolchain.displayName}")
            } else {
                println("[projectsEvaluated] WARNING: ${path} uses Java plugin but no toolchain is configured, setting Java 17")
                javaExtension.toolchain.languageVersion.set(JavaLanguageVersion.of(17))
            }
        }
    }
}
callbacks.init.gradle
// 6. projectsEvaluated: all projects are fully configured, safe for cross-project checks
// to be called when all projects for the build have been evaluated.
gradle.projectsEvaluated {
    println("[projectsEvaluated] All projects evaluated")

    // Example: globally configure the java plugin
    allprojects { project ->
        def javaExtension = project.extensions.findByType(JavaPluginExtension)
        if(javaExtension) {
            if (javaExtension.toolchain.languageVersion.orNull != null) {
                println("[projectsEvaluated] ${path} uses Java plugin with toolchain ${javaExtension.toolchain.displayName}")
            } else {
                println("[projectsEvaluated] WARNING: ${path} uses Java plugin but no toolchain is configured, setting Java 17")
                javaExtension.toolchain.languageVersion.set(JavaLanguageVersion.of(17))
            }
        }
    }
}

Build Listeners

Listeners are interfaces you implement to react to build lifecycle events.

Many BuildListeners are deprecated and/or are not Configuration Cache friendly, use them with caution:

Interface What it observes Works with CC?

BuildListener

Fires when projects are loaded/evaluated.

ProgressListener

Fires when the execution of an operation progresses.

(Partially Deprecated) TaskActionListener

Fires when a task has started or completed its action.

(Deprecated) TaskExecutionListener

Fires before and after each task executes.

(Deprecated) TaskExecutionGraphListener

Fires when the task execution graph has been populated.

OperationCompletionListener

Fires when an operation completes.

DependencyResolutionListener

Fires before and after dependency resolution.

ProjectEvaluationListener

Fires before and after each project is evaluated.

TestListener

Fires at different times during test execution.

You implement a listener interface and typically register it on the gradle object. The TaskExecutionGraphListener fires once after the task graph has been calculated, but before any task executes.

This example uses graph.allTasks to inspect what Gradle plans to run, and graph.hasTask() to conditionally adjust behaviour:

build.gradle.kts
gradle.taskGraph.addTaskExecutionGraphListener { graph ->
    val tasks = graph.allTasks.joinToString("\n  ") { it.path }
    println("[TaskExecutionGraphListener] Graph is ready. Tasks to execute:\n  $tasks")

    if (graph.hasTask(":skippedTask")) {
        println("[TaskExecutionGraphListener] :skippedTask is in the graph (it will still be skipped via onlyIf)")
    }
}

tasks.register("hello") {
    group = "demo"
    description = "Prints a greeting. Always runs — observe TaskExecutionListener output."
    doLast {
        println("Hello from the :hello task!")
    }
}

tasks.register("upToDateTask") {
    group = "demo"
    description = "Writes a file. Runs once, then reports UP-TO-DATE on subsequent runs."

    val outputFile = layout.buildDirectory.file("up-to-date-output.txt")
    outputs.file(outputFile)

    doLast {
        outputFile.get().asFile.writeText("generated")
        println("upToDateTask: file written.")
    }
}

tasks.register("skippedTask") {
    group = "demo"
    description = "Always skipped via onlyIf — observe SKIPPED in TaskExecutionListener."
    onlyIf { false }
    doLast {
        println("This never prints.")
    }
}
build.gradle
gradle.taskGraph.addTaskExecutionGraphListener { graph ->
    def tasks = graph.allTasks.collect { it.path }.join("\n  ")
    println("[TaskExecutionGraphListener] Graph is ready. Tasks to execute:\n  ${tasks}")

    if (graph.hasTask(":skippedTask")) {
        println("[TaskExecutionGraphListener] :skippedTask is in the graph (it will still be skipped via onlyIf)")
    }
}

tasks.register("hello") {
    group = "demo"
    description = "Prints a greeting. Always runs."
    doLast {
        println("Hello from the :hello task!")
    }
}

tasks.register("upToDateTask") {
    group = "demo"
    description = "Writes a file. Runs once, then reports UP-TO-DATE on subsequent runs."

    def outputFile = layout.buildDirectory.file("up-to-date-output.txt")
    outputs.file(outputFile)

    doLast {
        outputFile.get().asFile.text = "generated"
        println("upToDateTask: file written.")
    }
}

tasks.register("skippedTask") {
    group = "demo"
    description = "Always skipped via onlyIf."
    onlyIf { false }
    doLast {
        println("This never prints.")
    }
}

Note that tasks skipped via onlyIf still appear in the graph; skipping is evaluated later, per task, during execution:

[TaskExecutionGraphListener] Graph is ready. Tasks to execute:
  :hello
  :skippedTask
  :upToDateTask
  :runAll
[TaskExecutionGraphListener] :skippedTask is in the graph (it will still be skipped via onlyIf)

> Task :hello
Hello from the :hello task!

> Task :skippedTask SKIPPED

> Task :upToDateTask
upToDateTask: file written.

> Task :runAll

BUILD SUCCESSFUL in 0s
2 actionable tasks: 2 executed

Build Services

Build Services are used to share state or resources across tasks and across the build lifecycle.

This example implements OperationCompletionListener directly on the service to count tasks as they complete, and uses close() as an end-of-build hook to print the final summary, whether the build succeeded or failed:

build.gradle.kts
abstract class BuildDurationService
    : BuildService<BuildServiceParameters.None>, AutoCloseable {

    private val startTime = System.currentTimeMillis()

    // Called once at the end of the build — reliable teardown hook
    override fun close() {
        val elapsed = System.currentTimeMillis() - startTime
        println("─────────────────────────────────────")
        println("  Build duration : ${elapsed}ms")
        println("─────────────────────────────────────")
    }
}

val buildDurationService = gradle.sharedServices.registerIfAbsent("buildDuration", BuildDurationService::class) {
    maxParallelUsages = 1
}

tasks.register("taskA") {
    group = "demo"
    usesService(buildDurationService)
    val service = buildDurationService
    doLast {
        service.get()
        println("taskA running...")
        Thread.sleep(200)
    }
}

tasks.register("taskB") {
    group = "demo"
    doLast {
        println("taskB running...")
        Thread.sleep(300)
    }
}
build.gradle
abstract class BuildDurationService
    implements BuildService<BuildServiceParameters.None>, AutoCloseable {

    private final long startTime = System.currentTimeMillis()

    // Called once at the end of the build — reliable teardown hook
    @Override
    void close() {
        def elapsed = System.currentTimeMillis() - startTime
        println("─────────────────────────────────────")
        println("  Build duration : ${elapsed}ms")
        println("─────────────────────────────────────")
    }
}

def buildDurationService = gradle.sharedServices.registerIfAbsent("buildDuration", BuildDurationService) {
    maxParallelUsages = 1
}

tasks.register("taskA") {
    group = "demo"
    usesService(buildDurationService)
    def service = buildDurationService
    doLast {
        service.get()
        println("taskA running...")
        Thread.sleep(200)
    }
}

tasks.register("taskB") {
    group = "demo"
    doLast {
        println("taskB running...")
        Thread.sleep(300)
    }
}

The hooks are used to calculate the duration of the build:

> Task :taskA
taskA running...

> Task :taskB
taskB running...

> Task :runAll
─────────────────────────────────────
  Build duration : 510ms
─────────────────────────────────────

BUILD SUCCESSFUL in 0s
2 actionable tasks: 2 executed

Flow Actions Incubating

FlowAction is a newer API (introduced in Gradle 7.6) that lets you run logic in response to build lifecycle events, most commonly, when the build finishes. It was introduced as a Configuration Cache and Isolated Projects compatible alternative to BuildListener and other deprecated lifecycle listeners.

To use a Flow Action, you define a class implementing FlowAction<T> and a matching Parameters interface that declares the inputs your action needs. You then register it inside a Plugin using FlowScope, which is injected by Gradle alongside FlowProviders:

build.gradle.kts
abstract class PrintBuildResultPlugin : Plugin<Project> {

    @get:Inject
    abstract val flowScope: FlowScope

    @get:Inject
    abstract val flowProviders: FlowProviders

    override fun apply(target: Project) {
        flowScope.always(BuildResultPrinter::class.java) {
            parameters.buildResult.set(flowProviders.buildWorkResult)
        }
    }
}

abstract class BuildResultPrinter : FlowAction<BuildResultPrinter.Parameters> {

    interface Parameters : FlowParameters {
        @get:Input
        val buildResult: Property<BuildWorkResult>
    }

    override fun execute(parameters: Parameters) {
        val result = parameters.buildResult.get()
        if (result.failure.isPresent) {
            println("Build failed: ${result.failure.get().message}")
        } else {
            println("Build succeeded")
        }
    }
}
build.gradle
abstract class PrintBuildResultPlugin implements Plugin<Project> {

    @Inject
    abstract FlowScope getFlowScope()

    @Inject
    abstract FlowProviders getFlowProviders()

    @Override
    void apply(Project target) {
        flowScope.always(BuildResultPrinter) {
            parameters.buildResult.set(flowProviders.buildWorkResult)
        }
    }
}

abstract class BuildResultPrinter implements FlowAction<BuildResultPrinter.Parameters> {

    interface Parameters extends FlowParameters {
        @Input
        Property<BuildWorkResult> getBuildResult()
    }

    @Override
    void execute(Parameters parameters) {
        def result = parameters.buildResult.get()
        if (result.failure.isPresent()) {
            println "Build failed: ${result.failure.get().message}"
        } else {
            println "Build succeeded"
        }
    }
}

As expected, we see the build finish success message:

Build succeeded

BUILD SUCCESSFUL in 0s
1 actionable task: 1 executed