diff --git a/.github/workflows/api-compatibility-check.yml b/.github/workflows/api-compatibility-check.yml new file mode 100644 index 0000000000..48fe3b68fc --- /dev/null +++ b/.github/workflows/api-compatibility-check.yml @@ -0,0 +1,77 @@ +name: API Compatibility Check + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + branches: + - develop + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + api-compatibility: + name: Metalava API Compatibility + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: GetStream/android-ci-actions/actions/setup-java@main + + - name: Check API compatibility + id: api-check + run: | + ./gradlew \ + :stream-video-android-core:metalavaCheckCompatibilityRelease \ + :stream-video-android-ui-core:metalavaCheckCompatibilityRelease \ + --no-configuration-cache --stacktrace + continue-on-error: true + + - name: Generate current API signature + if: steps.api-check.outcome == 'failure' + run: | + ./gradlew \ + :stream-video-android-core:metalavaGenerateSignatureRelease \ + :stream-video-android-ui-core:metalavaGenerateSignatureRelease \ + --no-configuration-cache + + - name: Diff API changes + if: steps.api-check.outcome == 'failure' + run: | + echo "## API Changes Detected" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + for module in stream-video-android-core stream-video-android-ui-core; do + if [ -f "$module/api/current.txt" ]; then + DIFF=$(git diff --no-color -- "$module/api/current.txt" || true) + if [ -n "$DIFF" ]; then + echo "### $module" >> $GITHUB_STEP_SUMMARY + echo '```diff' >> $GITHUB_STEP_SUMMARY + echo "$DIFF" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + fi + done + + echo "> **Action required:** This PR contains breaking API changes." >> $GITHUB_STEP_SUMMARY + echo "> Add the \`approved-api-change\` label to acknowledge and proceed." >> $GITHUB_STEP_SUMMARY + + - name: Check for approval label + if: steps.api-check.outcome == 'failure' + run: | + if echo '${{ toJson(github.event.pull_request.labels.*.name) }}' | grep -q 'approved-api-change'; then + echo "✅ API breaking change approved via label" + echo "## ✅ Breaking API change approved" >> $GITHUB_STEP_SUMMARY + echo "The \`approved-api-change\` label is present. This change has been explicitly approved." >> $GITHUB_STEP_SUMMARY + else + echo "❌ Breaking API changes detected without approval" + echo "" + echo "To approve this change:" + echo " 1. Review the API diff in the job summary above" + echo " 2. Add the 'approved-api-change' label to this PR" + echo " 3. Re-run this workflow" + exit 1 + fi diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 0290acae97..9ccedb5298 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { compileOnly(libs.kotlin.gradlePlugin) // compileOnly(libs.compose.compiler.gradlePlugin) -> Enable with Kotlin 2.0+ compileOnly(libs.spotless.gradlePlugin) + runtimeOnly(libs.metalava.gradlePlugin) } gradlePlugin { @@ -38,5 +39,9 @@ gradlePlugin { id = "io.getstream.video.android.demoflavor" implementationClass = "DemoFlavorConventionPlugin" } + register("metalavaConvention") { + id = "io.getstream.video.android.metalava" + implementationClass = "MetalavaConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/MetalavaConventionPlugin.kt b/build-logic/convention/src/main/kotlin/MetalavaConventionPlugin.kt new file mode 100644 index 0000000000..9a59740140 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/MetalavaConventionPlugin.kt @@ -0,0 +1,12 @@ +import io.getstream.video.configureMetalava +import org.gradle.api.Plugin +import org.gradle.api.Project + +class MetalavaConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("me.tylerbwong.gradle.metalava") + configureMetalava() + } + } +} diff --git a/build-logic/convention/src/main/kotlin/io/getstream/video/MetalavaSetup.kt b/build-logic/convention/src/main/kotlin/io/getstream/video/MetalavaSetup.kt new file mode 100644 index 0000000000..37f379c62d --- /dev/null +++ b/build-logic/convention/src/main/kotlin/io/getstream/video/MetalavaSetup.kt @@ -0,0 +1,52 @@ +package io.getstream.video + +import org.gradle.api.Project +import org.gradle.api.provider.Property +import org.gradle.api.provider.SetProperty + +/** + * Configures the Metalava plugin for API signature generation and compatibility tracking. + * + * @param hiddenAnnotations Annotations whose marked APIs are excluded from the signature. + * @param extraArguments Additional metalava CLI arguments (use `--flag=value` format). + */ +fun Project.configureMetalava( + hiddenAnnotations: Set = emptySet(), + extraArguments: Set = emptySet(), +) { + val metalavaEnabled = providers.gradleProperty("metalava.enabled") + .getOrElse("true").toBoolean() + + val metalava = extensions.getByName("metalava") + + @Suppress("UNCHECKED_CAST") + (metalava.javaClass.getMethod("getFilename").invoke(metalava) as Property) + .set("api/current.txt") + + if (hiddenAnnotations.isNotEmpty()) { + @Suppress("UNCHECKED_CAST") + (metalava.javaClass.getMethod("getHiddenAnnotations").invoke(metalava) as SetProperty) + .set(hiddenAnnotations) + } + + val defaultArgs = setOf( + "--hide=ReferencesHidden", + "--hide=HiddenTypeParameter", + "--hide=UnavailableSymbol", + "--hide=IoError", + ) + @Suppress("UNCHECKED_CAST") + (metalava.javaClass.getMethod("getArguments").invoke(metalava) as SetProperty) + .set(defaultArgs + extraArguments) + + afterEvaluate { + tasks.matching { it.name.startsWith("metalava") }.configureEach { + setEnabled(metalavaEnabled) + } + tasks.named("apiDump") { + if (metalavaEnabled) { + finalizedBy("metalavaGenerateSignatureRelease") + } + } + } +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index c29e0e9764..198af61c4d 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -5,6 +5,7 @@ dependencyResolutionManagement { google() mavenCentral() mavenLocal() + gradlePluginPortal() } versionCatalogs { create("libs") { diff --git a/build.gradle.kts b/build.gradle.kts index 03fef86ec8..7c2477c5f8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -100,15 +100,20 @@ subprojects { //} afterEvaluate { - println("Running Add Pre Commit Git Hook Script on Build") - exec { - if (System.getProperty("os.name").toLowerCase().contains("win")) { - // Windows-specific command - commandLine("cmd", "/c", "copy", ".\\scripts\\git-hooks\\pre-push", ".\\.git\\hooks") - } else { - // Unix-based systems - commandLine("cp", "./scripts/git-hooks/pre-push", "./.git/hooks") + val gitDir = file(".git") + if (gitDir.isDirectory) { + println("Running Add Pre Commit Git Hook Script on Build") + exec { + if (System.getProperty("os.name").toLowerCase().contains("win")) { + // Windows-specific command + commandLine("cmd", "/c", "copy", ".\\scripts\\git-hooks\\pre-push", ".\\.git\\hooks") + } else { + // Unix-based systems + commandLine("cp", "./scripts/git-hooks/pre-push", "./.git/hooks") + } } + println("Added pre-push Git Hook Script.") + } else { + println("Skipping git hook installation (worktree or non-standard .git)") } - println("Added pre-push Git Hook Script.") } diff --git a/gradle.properties b/gradle.properties index 1e86ddacba..64c38fad1c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -45,5 +45,8 @@ android.suppressUnsupportedCompileSdk=34 enableComposeCompilerMetrics=true enableComposeCompilerReports=true +# Metalava API signature generation (set to false to disable) +metalava.enabled=true + # Project version version=1.19.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5317ff3120..ed1619bb91 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,6 +84,7 @@ playAppUpdate = "2.1.0" hilt = "2.52" leakCanary = "2.13" binaryCompatabilityValidator = "0.16.3" +metalavaGradle = "0.5.0" playPublisher = "3.12.1" googleMlKitSelfieSegmentation = "16.0.0-beta6" @@ -223,6 +224,7 @@ android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", ver kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } compose-compiler-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } spotless-gradlePlugin = { group = "com.diffplug.spotless", name = "spotless-plugin-gradle", version.ref = "spotless" } +metalava-gradlePlugin = { group = "me.tylerbwong.gradle.metalava", name = "me.tylerbwong.gradle.metalava.gradle.plugin", version.ref = "metalavaGradle" } androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraCore" } play-services-mlkit-barcode-scanning = { group = "com.google.android.gms", name = "play-services-mlkit-barcode-scanning", version = "18.3.0" } androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraCore" } @@ -256,3 +258,4 @@ firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = " hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } play-publisher = { id = "com.github.triplet.play", version.ref = "playPublisher" } baseline-profile = { id = "androidx.baselineprofile", version.ref = "androidxMacroBenchmark" } +metalava-gradle = { id = "me.tylerbwong.gradle.metalava", version.ref = "metalavaGradle" } diff --git a/stream-video-android-core/build.gradle.kts b/stream-video-android-core/build.gradle.kts index 2351fb500f..ce66fe7080 100644 --- a/stream-video-android-core/build.gradle.kts +++ b/stream-video-android-core/build.gradle.kts @@ -23,6 +23,7 @@ plugins { id(libs.plugins.kotlin.serialization.get().pluginId) id(libs.plugins.kotlin.parcelize.get().pluginId) id(libs.plugins.wire.get().pluginId) + id("io.getstream.video.android.metalava") } wire { @@ -52,6 +53,12 @@ apiValidation { nonPublicMarkers.add("io.getstream.video.android.core.internal.InternalStreamVideoApi") } +metalava { + hiddenAnnotations.set( + setOf("io.getstream.video.android.core.internal.InternalStreamVideoApi"), + ) +} + android { namespace = "io.getstream.video.android.core" compileSdk = libs.versions.compileSdk.get().toInt() diff --git a/stream-video-android-ui-core/build.gradle.kts b/stream-video-android-ui-core/build.gradle.kts index 37d8077666..e3daaa9d9a 100644 --- a/stream-video-android-ui-core/build.gradle.kts +++ b/stream-video-android-ui-core/build.gradle.kts @@ -16,6 +16,7 @@ plugins { id("io.getstream.video.android.library") + id("io.getstream.video.android.metalava") } android {