# Build Lifecycle

version 9.6.0

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

### 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: `build` → `assemble` → `createDocs`.

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:

## 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 through 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