##// END OF EJS Templates
WIP working on outgoing variant artifacts
WIP working on outgoing variant artifacts

File last commit:

r47:6084dc61f02a default
r47:6084dc61f02a default
Show More
variant_artifacts.md
694 lines | 19.2 KiB | text/x-minidsrc | MarkdownLexer
/ variant_artifacts.md

Variants and Variant Artifacts

Overview

This document describes the artifact model built on top of variants and variantSources.

The goal is to define:

  • a stable artifact-facing model for outgoing contracts;
  • a DSL for declaring slots and their inputs;
  • a resolver bridge between variantArtifacts and variantSources;
  • extension points for deduplication and similar policies;
  • a model that remains live during configuration and does not require an artificial freeze of slot content.

The design follows the same split already used elsewhere:

  • variants defines the closed domain topology;
  • variantSources defines source materialization semantics over that topology;
  • variantArtifacts defines outgoing artifact contracts over that topology.

Core idea

variantArtifacts is not the owner of the variant model and not the owner of source-set materialization.

Its purpose is narrower:

  • decide which variants participate in outgoing publication;
  • define artifact slots for those outgoing variants;
  • declare how each slot gathers its inputs.

This makes variantArtifacts an outgoing-contract layer, not a compilation or source-materialization layer.


Model boundaries

What belongs to variants

  • identities: Variant, Role, Layer;
  • normalized topology relation (variant, role, layer);
  • finalization of the core domain model;
  • VariantsView and derived topology views.

What belongs to variantSources

  • compile units and role projections derived from finalized variants;
  • source-set naming policy;
  • source-set materialization;
  • lazy access to GenericSourceSet providers and their named outputs.

What belongs to variantArtifacts

  • selection of outgoing variants;
  • slot identities inside an outgoing variant;
  • slot assembly declarations;
  • root outgoing configurations for outgoing variants;
  • slot-level assembly bodies;
  • publication-facing hooks.

What does not belong to variantArtifacts

  • ownership of variant existence;
  • ownership of source-set naming;
  • direct mutation of source materialization internals;
  • compiler- or toolchain-specific logic;
  • eager flattening of source inputs into files during DSL declaration.

Outgoing subset

Not every declared Variant must become outgoing.

variantArtifacts defines an outgoing subset of variants.

For each outgoing variant there is one root outgoing aggregate:

  • one root consumable Configuration;
  • one or more artifact slots;
  • one primary slot;
  • optional secondary slots.

This is why OutgoingConfiguration is a real model object and not merely a publication event payload.

It represents a live build-facing aggregate:

  • it has its own attributes;
  • it is visible to other build logic as soon as registered;
  • it contains lazy handles rather than eagerly materialized state;
  • it is distinct from slot assembly state.

Identity-first split

The artifact model should preserve the same identity-first principle used for the rest of the project.

Identity objects

  • Variant
  • Slot
  • ArtifactSlot = (variant, slot)

These objects are:

  • cheap;
  • replayable;
  • suitable for discovery and selection.

Stateful objects

  • OutgoingConfiguration
  • ArtifactAssembly

These objects are:

  • build-facing;
  • allowed to contain Gradle lazy handles;
  • obtained through dedicated APIs rather than embedded into identity objects.

Rule of thumb

  • slot identity is cheap and replayable;
  • inside one OutgoingConfiguration, Slot is the natural local identity;
  • ArtifactSlot is useful when slot identity must be referenced outside the parent outgoing configuration;
  • slot body is stateful and resolved separately;
  • outgoing configuration is a live aggregate, not a mere snapshot.

Artifact model

Conceptually:

interface VariantArtifactsContext {
    VariantsView getVariants();
    void all(Action<? super OutgoingConfiguration> action);
    Optional<OutgoingConfiguration> findArtifacts(Variant variant);
    OutgoingConfiguration requireArtifacts(Variant variant);
    ArtifactAssemblies getAssemblies();
}
interface OutgoingConfiguration {
    Variant getVariant();
    NamedDomainObjectProvider<Configuration> getOutgoingConfiguration();
    NamedDomainObjectContainer<Slot> getSlots();
    Property<Slot> getPrimarySlot();
}
interface ArtifactAssemblies {
    ArtifactAssembly resolveSlot(ArtifactSlot slot);
}

This intentionally uses a Gradle-style local slot container rather than a separate ArtifactSlotsView.

The reason is simple:

  • inside one OutgoingConfiguration, slot identity is local and naturally expressed as Slot;
  • a dedicated ArtifactSlotsView would suggest a detached readonly projection without clearly owning mutation/configuration semantics;
  • NamedDomainObjectContainer<Slot> better matches the live configuration model and keeps the parent aggregate as the owner of slot structure.

ArtifactSlot remains useful, but only as a fully qualified identity outside the parent aggregate:

  • resolver APIs;
  • global payloads;
  • cross-variant references.

Important distinction:

  • OutgoingConfiguration describes outgoing publication structure;
  • ArtifactAssembly describes how one slot artifact is assembled.

An ArtifactAssembly does not create the root outgoing configuration. It serves one already declared slot inside that configuration.

Primary slot ownership

Primary status belongs to the parent outgoing configuration, not to the slot itself.

This is why the preferred container-level contract is:

Property<Slot> getPrimarySlot();

and not a slot-local boolean or a derived ArtifactSlot reference.

Reasons:

  • the primary role is assigned by the parent aggregate;
  • within one OutgoingConfiguration, the variant identity is already known, so Slot is sufficient and avoids redundant (variant, slot) duplication;
  • Property matches the rest of the Gradle-facing live model better than a custom findPrimarySlot() style API;
  • Property gives useful write/configure/finalize semantics without inventing a separate special lifecycle abstraction.

Expected usage:

  • DSL or adapters may assign the primary slot through set(...);
  • adapters that want to provide a default may use convention(...);
  • the property may remain unset while the model is still incomplete;
  • at materialization time the property may be finalized;
  • if more than one slot exists and primarySlot is still unset at the materialization point, that is a model error.

The model should still enforce that the selected primary slot belongs to the same OutgoingConfiguration.

Proposed public shape

With these constraints, the preferred public structure is:

interface VariantArtifactsContext {
    VariantsView getVariants();
    void all(Action<? super OutgoingConfiguration> action);
    Optional<OutgoingConfiguration> findArtifacts(Variant variant);
    OutgoingConfiguration requireArtifacts(Variant variant);
    ArtifactAssemblies getAssemblies();
}

interface OutgoingConfiguration {
    Variant getVariant();
    NamedDomainObjectProvider<Configuration> getOutgoingConfiguration();
    NamedDomainObjectContainer<Slot> getSlots();
    Property<Slot> getPrimarySlot();
}

interface ArtifactAssemblies {
    ArtifactAssembly resolveSlot(ArtifactSlot slot);
}

record ArtifactSlot(Variant variant, Slot slot) {}

This gives:

  • one global registry of outgoing variants;
  • one local slot container per outgoing variant;
  • one fully qualified slot identity for resolver and cross-aggregate use;
  • one explicit service for slot assembly materialization.

Minimal internal shape

The preferred minimal internal structure is:

final class VariantArtifactsRegistry implements VariantArtifactsContext {
    OutgoingConfiguration outgoingConfiguration(Variant variant);
    ArtifactAssemblyRules slotRules(ArtifactSlot slot);
    ArtifactAssemblies assemblies();
}

interface ArtifactAssemblyRules {
    void from(Object input);
    void fromVariant(Action<? super OutputSelectionSpec> action);
    void fromRole(String roleName, Action<? super OutputSelectionSpec> action);
    void fromLayer(String layerName, Action<? super OutputSelectionSpec> action);
}

Responsibility split:

  • VariantArtifactsRegistry owns the whole artifact model;
  • OutgoingConfiguration owns only the structural variant-local aggregate;
  • ArtifactAssemblyRules owns the content declaration of one slot;
  • ArtifactAssemblies materializes ArtifactAssembly from ArtifactSlot -> ArtifactAssemblyRules.

This keeps OutgoingConfiguration focused on structure and avoids overloading it with slot-content APIs such as rules(slot).

DSL binding

The DSL should be connected through the registry, not by making OutgoingConfiguration responsible for content rules.

Conceptually:

variantArtifacts.variant("browser", spec -> {
    spec.slot("runtime", assembly -> {
        assembly.fromRole("production", out -> out.output("js"));
    });
});

Operationally this means:

  1. registry creates or returns OutgoingConfiguration for the variant;
  2. slot declaration creates or returns Slot inside that outgoing configuration;
  3. registry forms ArtifactSlot(variant, slot);
  4. registry resolves slotRules(artifactSlot);
  5. bound ArtifactAssemblySpec writes into those rules.

So the DSL writes:

  • structure into OutgoingConfiguration;
  • slot content into registry-owned ArtifactAssemblyRules.

This is the intended bridge point between the public DSL and the internal resolver/materialization model.


Live model and monotonic structure

variantArtifacts should be treated as a live configuration model during the whole configuration phase.

This means:

  • slot inputs remain live;
  • from(...), fromVariant(...), fromRole(...), fromLayer(...) may keep contributing inputs until task execution;
  • ArtifactAssembly may expose live FileCollection, Provider, and task wiring;
  • external task outputs remain outside the control of this model and must be accepted as live inputs.

The model should therefore avoid a mandatory freeze phase for slot content.

Instead, it should follow a monotonic rule:

  • outgoing variant existence may grow;
  • slot existence may grow;
  • slot content may grow;
  • publication-visible identity should not be retroactively redefined.

In practice this means:

  • slot names are stable once declared;
  • primary slot designation is structural;
  • slot input content remains live.

This also means that the model does not need a dedicated freeze phase for slot content merely because the root outgoing configuration was registered earlier.

Early registration of the root Configuration and live evolution of slot input content are compatible concerns.


DSL principles

The DSL should remain declarative and symbolic.

It should describe:

  • which variant is outgoing;
  • which slots exist;
  • which slot is primary;
  • which selectors contribute inputs to each slot.

It should not directly expose:

  • GenericSourceSet;
  • FileCollection;
  • concrete resolved files from variantSources;
  • internal resolver state.

DSL shape

Conceptually:

variantArtifacts {
    variant("browser") {
        primarySlot("runtime") {
            fromRole("production") {
                output("js")
                output("resources")
            }
        }

        slot("types") {
            fromVariant {
                output("dts")
            }
        }

        slot("sources") {
            fromLayer("main") {
                output("sources")
            }
        }

        slot("bundleMetadata") {
            from(someTask)
            from(layout.buildDirectory.file("generated/meta.json"))
        }
    }
}

Meaning of contribution forms

  • from(Object) adds a direct input independent from variantSources;
  • fromVariant { output(...) } selects named outputs from all compile units of the current variant;
  • fromRole(role) { output(...) } selects named outputs from compile units that belong to the given role projection;
  • fromLayer(layer) { output(...) } selects named outputs from the compile unit of the current variant and the given layer, if such unit exists.

The DSL stores declarations, not resolved file collections.


Contribution model

Internally the DSL should compile to slot contributions.

Conceptually:

  • DirectContribution
  • VariantOutputContribution
  • RoleOutputContribution
  • LayerOutputContribution

These contributions should remain symbolic for as long as possible.

They should not resolve source sets or files at declaration time.

Each contribution is expected to provide:

  • its selection scope;
  • the requested output names;
  • enough symbolic identity for later validation and resolver policies.

Resolver bridge between variantSources and variantArtifacts

This is the central integration point.

variantArtifacts should not access mutable internals of variantSources.

Instead, it should resolve slot inputs through the public finalized VariantSourcesContext.

Bridge responsibilities

The bridge:

  • takes slot contribution declarations;
  • expands them against finalized variant topology;
  • maps logical selectors to compile units and role projections;
  • obtains source sets lazily through VariantSourcesContext;
  • resolves named outputs from those source sets;
  • builds the live input model for an ArtifactAssembly.

Bridge input

  • current outgoing variant identity;
  • slot contribution declarations;
  • VariantSourcesContext.

Bridge output

  • a live collection of logical slot inputs;
  • later adapted to FileCollection or other assembly-facing input models.

Resolution semantics

For one outgoing variant:

  • fromVariant { output(x) }
  • expands to all CompileUnit of that variant;
  • fromRole(role) { output(x) }
  • expands to RoleProjection(variant, role) and then to its compile units;
  • fromLayer(layer) { output(x) }
  • expands to one compile unit (variant, layer) when it exists;
  • from(Object)
  • bypasses variantSources completely.

After compile units are known, the bridge asks ctx.getSourceSets().getSourceSet(unit) for each selected unit and resolves the requested named output.

This keeps variantArtifacts independent from source-set naming internals and other materialization details.


Validation

Validation should be structural and symbolic.

It should validate:

  • outgoing variant refers to an existing Variant;
  • referenced Role exists in that variant projection space;
  • referenced Layer exists in that variant compile-unit space;
  • primary slot is defined when needed;
  • primary slot refers to a slot declared in the same outgoing configuration.

Validation should not require eager materialization of source sets or eager resolution of files.


Deduplication and policy extension points

Deduplication is important, but it should not be baked into the DSL itself.

The correct place for it is the resolver bridge, after symbolic contributions have been expanded to logical inputs but before they are finally adapted to assembly-facing file collections.

Why not in the DSL

At declaration time it is still unknown whether selectors overlap:

  • fromVariant
  • fromRole
  • fromLayer

may all describe the same logical source output.

Why not rely only on FileCollection

FileCollection may still provide useful physical deduplication, but it is too late and too file-oriented to serve as the only semantic mechanism.

The artifact model should first deduplicate logical inputs, then let Gradle perform any additional physical deduplication.

Default expectation

The default resolver should support:

  • deduplication of topology-aware inputs by logical identity;
  • no implicit deduplication of direct from(Object) inputs.

Logical identity should be based on domain meaning, for example:

  • (CompileUnit, outputName)

and not on projected source-set names.

This is important because source-set naming policy belongs to variantSources and must not silently redefine artifact semantics.

Extension points

The model should provide explicit internal extension points for:

  • deduplication policy;
  • logical input identity;
  • adaptation of resolved logical inputs to assembly-facing objects.

Conceptually:

interface SlotInputDedupPolicy { ... }
interface LogicalSlotInputIdentity { ... }
interface SlotInputAdapter { ... }

The default implementation may remain simple, but these seams should exist from the start.


Publication hooks

Publication hooks remain useful, but they should observe the live structural model rather than define it.

Examples:

  • whenOutgoingVariant(...)
  • whenOutgoingSlot(...)

These hooks are adapter-facing customization points over already declared outgoing structure:

  • root configuration attributes;
  • slot artifact attributes;
  • assembly task tweaks.

The recommended way to connect publication-facing Spec objects to the structural model is by backlink, not by moving slot rules into publication types.

Conceptually:

interface OutgoingConfigurationSpec {
    OutgoingConfiguration getOutgoingArtifacts();
    Variant getVariant();
    Configuration getConfiguration();
}

interface OutgoingArtifactSlotSpec {
    ArtifactSlot getArtifactSlot();
    ArtifactAssembly getAssembly();
    boolean isPrimary();
}

In this arrangement:

  • OutgoingConfigurationSpec remains a publication-facing facade;
  • OutgoingConfigurationSpec may expose the structural aggregate when an adapter needs it;
  • slot rules still belong to VariantArtifactsRegistry;
  • publication specs do not become owners of declaration or resolver state.

They should not become the primary structural API for the artifact model.

This is why a separate phase-oriented OutgoingPublicationsContext is not required.

The live OutgoingConfiguration aggregate is sufficient.


Design principles

1. Keep topology ownership in variants

variantArtifacts selects from the topology model. It does not own it.

2. Keep source ownership in variantSources

variantArtifacts consumes source materialization through a resolver bridge. It does not own source-set semantics.

3. Keep the DSL symbolic

The DSL declares intent and selection rules, not materialized files.

4. Keep slot content live

Do not introduce an artificial finalize phase for slot content unless a real semantic need appears.

5. Fix only structural identity

Slot name, primary designation, and outgoing shape are structural. Slot inputs remain live.

6. Resolve through dedicated bridges

Cross-model integration belongs in a resolver service, not in DSL classes.

7. Add policy seams early

Deduplication and similar concerns should have extension points from the start, even if the initial implementation is conservative.


Summary

variantArtifacts should be modeled as a live outgoing-contract layer over variants, with source input resolution delegated to a dedicated bridge over variantSources.

The resulting shape is:

  • variants owns topology;
  • variantSources owns source materialization;
  • variantArtifacts owns outgoing contract structure;
  • a resolver bridge connects symbolic slot declarations to live source-derived inputs;
  • deduplication and similar concerns are policies of that bridge, not of the DSL itself;
  • slot content stays live during configuration;
  • only publication-visible structure is treated as stable identity.