From 84cad637d00014cfe0abc98b358e8235d21327ca Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Sat, 30 May 2026 11:02:21 +0300 Subject: [PATCH 01/13] ci(codeql): force Kotlin recompile so CodeQL sees source (#218) The Analyze Kotlin job failed with 'no source code seen during build' (exit code 32): assembleDebug compile tasks were served from cache / marked UP-TO-DATE, so CodeQL's tracer observed no Kotlin source. Add --no-build-cache --rerun-tasks to the CodeQL build step to force actual recompilation, giving the tracer source to analyze. Co-authored-by: Claude --- .github/workflows/codeql.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9ae0ba7a..d278e323 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,7 +33,10 @@ jobs: languages: java-kotlin - name: Build for CodeQL - # autobuild looks for 'testClasses' which doesn't exist in KMP; build manually instead - run: ./gradlew assembleDebug + # autobuild looks for 'testClasses' which doesn't exist in KMP; build manually instead. + # --no-build-cache + --rerun-tasks force Kotlin to actually recompile so the CodeQL + # tracer observes source. Without this, cached/up-to-date compile tasks are skipped and + # CodeQL fails with "no source code seen during build" (exit code 32). + run: ./gradlew assembleDebug --no-build-cache --rerun-tasks - uses: github/codeql-action/analyze@v4 From 405886ff7cb1fd06eef9c6267a658d19235d8d71 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Sat, 30 May 2026 11:22:19 +0300 Subject: [PATCH 02/13] =?UTF-8?q?chore:=20release=201.0.0=20=E2=80=94=20An?= =?UTF-8?q?droid-stable,=20docs=20restructure,=20CodeQL=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump VERSION_NAME to 1.0.0 - Add [1.0.0] CHANGELOG entry (Android-facing API as primary stable target) - Fix mkdocs: exclude cc-verification/specs, add Known Limitations to nav, move iOS guides to "iOS Preview" section, update site_description - Add "Stable in 1.0" admonition to Android guide - Add "Preview" admonitions to iOS guides - Fix CodeQL workflow: build-mode=manual + --no-build-cache --rerun-tasks --- .github/workflows/codeql.yml | 1 + CHANGELOG.md | 16 +++++++++++++++- docs/guides/android.md | 3 +++ docs/guides/ios.md | 4 ++++ docs/ios-integration.md | 4 ++++ gradle.properties | 2 +- mkdocs.yml | 11 +++++++---- 7 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d278e323..f16772bb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,6 +31,7 @@ jobs: - uses: github/codeql-action/init@v4 with: languages: java-kotlin + build-mode: manual - name: Build for CodeQL # autobuild looks for 'testClasses' which doesn't exist in KMP; build manually instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index f8aea622..e109320f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] - 2026-05-30 + +First stable release of Featured. + +Android is the primary stable target in this release — the Android-facing public API for all +`dev.androidbroadcast.featured:*` artifacts published to Maven Central is covered +by semantic versioning guarantees from this version onward. + +iOS (via KMP / SKIE) and JVM targets are included and functional, but API stability +guarantees for those platforms will be formalised in a future minor release. + +See [1.0.0-Beta1] for the full list of features added in the initial release. + ## [1.0.0-Beta1] - 2026-05-17 ### Added @@ -84,5 +97,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - License mismatch: use MIT in all POM declarations (#174) - Stale artifact IDs in quick-start docs (#179) -[Unreleased]: https://github.com/androidbroadcast/Featured/compare/v1.0.0-Beta1...HEAD +[Unreleased]: https://github.com/androidbroadcast/Featured/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/androidbroadcast/Featured/compare/v1.0.0-Beta1...v1.0.0 [1.0.0-Beta1]: https://github.com/androidbroadcast/Featured/releases/tag/v1.0.0-Beta1 diff --git a/docs/guides/android.md b/docs/guides/android.md index 9988731a..bb9e19e0 100644 --- a/docs/guides/android.md +++ b/docs/guides/android.md @@ -1,5 +1,8 @@ # Android Integration Guide +!!! success "Stable in 1.0" + The Android API is stable and covered by semantic versioning from version 1.0.0 onward. + This guide walks you through integrating Featured into an Android project from scratch — from adding Gradle dependencies to using flags in a ViewModel with Compose and Firebase Remote Config. ## 1. Add Gradle dependencies diff --git a/docs/guides/ios.md b/docs/guides/ios.md index ca053632..0e29a188 100644 --- a/docs/guides/ios.md +++ b/docs/guides/ios.md @@ -1,5 +1,9 @@ # iOS Integration Guide +!!! warning "Preview" + iOS support is functional but API stability is not yet guaranteed. + Stable iOS support is planned for a future minor release. + Featured exposes its Kotlin API to Swift via [SKIE](https://skie.touchlab.co/), which bridges coroutines, sealed classes, and default arguments automatically. ## 1. Swift Package Manager setup diff --git a/docs/ios-integration.md b/docs/ios-integration.md index cd3d83e0..d02a1e99 100644 --- a/docs/ios-integration.md +++ b/docs/ios-integration.md @@ -1,5 +1,9 @@ # iOS Integration Guide: Swift Dead Code Elimination with #if +!!! warning "Preview" + iOS support is functional but API stability is not yet guaranteed. + Stable iOS support is planned for a future minor release. + This guide explains how to use the `featured-gradle-plugin` xcconfig output to eliminate disabled feature-flag code paths from your iOS Release binaries at compile time. diff --git a/gradle.properties b/gradle.properties index c4166f50..8baa4710 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ android.r8.strictInputValidation=true android.proguard.failOnMissingFiles=true # Publishing -VERSION_NAME=1.0.0-Beta1 +VERSION_NAME=1.0.0 # Dokka org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled diff --git a/mkdocs.yml b/mkdocs.yml index 5eaba4d6..1fd14cb8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: Featured -site_description: Type-safe, reactive feature-flag and configuration management for Kotlin Multiplatform +site_description: Type-safe, reactive feature-flag and configuration management for Android (Kotlin Multiplatform) site_url: https://androidbroadcast.github.io/Featured/ repo_name: AndroidBroadcast/Featured repo_url: https://github.com/AndroidBroadcast/Featured @@ -32,7 +32,8 @@ theme: - content.tabs.link exclude_docs: | - superpowers/ + cc-verification/ + specs/ plugins: - search @@ -56,11 +57,13 @@ nav: - Getting Started: getting-started.md - Guides: - Android: guides/android.md - - iOS: guides/ios.md - JVM: guides/jvm.md - Providers: guides/providers.md - Best Practices: guides/best-practices.md - R8 DCE Verification: guides/r8-verification.md - - iOS Dead-Code Elimination: ios-integration.md + - Known Limitations: known-limitations.md + - iOS Preview: + - iOS Integration: guides/ios.md + - Swift Dead-Code Elimination: ios-integration.md - API Reference: api/index.md - Changelog: changelog.md From a6e55a855782397c400637197f2d20b92f3706e6 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Sat, 30 May 2026 11:56:31 +0300 Subject: [PATCH 03/13] test(shrinker): cover -keep defeating flag dead-code elimination (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(shrinker): cover -keep defeating flag dead-code elimination A consumer -keep rule (often a broad wildcard or @Keep) that covers a flag-guarded class defeats R8 tree-shaking: -assumevalues still folds the disabled branch (behaviour unchanged), but the class itself is pinned as an unconditional GC root and ships in the APK despite being unreachable — silently losing the size benefit of build-time flags. - Add writeBooleanRulesWithKeptDeadBranch() modelling the pitfall - Add a regression test asserting the dead-branch class survives the keep - Document the two-phase elimination model and keep-rule guidance in the R8 verification guide * test(shrinker): assert branch folding in keep regression; fix docs Address review feedback on the -keep regression test and guide: - The keep test now also asserts BifurcatedCaller no longer references IfBranchCode, proving R8 still folded the disabled branch (phase 1) rather than only keeping the class alive via the kept caller. Adds assertClassDoesNotReference() (ASM bytecode inspection). - Move -dontoptimize out of the 'not a problem' list in the R8 guide into a distinct hazard note — it suppresses elimination and must not be grouped with the harmless accessor-method keep. --------- Co-authored-by: Claude --- docs/guides/r8-verification.md | 48 ++++++++++ .../shrinker/assertions/JarAssertions.kt | 89 +++++++++++++++++++ .../r8/R8BooleanFlagEliminationTest.kt | 30 +++++++ .../shrinker/rules/ProguardRulesWriter.kt | 26 ++++++ 4 files changed, 193 insertions(+) diff --git a/docs/guides/r8-verification.md b/docs/guides/r8-verification.md index 51cffe8b..ec03556f 100644 --- a/docs/guides/r8-verification.md +++ b/docs/guides/r8-verification.md @@ -42,6 +42,10 @@ present or absent, proving that the rule caused (or did not cause) elimination. The third test is a control: it proves that elimination is caused by the rule, not by R8's own constant-folding. +The fourth test — `dead-branch class survives when a user -keep rule pins it despite the +assumevalues rule` — covers the consumer pitfall described in +[Keep rules vs. flag-guarded code](#keep-rules-vs-flag-guarded-code) below. + ### Int flags (`R8IntFlagEliminationTest`) | Test | Rule | Expected | @@ -52,6 +56,50 @@ own constant-folding. With `-assumevalues return 0`, R8 constant-folds `0 > 0` to `false` and eliminates the guarded block entirely. +## Keep rules vs. flag-guarded code + +Dead-code elimination for a disabled flag happens in two distinct R8 phases: + +1. **Constant folding + branch elimination** (an *optimization*). The `-assumevalues` rule + pins the flag accessor to a constant, so R8 folds `if (false) { … }` and removes the dead + branch — including the call into the flag-guarded class — from the *caller's* method body. +2. **Tree-shaking** (a *shrink*). Once nothing references the flag-guarded class anymore, it + becomes unreachable and R8 drops it from the output. + +Phase 2 is reachability-based, and `-keep` is an **unconditional root** of the reachability +graph. So if a consumer adds a `-keep` that covers a flag-guarded class: + +- Phase 1 **still runs** — the branch is still folded away, the guarded code never executes, + and runtime behaviour is unchanged. No crash, no correctness regression. +- Phase 2 **does not run** for that class — R8 treats it as always-reachable and keeps it in + the APK even though nothing references it. + +**The net effect is a silent loss of the size benefit:** dead code ships in the APK with no +visible symptom. This is verified by the `dead-branch class survives when a user -keep rule +pins it…` test in `R8BooleanFlagEliminationTest`. + +A deliberate `-keep` on a specific flag class is rare. The realistic causes are **broad +rules that accidentally cover flag-guarded code**: + +- catch-all wildcards such as `-keep class com.myapp.** { *; }`; +- `@Keep` annotations applied at a package or base-class level; +- reflection / serialization keep rules (Gson, Moshi, etc.) and DI keep rules; +- `consumer-rules.pro` shipped by third-party libraries. + +**Recommendation:** keep these rules as narrow as possible and avoid blanket `-keep` over +packages that contain flag-guarded features. + +One related rule is *not* a problem and should not be "fixed": + +- `-keep` on the **flag accessor method** — `-assumevalues` still constant-folds the value, so + branch elimination is unaffected; `-keep` only prevents removing/renaming the method itself. + +A separate hazard, **not** a keep rule, also defeats elimination and should be avoided: + +- `-dontoptimize` disables phase 1 entirely and stops all constant folding, so disabled + branches are never removed in the first place. Do not enable it in a build that relies on + build-time flag DCE. + ## Running the tests ```bash diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt index 91483997..bd633f44 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt @@ -1,7 +1,13 @@ package dev.androidbroadcast.featured.shrinker.assertions +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes import java.io.File import java.util.jar.JarFile +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -28,3 +34,86 @@ internal fun assertClassPresent( ) } } + +/** + * Asserts that the bytecode of [ownerInternalName] in [jar] contains no reference to + * [referencedInternalName] — neither as a `NEW`/type reference nor as a method-call target. + * + * This proves that R8 actually constant-folded the flag and removed the dead branch's call + * site from the *caller*, as opposed to merely keeping the branch-target class alive. A test + * that only checked class presence would still pass if folding silently stopped working and + * the call site stayed reachable; this assertion closes that gap. + */ +internal fun assertClassDoesNotReference( + jar: File, + ownerInternalName: String, + referencedInternalName: String, +) { + val classBytes = + JarFile(jar).use { jf -> + val entry = + assertNotNull( + jf.getJarEntry("$ownerInternalName.class"), + "Expected $ownerInternalName to be present in the output JAR", + ) + jf.getInputStream(entry).use { it.readBytes() } + } + + var referenced = false + val referencedType = "L$referencedInternalName;" + val detector = + object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + exceptions: Array?, + ): MethodVisitor = + object : MethodVisitor(Opcodes.ASM9) { + override fun visitTypeInsn( + opcode: Int, + type: String?, + ) { + if (type == referencedInternalName) referenced = true + } + + override fun visitMethodInsn( + opcode: Int, + owner: String?, + name: String?, + descriptor: String?, + isInterface: Boolean, + ) { + if (owner == referencedInternalName) referenced = true + } + + override fun visitFieldInsn( + opcode: Int, + owner: String?, + name: String?, + descriptor: String?, + ) { + if (owner == referencedInternalName) referenced = true + } + } + + override fun visitField( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + value: Any?, + ): FieldVisitor? { + if (descriptor == referencedType) referenced = true + return null + } + } + ClassReader(classBytes).accept(detector, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + + assertFalse( + referenced, + "Expected $ownerInternalName to no longer reference $referencedInternalName after R8 " + + "folded the disabled branch, but a reference was found in its bytecode", + ) +} diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt index fda8aba7..7aa4a708 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt @@ -1,12 +1,14 @@ package dev.androidbroadcast.featured.shrinker.r8 import dev.androidbroadcast.featured.shrinker.assertions.assertClassAbsent +import dev.androidbroadcast.featured.shrinker.assertions.assertClassDoesNotReference import dev.androidbroadcast.featured.shrinker.assertions.assertClassPresent import dev.androidbroadcast.featured.shrinker.bytecode.BIFURCATED_CALLER_INTERNAL import dev.androidbroadcast.featured.shrinker.bytecode.ELSE_BRANCH_CODE_INTERNAL import dev.androidbroadcast.featured.shrinker.bytecode.IF_BRANCH_CODE_INTERNAL import dev.androidbroadcast.featured.shrinker.harness.R8TestHarness import dev.androidbroadcast.featured.shrinker.rules.writeBooleanRules +import dev.androidbroadcast.featured.shrinker.rules.writeBooleanRulesWithKeptDeadBranch import dev.androidbroadcast.featured.shrinker.rules.writeNoBooleanAssumeRules import kotlin.test.Test @@ -94,4 +96,32 @@ internal class R8BooleanFlagEliminationTest : R8TestHarness() { assertClassPresent(outputJar, IF_BRANCH_CODE_INTERNAL) assertClassPresent(outputJar, ELSE_BRANCH_CODE_INTERNAL) } + + /** + * Regression guard for the consumer pitfall: a user-supplied `-keep` on a class that is + * only reachable through the disabled branch defeats dead-code elimination. + * + * The `-assumevalues … return false` rule is present and still works — R8 constant-folds + * the flag and drops the dead branch's call site, so runtime behaviour is unchanged. But + * `-keep class IfBranchCode { *; }` is an unconditional GC root, so the class itself can no + * longer be tree-shaken: it survives in the output even though nothing reaches it. + * + * This is the failure mode behind broad wildcard / `@Keep` rules silently inflating the + * APK. The control test above proves elimination normally happens; this test proves a + * `-keep` is what brings the dead class back. + * + * The final assertion pins the documented split between the two R8 phases: even though the + * class is kept, the `-assumevalues` rule must still have folded the disabled branch, so + * `BifurcatedCaller` must no longer reference `IfBranchCode`. Without this, the test would + * pass even if folding silently stopped working and the call site stayed reachable. + */ + @Test + fun `dead-branch class survives when a user -keep rule pins it despite the assumevalues rule`() { + val outputJar = runBooleanR8 { writeBooleanRulesWithKeptDeadBranch(it) } + + assertClassPresent(outputJar, IF_BRANCH_CODE_INTERNAL) + assertClassPresent(outputJar, ELSE_BRANCH_CODE_INTERNAL) + assertClassPresent(outputJar, BIFURCATED_CALLER_INTERNAL) + assertClassDoesNotReference(outputJar, BIFURCATED_CALLER_INTERNAL, IF_BRANCH_CODE_INTERNAL) + } } diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt index 69f409d2..2bce2b03 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt @@ -49,6 +49,32 @@ internal fun writeBooleanRules( ) } +/** + * Same `-assumevalues` block as [writeBooleanRules] with `returnValue = false`, but with an + * extra `-keep class … { *; }` on the **dead-branch** class ([IF_BRANCH_CODE_FQN]). + * + * This models a consumer that — deliberately or, far more commonly, via a broad wildcard or + * `@Keep` rule — pins a class that is only reachable through a disabled flag branch. + * + * The `-assumevalues` rule still lets R8 constant-fold the flag and drop the dead branch's + * *call site*, so behaviour is unchanged. But `-keep` is an unconditional GC root: the class + * itself can no longer be tree-shaken and survives in the output despite being unreachable, + * silently defeating the size benefit of build-time flags. + */ +internal fun writeBooleanRulesWithKeptDeadBranch(dest: File) { + dest.writeText( + """ + -assumevalues class $BOOL_EXTENSIONS_FQN { + boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return false; + } + -keep class $BIFURCATED_CALLER_FQN { *; } + -keep class $IF_BRANCH_CODE_FQN { *; } + -keepclassmembers class $ELSE_BRANCH_CODE_FQN { public static int sideEffect; } + -dontwarn ** + """.trimIndent(), + ) +} + /** * No `-assumevalues` block — R8 cannot constant-fold the flag value. * The `-keepclassmembers` rules ensure the `sideEffect` field is not stripped From 278ad48a866a261475656ab6d9f807f403bb85ea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 31 May 2026 03:27:06 +0000 Subject: [PATCH 04/13] chore: update Package.swift checksum for v1.0.0 --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index bb109f11..9e95682f 100644 --- a/Package.swift +++ b/Package.swift @@ -21,8 +21,8 @@ let package = Package( .binaryTarget( name: "FeaturedCore", // Updated automatically by CI on each release. - url: "https://github.com/AndroidBroadcast/Featured/releases/download/v1.0.0-Beta1/FeaturedCore.xcframework.zip", - checksum: "cebaef358e5ec71f0ee2128ae5c91a8a4257e63da4d0b4b93c7c8a74784e373b" + url: "https://github.com/AndroidBroadcast/Featured/releases/download/v1.0.0/FeaturedCore.xcframework.zip", + checksum: "e74984347e12aa67796d6ca7d30b1ac3104f697b3faf73703718e49045924a01" ), ] ) From fff11d5c1870f3005f06a4e09c6fdf542ca46e8c Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Mon, 1 Jun 2026 11:07:20 +0300 Subject: [PATCH 05/13] Wire Gradle Plugin Portal publishing for clean 1.0.0 release (#230) * Publish Gradle plugin to Plugin Portal, keep Central listing clean (#228) Apply com.gradle.plugin-publish so the two java-gradle-plugin marker artifacts (incl. the second groupId) are hosted on the Gradle Plugin Portal via publishPlugins. Disable the marker -> Maven Central tasks so the Central listing carries only the clean featured-gradle-plugin impl jar (+ sources/javadoc/pom). Co-authored-by: Claude Opus 4.8 (1M context) * Publish plugin to Gradle Plugin Portal on tagged releases Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 10 ++++++++ featured-gradle-plugin/build.gradle.kts | 32 +++++++++++++++++++++++++ gradle/libs.versions.toml | 2 ++ 3 files changed, 44 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ee87f8f3..b189d9d0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -52,6 +52,16 @@ jobs: # release-pipeline debuggability (runs once per tag, CC savings are zero). run: ./gradlew --no-daemon publishToMavenCentral --no-configuration-cache + - name: Publish plugin to Gradle Plugin Portal + # Tag-only: the Portal rejects SNAPSHOT versions; branch pushes produce SNAPSHOTs. + # publishPlugins uploads directly — no manual promotion step unlike Maven Central. + if: startsWith(github.ref, 'refs/tags/') + env: + GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} + ORG_GRADLE_PROJECT_VERSION_NAME: ${{ steps.version.outputs.VERSION_NAME }} + run: ./gradlew --no-daemon :featured-gradle-plugin:publishPlugins --no-configuration-cache + publish-xcframework: name: Publish XCFramework to GitHub Release # Only runs for version tags — not for SNAPSHOT pushes to main. diff --git a/featured-gradle-plugin/build.gradle.kts b/featured-gradle-plugin/build.gradle.kts index f4628d82..83e60ded 100644 --- a/featured-gradle-plugin/build.gradle.kts +++ b/featured-gradle-plugin/build.gradle.kts @@ -1,7 +1,10 @@ +import org.gradle.api.publish.maven.tasks.PublishToMavenRepository + plugins { alias(libs.plugins.kotlinJvm) alias(libs.plugins.kotlinSerialization) `java-gradle-plugin` + alias(libs.plugins.pluginPublish) alias(libs.plugins.mavenPublish) } @@ -13,14 +16,22 @@ kotlin { } gradlePlugin { + website.set("https://github.com/AndroidBroadcast/Featured") + vcsUrl.set("https://github.com/AndroidBroadcast/Featured") plugins { create("featured") { id = "dev.androidbroadcast.featured" implementationClass = "dev.androidbroadcast.featured.gradle.FeaturedPlugin" + displayName = "Featured Gradle Plugin" + description = "Gradle plugin for Featured – generates type-safe configuration flag declarations" + tags.set(listOf("featured", "feature-flags", "configuration", "codegen", "kotlin-multiplatform")) } create("featuredApplication") { id = "dev.androidbroadcast.featured.application" implementationClass = "dev.androidbroadcast.featured.gradle.FeaturedApplicationPlugin" + displayName = "Featured Application Gradle Plugin" + description = "Featured aggregator plugin – merges per-module flag manifests into a single generated registry" + tags.set(listOf("featured", "feature-flags", "configuration", "codegen", "kotlin-multiplatform")) } } } @@ -59,6 +70,27 @@ mavenPublishing { } } +// The java-gradle-plugin marker artifacts (one per plugin id, the second under its own +// groupId) are resolved via the Gradle Plugin Portal, where `publishPlugins` uploads them. +// Vanniktech binds every MavenPublication — markers included — to the Maven Central +// repository, which would pollute the Central listing with two stub marker artifacts. +// Disable only the marker -> Central tasks so Central carries just the clean +// `featured-gradle-plugin` impl jar (+ sources/javadoc/pom); the Portal still receives the +// markers via `publishPlugins`, which uploads publications directly over HTTP independently +// of these maven-publish repository tasks. +// +// Match on the task name (which Gradle fixes at registration time) rather than the task's +// `repository`/`publication` properties: maven-publish wires those properties later, in an +// afterEvaluate, so a `configureEach` action that reads them runs too early and sees them +// unset. The name `publishPublicationToRepository` is always correct here. +// `publishPluginMavenPublication...` (the impl) ends with `PluginMavenPublication...`, not +// `PluginMarkerMavenPublication...`, so it is intentionally left enabled. +tasks.withType().configureEach { + if (name.endsWith("PluginMarkerMavenPublicationToMavenCentralRepository")) { + enabled = false + } +} + // A separate configuration whose resolved jars are appended to the pluginUnderTestMetadata // classpath. This makes GradleRunner.withPluginClasspath() inject them into the TestKit // subprocess, which is necessary for compileOnly dependencies (like AGP) that the plugin diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 94e2a257..a315c2c4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ spotless = "8.4.0" ktlint = "1.8.0" turbine = "1.2.1" mavenPublish = "0.36.0" +pluginPublish = "2.1.1" dokka = "2.2.0" detekt = "1.23.8" configcat = "5.1.0" @@ -93,5 +94,6 @@ kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } skie = { id = "co.touchlab.skie", version.ref = "skie" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } +pluginPublish = { id = "com.gradle.plugin-publish", version.ref = "pluginPublish" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } androidKmpLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } From 8a918ad158e155dcf2f86d519babbc2872691776 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 1 Jun 2026 08:15:23 +0000 Subject: [PATCH 06/13] chore: update Package.swift checksum for v1.0.0 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 9e95682f..df1b6017 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ let package = Package( name: "FeaturedCore", // Updated automatically by CI on each release. url: "https://github.com/AndroidBroadcast/Featured/releases/download/v1.0.0/FeaturedCore.xcframework.zip", - checksum: "e74984347e12aa67796d6ca7d30b1ac3104f697b3faf73703718e49045924a01" + checksum: "aea0c7ba061dc002f801bd4755e17b2e2b2e8760387fa0dd8879bce8c6a5ce54" ), ] ) From a9ba3d976cf7978553b417a694066f5e16fba479 Mon Sep 17 00:00:00 2001 From: Kirill Rozov Date: Mon, 1 Jun 2026 11:46:12 +0300 Subject: [PATCH 07/13] Extract Gradle Plugin Portal publish into separate workflow (#232) Isolates Portal publication so a re-run never re-triggers the Maven Central step. Adds workflow_dispatch with required ref/version inputs so the v1.0.0 tag can be published retroactively. Root cause fixed: the missing GPG signing env (ORG_GRADLE_PROJECT_signingInMemoryKey*) is now present alongside Portal creds, resolving the signatory error on marker publications. --- .github/workflows/publish-plugin-portal.yml | 66 +++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/publish-plugin-portal.yml diff --git a/.github/workflows/publish-plugin-portal.yml b/.github/workflows/publish-plugin-portal.yml new file mode 100644 index 00000000..4be4ff7e --- /dev/null +++ b/.github/workflows/publish-plugin-portal.yml @@ -0,0 +1,66 @@ +name: Publish Plugin to Gradle Plugin Portal + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" + # No branches: branch pushes produce SNAPSHOTs; Portal rejects them. + workflow_dispatch: + inputs: + ref: + description: "Git ref to check out (e.g. v1.0.0). Must point to the exact release tag." + required: true + version: + description: "Plugin version to publish (e.g. 1.0.0, without leading v)." + required: true + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish-plugin-portal: + name: Publish plugin to Gradle Plugin Portal + runs-on: ubuntu-latest + environment: Main + + steps: + - uses: actions/checkout@v6 + with: + # On tag push: check out the triggering tag. + # On workflow_dispatch: check out the explicit ref input so develop-HEAD + # (1.1.0-SNAPSHOT) is never published in place of the release tag. + ref: ${{ inputs.ref || github.ref }} + + - uses: ./.github/actions/setup-build-env + + - name: Determine version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + if [[ -n "${INPUT_VERSION}" ]]; then + # Manual dispatch: use the explicitly supplied version. + VERSION="${INPUT_VERSION}" + else + # Tag push: strip the leading "v" from the tag (e.g. v1.0.0 -> 1.0.0). + VERSION="${GITHUB_REF_NAME#v}" + fi + echo "VERSION_NAME=${VERSION}" | tee -a "$GITHUB_OUTPUT" + + - name: Publish plugin to Gradle Plugin Portal + # publishPlugins uploads directly — no manual promotion step unlike Maven Central. + # GPG signing env is required: com.gradle.plugin-publish creates maven publications + # for plugin markers and the signing plugin signs them as part of publishPlugins. + env: + GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.GPG_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_KEY_PASSWORD }} + ORG_GRADLE_PROJECT_VERSION_NAME: ${{ steps.version.outputs.VERSION_NAME }} + run: ./gradlew --no-daemon :featured-gradle-plugin:publishPlugins --no-configuration-cache From 9cbd50a05d233eda743b097e4649e56a0ff70e00 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 3 Jun 2026 19:35:20 +0000 Subject: [PATCH 08/13] chore: update Package.swift checksum for v1.1.0 --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 9e95682f..1f3f3a63 100644 --- a/Package.swift +++ b/Package.swift @@ -21,8 +21,8 @@ let package = Package( .binaryTarget( name: "FeaturedCore", // Updated automatically by CI on each release. - url: "https://github.com/AndroidBroadcast/Featured/releases/download/v1.0.0/FeaturedCore.xcframework.zip", - checksum: "e74984347e12aa67796d6ca7d30b1ac3104f697b3faf73703718e49045924a01" + url: "https://github.com/AndroidBroadcast/Featured/releases/download/v1.1.0/FeaturedCore.xcframework.zip", + checksum: "28b0d80da0bdce65eca3eafd4f836e4dcd49e10f2fcf7ea8c38eb8aa31e2be6f" ), ] ) From 4b5fffe1559bcc1d4df5f352a6d5876360c6a94d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 5 Jun 2026 12:06:35 +0000 Subject: [PATCH 09/13] chore: update Package.swift checksum for v1.1.1 --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 1f3f3a63..53d84adc 100644 --- a/Package.swift +++ b/Package.swift @@ -21,8 +21,8 @@ let package = Package( .binaryTarget( name: "FeaturedCore", // Updated automatically by CI on each release. - url: "https://github.com/AndroidBroadcast/Featured/releases/download/v1.1.0/FeaturedCore.xcframework.zip", - checksum: "28b0d80da0bdce65eca3eafd4f836e4dcd49e10f2fcf7ea8c38eb8aa31e2be6f" + url: "https://github.com/AndroidBroadcast/Featured/releases/download/v1.1.1/FeaturedCore.xcframework.zip", + checksum: "74e7b65ddae47b14b7e669b3433ac8969971a9fb97c79dcde87bc99b45a054bf" ), ] ) From a11ebeeee8de49a481bc684918febc8e44cc7060 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Wed, 10 Jun 2026 13:58:09 +0300 Subject: [PATCH 10/13] ci: upload to Central Portal as USER_MANAGED, drop auto-release Backport of #245 to main so the re-created v1.1.1 tag publishes the deployment as USER_MANAGED (manual promotion in the Portal) instead of auto-releasing, which was failing with 403 on the build-service upload. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/publish.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b0f20c86..0c350074 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,16 +48,11 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.GPG_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_KEY_PASSWORD }} ORG_GRADLE_PROJECT_VERSION_NAME: ${{ steps.version.outputs.VERSION_NAME }} - # On a version tag: pass -PmavenCentralAutomaticPublishing=true so the Vanniktech plugin - # releases the Central deployment automatically (no manual staging promote needed). - # On SNAPSHOT branch pushes: plain upload to the snapshots repo, no auto-release. + # On a version tag: uploads the deployment bundle to the Sonatype Central Portal as + # USER_MANAGED — the deployment must be promoted to release manually in the Portal UI. + # On SNAPSHOT branch pushes: uploads to the snapshots repo. # --no-configuration-cache for release-pipeline debuggability (runs once per tag, CC savings are zero). - run: | - AUTO="" - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then - AUTO="-PmavenCentralAutomaticPublishing=true" - fi - ./gradlew --no-daemon publishToMavenCentral $AUTO --no-configuration-cache + run: ./gradlew --no-daemon publishToMavenCentral --no-configuration-cache publish-xcframework: name: Publish XCFramework to GitHub Release From b3cd6afb1c6d9c58976b18e2da8bc3d8462ebd51 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 10 Jun 2026 11:11:49 +0000 Subject: [PATCH 11/13] chore: update Package.swift checksum for v1.1.1 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 53d84adc..b637e076 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ let package = Package( name: "FeaturedCore", // Updated automatically by CI on each release. url: "https://github.com/AndroidBroadcast/Featured/releases/download/v1.1.1/FeaturedCore.xcframework.zip", - checksum: "74e7b65ddae47b14b7e669b3433ac8969971a9fb97c79dcde87bc99b45a054bf" + checksum: "0eff77bd2c9d25a903167d30563a273e54a070d34d4c9d3ff61ea8c628da25ec" ), ] ) From 92551d60a0fb9ebc7a3c9772b38b5035e06776b7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 13 Jun 2026 12:37:27 +0000 Subject: [PATCH 12/13] chore: update Package.swift checksum for v1.2.0 --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index b637e076..7fd3d422 100644 --- a/Package.swift +++ b/Package.swift @@ -21,8 +21,8 @@ let package = Package( .binaryTarget( name: "FeaturedCore", // Updated automatically by CI on each release. - url: "https://github.com/AndroidBroadcast/Featured/releases/download/v1.1.1/FeaturedCore.xcframework.zip", - checksum: "0eff77bd2c9d25a903167d30563a273e54a070d34d4c9d3ff61ea8c628da25ec" + url: "https://github.com/AndroidBroadcast/Featured/releases/download/v1.2.0/FeaturedCore.xcframework.zip", + checksum: "a3a6237f766bc884bde21742429c60b9e6695a6af01c78b651be5a0802e826d2" ), ] ) From 6317cba48e1569dcc58f0c6280d3b0d71e87c1b0 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Sat, 13 Jun 2026 15:38:55 +0300 Subject: [PATCH 13/13] Back-merge v1.2.0 release and bump develop to 1.2.1-SNAPSHOT Co-Authored-By: Claude Opus 4.8 (1M context) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 65939715..c1e1fdb7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -45,7 +45,7 @@ android.r8.strictInputValidation=true android.proguard.failOnMissingFiles=true # Publishing -VERSION_NAME=1.2.0 +VERSION_NAME=1.2.1-SNAPSHOT # Dokka org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled