IP-safe partial cross-project information aggregation with tracked outputs

Hello, we have a Gradle 9.5.1 multi-project build using Kotlin DSL and migrating to Isolated Projects.

Some subprojects have:

  • taskA: scans local annotation metadata.
  • taskB: depends on taskA and is the usual partial-build entry point.

The root project has:

  • taskAAggregator: merges the taskA outputs from the modules involved in the current invocation.
  • taskBAggregator: consumes the merged A output and performs the next cross-project processing step.

For example:

./gradlew :module1:taskB :module2:taskB

should effectively run:

:module1:taskA
:module2:taskA
:taskAAggregator
:module1:taskB
:module2:taskB
:taskBAggregator

The important constraint is that this must remain a partial build. Running :module1:taskB should not force taskA in every other module.

Desired semantics

Conceptually, this is what we want:

taskA.finalizedBy(taskAAggregator)
taskAAggregator.mustRunAfter(taskA)

taskB.finalizedBy(taskBAggregator)
taskBAggregator.mustRunAfter(taskB)

taskBAggregator.dependsOn(taskAAggregator)

The mustRunAfter is important because the aggregator should run after producers that are already in the task graph, but should not pull in producers from unrelated modules.

However, direct cross-project finalizedBy / mustRunAfter wiring is not allowed under Isolated Projects because it requires accessing another project’s task container.

Patterns considered

Artifact/variant-based aggregation is IP-safe and gives proper tracked inputs/outputs, but it forces all producer tasks to run when the aggregate is requested. That does not fit our partial-build requirement.

An AutoCloseable build service can collect only the producers that actually ran, but the resulting aggregate is not a tracked task output and cannot be used cleanly as an input to taskBAggregator.

Possible workaround: local marker/gate tasks

We are considering replacing direct cross-project task references with artifact-based proxy tasks.

In each subproject:

taskAMustRunAfter.mustRunAfter(taskA)
taskA.finalizedBy(taskAFinalizedBy)
  • taskAMustRunAfter is a lightweight marker task that publishes a dummy artifact.
  • taskAFinalizedBy is a lightweight finalizer/gate task that depends on the root aggregator via an artifact dependency.

In the root project:

  • taskAAggregator resolves the marker artifacts from all subprojects.
  • taskAAggregator publishes its own marker artifact.
  • subproject taskAFinalizedBy tasks consume that root marker artifact.

This avoids direct cross-project task access. All cross-project relationships go through Gradle’s artifact/variant system.

The intended ordering is:

selected taskA tasks
→ marker tasks
→ taskAAggregator
→ finalizer/gate tasks

The same pattern would be repeated for taskB / taskBAggregator, with taskBAggregator depending on taskAAggregator.

Questions

  1. Is there an Isolated Projects-compatible way to express this directly:
    “run an aggregator after whichever producer tasks are already in the task graph, without causing all producers to run”?

  2. Is there a supported pattern for this when the first aggregator must produce a real tracked output that another aggregator consumes as an input?

  3. Is the marker/gate task approach valid under Isolated Projects?

    In particular:

    • Is a subproject-to-root artifact dependency supported under IP?
    • Will Gradle resolve the combination of finalizedBy plus transitive artifact-backed dependsOn ordering correctly?
    • Does this approach really avoid running heavy producer tasks in modules that were not part of the original invocation?
  4. If this is not the right approach, what workflow would Gradle recommend for partial cross-project aggregation with tracked outputs under Isolated Projects and Configuration Cache?

Any examples from existing plugins or builds with similar partial aggregation behavior would be very helpful.