Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Featured library plugin now publishes a per-module feature-flag manifest as a consumable Gradle artifact (`featuredManifest` configuration, schema v1). Existing flag-generation pipeline is unchanged. Consumer-side aggregation arrives in a follow-up release.

## [1.0.0-Beta1] - 2026-05-17

### Added
Expand Down
2 changes: 2 additions & 0 deletions featured-gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSerialization)
`java-gradle-plugin`
alias(libs.plugins.mavenPublish)
}
Expand Down Expand Up @@ -70,6 +71,7 @@ tasks.pluginUnderTestMetadata {

dependencies {
compileOnly("com.android.tools.build:gradle:9.1.0")
implementation(libs.kotlinx.serialization.json)
// Inject AGP into the TestKit subprocess via pluginUnderTestMetadata so that the Featured
// plugin can access AndroidComponentsExtension when wireProguardToVariants() is called.
testPluginClasspath("com.android.tools.build:gradle:9.1.0")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package dev.androidbroadcast.featured.gradle

import dev.androidbroadcast.featured.gradle.manifest.FEATURED_MANIFEST_CONFIGURATION_NAME
import dev.androidbroadcast.featured.gradle.manifest.FEATURED_MANIFEST_USAGE
import dev.androidbroadcast.featured.gradle.manifest.GENERATE_FEATURED_MANIFEST_TASK_NAME
import dev.androidbroadcast.featured.gradle.manifest.GenerateFeaturedManifestTask
import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION
import dev.androidbroadcast.featured.gradle.manifest.schemaMajorAttr
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.attributes.Usage
import org.gradle.api.tasks.TaskProvider

internal const val RESOLVE_FLAGS_TASK_NAME = "resolveFeatureFlags"
Expand Down Expand Up @@ -54,6 +61,8 @@ public class FeaturedPlugin : Plugin<Project> {
val proguardTask = registerProguardTask(target, resolveTask)
registerIosConstValTask(target, resolveTask)
registerXcconfigTask(target, resolveTask)
val manifestTask = registerManifestTask(target, resolveTask)
registerFeaturedManifestConfiguration(target, manifestTask)
wireToRootAggregator(target, resolveTask)
listOf("com.android.application", "com.android.library").forEach { pluginId ->
target.plugins.withId(pluginId) {
Expand Down Expand Up @@ -145,6 +154,78 @@ public class FeaturedPlugin : Plugin<Project> {
}
}

private fun registerManifestTask(
target: Project,
resolveTask: TaskProvider<ResolveFlagsTask>,
): TaskProvider<GenerateFeaturedManifestTask> =
target.tasks.register(
GENERATE_FEATURED_MANIFEST_TASK_NAME,
GenerateFeaturedManifestTask::class.java,
) { task ->
task.group = "featured"
task.description = "Generates featured-manifest.json for '${target.path}'."
task.flagsFile.set(resolveTask.flatMap { it.outputFile })
// Snapshot target.path at configuration time — Project must not be captured by
// task state to remain Configuration Cache compliant.
task.modulePath.set(target.path)
task.outputFile.convention(
target.layout.buildDirectory.file("featured/featured-manifest.json"),
)
task.dependsOn(resolveTask)
}

private fun registerFeaturedManifestConfiguration(
target: Project,
manifestTask: TaskProvider<GenerateFeaturedManifestTask>,
) {
// Register the schemaMajorAttr in the project's attribute schema so that Gradle's
// dependency resolution can match it precisely between producer and consumer.
target.dependencies.attributesSchema.attribute(schemaMajorAttr)

val manifestConfiguration =
target.configurations.consumable(
FEATURED_MANIFEST_CONFIGURATION_NAME,
) { config ->
config.attributes {
it.attribute(
Usage.USAGE_ATTRIBUTE,
target.objects.named(Usage::class.java, FEATURED_MANIFEST_USAGE),
)
// Use SCHEMA_VERSION constant — not a hardcoded literal — so that a future bump
// automatically flows through to the attribute without a separate edit here.
it.attribute(schemaMajorAttr, SCHEMA_VERSION)
}
}

// Wire the manifest file as an outgoing artifact. The provider chain already carries
// the task dependency; builtBy is explicit for IDE / --dry-run readability.
target.artifacts.add(
FEATURED_MANIFEST_CONFIGURATION_NAME,
manifestTask.flatMap { it.outputFile },
) { artifact ->
artifact.builtBy(manifestTask)
}

// Maven-publish guard intentionally omitted (verified 2026-05-18 via KMP smoke test).
//
// The `java`, `java-library`, `kotlinMultiplatform`, and `com.android.library` software
// components do NOT auto-publish arbitrary consumable configurations. Each component
// exposes only the variants it explicitly added via `addVariantsFromConfiguration` —
// typically `apiElements` / `runtimeElements` for Java, target-specific
// `*ApiElements` / `*RuntimeElements` for KMP, build-type variants for AGP.
//
// The `featuredManifest` configuration is never registered with any of these components,
// so it does not appear in published Maven metadata. A guard via
// `withVariantsFromConfiguration(...) { skip() }` is not only unnecessary — it actively
// throws `Variant for configuration 'featuredManifest' does not exist in component`
// during publication because `withVariantsFromConfiguration` requires the variant to
// have been added first.
//
// The KMP smoke fixture (`kmp-publish-project`) and `FeaturedKmpPublicationTest` verify
// this invariant: a KMP module that applies both `dev.androidbroadcast.featured` and
// `maven-publish` produces module metadata with no `featured-manifest` Usage variant.
}

/**
* Ensures the root project has a `scanAllLocalFlags` aggregation task and wires
* [resolveTask] into it. `./gradlew scanAllLocalFlags` triggers flag resolution
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package dev.androidbroadcast.featured.gradle.manifest

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

/**
* Root manifest published by each module that applies the Featured Gradle plugin.
*
* **Public contract is the JSON wire format documented below.** These Kotlin types are an
* internal producer-side helper. The consumer (PR B aggregator) may implement its own
* deserialization model independently — renaming internal Kotlin fields does NOT break
* the contract; changing the JSON wire format (field names, field semantics, enum variant
* names) DOES break it and requires a [SCHEMA_VERSION] bump.
*
* ---
*
* ## Example JSON
*
* ```json
* {
* "schemaVersion": 1,
* "modulePath": ":feature:checkout",
* "flags": [
* {
* "key": "dark_mode",
* "propertyName": "darkMode",
* "kind": "LOCAL",
* "valueType": "BOOLEAN",
* "defaultValue": "false"
* },
* {
* "key": "promo_banner",
* "propertyName": "promoBanner",
* "kind": "REMOTE",
* "valueType": "STRING",
* "defaultValue": "hello world",
* "description": "Show promo banner"
* },
* {
* "key": "checkout_variant",
* "propertyName": "checkoutVariant",
* "kind": "LOCAL",
* "valueType": "ENUM",
* "defaultValue": "LEGACY",
* "enumTypeFqn": "com.example.CheckoutVariant"
* }
* ]
* }
* ```
*
* ---
*
* ## Field semantics
*
* - **`modulePath`** — Gradle `Project.path` in the `:foo:bar` format. Root project is `":"`.
* - **`propertyName`** — camelCase Kotlin property name for the aggregator to generate.
* Derived from `key` via `toCamelCase()` in the producer.
* - **`kind`** — `LOCAL` for flags declared in `localFlags { }`, `REMOTE` for `remoteFlags { }`.
* - **`defaultValue`** — raw default value as a string. For `STRING` valueType, the enclosing
* quotes (added by `FlagContainer.string()`) are removed by the producer; the stored value
* is the bare string (e.g. `hello world`, not `"hello world"`). For `ENUM` valueType, only
* the constant name is stored (e.g. `LEGACY`, not `EnumClass.LEGACY`) so that the aggregator
* can pass it directly to `enumValueOf<T>(defaultValue)`.
* - **`enumTypeFqn`** — fully-qualified name of the enum class when `valueType` is `ENUM`;
* `null` for all other types.
* - **`description`**, **`category`**, **`expiresAt`** — optional metadata passed through from
* the DSL. Absent from JSON when null (`explicitNulls = false`).
*
* ---
*
* ## Evolvability policy
*
* | Change | Action |
* |---------------------------------------------|----------------------------------------------|
* | Add optional field with a default | Additive — no schema bump |
* | Remove or rename existing field | Breaking — bump [SCHEMA_VERSION] + `schema-major` attribute |
* | Add new enum variant in [FlagKind]/[ValueType] | Breaking — bump major |
* | Change semantics of existing field | Breaking — bump major |
*
* ---
*
* ## ABI status
*
* The `featured-manifest` Usage attribute and the `schema-major` Gradle attribute are stable
* consumer-facing contracts. See [FeaturedManifestContract] for the attribute constants.
*/
@Serializable
internal data class FeaturedManifest(
val schemaVersion: Int,
val modulePath: String,
// No default value — guarantees that an empty list is serialized as "flags":[]
// rather than being omitted when encodeDefaults = false.
val flags: List<FlagDescriptor>,
)

/**
* Describes a single feature flag declared via the `featured { }` DSL.
*
* Null optional fields are omitted from the JSON output (`explicitNulls = false` in
* [FeaturedManifestJson]).
*/
@Serializable
internal data class FlagDescriptor(
val key: String,
val propertyName: String,
val kind: FlagKind,
val valueType: ValueType,
val defaultValue: String,
val enumTypeFqn: String? = null,
val description: String? = null,
val category: String? = null,
val expiresAt: String? = null,
)

/** Whether the flag is declared in `localFlags { }` or `remoteFlags { }`. */
@Serializable
internal enum class FlagKind { LOCAL, REMOTE }

/** The Kotlin type of the flag's value. */
@Serializable
internal enum class ValueType { BOOLEAN, INT, LONG, FLOAT, DOUBLE, STRING, ENUM }

/** Wire-format schema version. Bump this (and the `schema-major` Gradle attribute) on breaking changes. */
internal const val SCHEMA_VERSION = 1

/**
* Pre-configured [Json] instance used for both encoding and decoding [FeaturedManifest].
*
* - `prettyPrint = true` — human-readable output for easier debugging and diff review.
* - `explicitNulls = false` — null optional fields are omitted from the JSON, keeping
* the output compact and forward-compatible.
* - `encodeDefaults = false` — Kotlin default values are not written if they match the
* declared default. Note: [FeaturedManifest.flags] intentionally has **no** default so
* it is always serialized, even when empty.
* - `ignoreUnknownKeys = true` — forward-compatible decoding: a consumer built against
* schema v1 can safely read a manifest produced by a future schema version that added
* optional fields.
*/
internal val FeaturedManifestJson =
Json {
prettyPrint = true
explicitNulls = false
encodeDefaults = false
ignoreUnknownKeys = true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package dev.androidbroadcast.featured.gradle.manifest

import org.gradle.api.attributes.Attribute

internal const val GENERATE_FEATURED_MANIFEST_TASK_NAME = "generateFeaturedManifest"
internal const val FEATURED_MANIFEST_CONFIGURATION_NAME = "featuredManifest"
internal const val FEATURED_MANIFEST_USAGE = "featured-manifest"
internal const val SCHEMA_MAJOR_ATTRIBUTE_NAME = "dev.androidbroadcast.featured.schema-major"

/**
* Gradle attribute that carries the major version of the Featured manifest schema.
*
* The attribute is declared as `Attribute<Int>` for ergonomic use from Kotlin call sites
* (`attribute(schemaMajorAttr, SCHEMA_VERSION)`). Under the hood Kotlin maps `Int` in a
* generic position to `java.lang.Integer`, which is the JVM boxed type Gradle uses for
* attribute equality. [Int.javaObjectType] (`Int::class.javaObjectType`) returns exactly
* `Integer.class`, so this is wire-compatible with a Java-side `Attribute<Integer>`.
*
* The consumer (PR B aggregator) must declare the same [Attribute] instance — sharing
* this constant guarantees a single Attribute object.
*/
internal val schemaMajorAttr: Attribute<Int> =
Attribute.of(SCHEMA_MAJOR_ATTRIBUTE_NAME, Int::class.javaObjectType)
Loading
Loading