You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pulsar.apache.org by lh...@apache.org on 2026/06/16 08:16:00 UTC

(pulsar) branch master updated: [fix][build] Fix the shaded client artifacts: jar contents and Maven publication (#26034)

This is an automated email from the ASF dual-hosted git repository.

lhotari pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar.git


The following commit(s) were added to refs/heads/master by this push:
     new 089990f9f5e [fix][build] Fix the shaded client artifacts: jar contents and Maven publication (#26034)
089990f9f5e is described below

commit 089990f9f5e92ee379781e8aad9966c16a81698b
Author: Lari Hotari <lh...@users.noreply.github.com>
AuthorDate: Tue Jun 16 11:15:53 2026 +0300

    [fix][build] Fix the shaded client artifacts: jar contents and Maven publication (#26034)
---
 .../pulsar.client-shade-conventions.gradle.kts     | 114 +++++++++++++-
 ...lsar.public-java-library-conventions.gradle.kts |   2 +-
 .../kotlin/pulsar.publish-conventions.gradle.kts   | 161 +++-----------------
 ...sar.publish-repositories-conventions.gradle.kts | 169 +++++++++++++++++++++
 build.gradle.kts                                   |  28 +---
 distribution/shell/src/assemble/LICENSE.bin.txt    |   1 -
 gradle/libs.versions.toml                          |   2 +-
 microbench/build.gradle.kts                        |   4 +-
 pulsar-client-admin-shaded/build.gradle.kts        |  29 +++-
 pulsar-client-all/build.gradle.kts                 |  24 +--
 pulsar-client-shaded/build.gradle.kts              |  42 +++--
 pulsar-client/build.gradle.kts                     |   6 +-
 pulsar-common/build.gradle.kts                     |   1 +
 pulsar-functions/runtime-all/build.gradle.kts      |   4 +-
 14 files changed, 381 insertions(+), 206 deletions(-)

diff --git a/build-logic/conventions/src/main/kotlin/pulsar.client-shade-conventions.gradle.kts b/build-logic/conventions/src/main/kotlin/pulsar.client-shade-conventions.gradle.kts
index 60ed0cfa67c..608f93ce5ad 100644
--- a/build-logic/conventions/src/main/kotlin/pulsar.client-shade-conventions.gradle.kts
+++ b/build-logic/conventions/src/main/kotlin/pulsar.client-shade-conventions.gradle.kts
@@ -17,6 +17,15 @@
  * under the License.
  */
 
+import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
+import org.gradle.api.attributes.Bundling
+import org.gradle.api.attributes.Category
+import org.gradle.api.attributes.LibraryElements
+import org.gradle.api.attributes.Usage
+import org.gradle.api.component.AdhocComponentWithVariants
+import org.gradle.api.tasks.PathSensitivity
+import java.util.zip.ZipFile
+
 // Convention plugin for Pulsar client shaded modules (pulsar-client-shaded,
 // pulsar-client-admin-shaded, pulsar-client-all). Configures the shadow jar
 // with the shared dependency includes, file excludes, relocations, and
@@ -29,6 +38,40 @@ plugins {
 val shadePrefix = "org.apache.pulsar.shade"
 extra["shadePrefix"] = shadePrefix
 
+// ---- Published dependency scopes for non-bundled dependencies ----
+// The Shadow plugin publishes the `shadow` configuration's dependencies as the dependency-reduced
+// POM/Gradle Module Metadata of the shaded artifact, mapping ALL of them to Maven `runtime` scope
+// (a single java-runtime variant). That loses the api/implementation distinction the original
+// modules express, so consumers can't compile against the parts of the API that live in non-bundled
+// modules (e.g. pulsar-client-api).
+//
+// To restore control, modules can declare a dependency in the `shadowApi` configuration instead of
+// `shadow`. `shadowApi` deps are published with `compile` scope (a java-api variant added to the
+// shadow component), while `shadow` deps stay `runtime`. Both end up in the POM and GMM:
+//   "shadowApi"(...)  -> compile scope  (consumer compile + runtime classpath)
+//   "shadow"(...)     -> runtime scope  (consumer runtime classpath only)
+val shadowApi = configurations.dependencyScope("shadowApi") {
+    description = "Non-bundled dependencies published with compile (api) scope in the shaded " +
+        "artifact's dependency-reduced POM and Gradle Module Metadata."
+}
+val shadowApiElements = configurations.consumable("shadowApiElements") {
+    description = "API elements (compile scope) of the shaded artifact, mirroring shadowRuntimeElements."
+    extendsFrom(shadowApi.get())
+    attributes {
+        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_API))
+        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY))
+        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR))
+        attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.SHADOWED))
+    }
+    // Carry the shaded jar so this variant is a complete API variant (like apiElements does for the
+    // standard java-library component).
+    outgoing.artifact(tasks.named("shadowJar"))
+}
+// Add the java-api variant to the shadow component so maven-publish emits the shadowApi deps with
+// compile scope. The shadow plugin already wires shadowRuntimeElements -> runtime scope.
+(components["shadow"] as AdhocComponentWithVariants)
+    .addVariantsFromConfiguration(shadowApiElements.get()) { mapToMavenScope("compile") }
+
 tasks.named<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("shadowJar") {
 
     // ---- Dependency includes ----
@@ -171,10 +214,75 @@ tasks.named<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("shadowJ
 
     // ---- File content transformations ----
     relocateAsyncHttpClientProperties(shadePrefix)
-    // Relocate Netty native library filenames to avoid conflicts with unshaded Netty
+    // Relocate Netty native library filenames so the shaded Netty NativeLibraryLoader
+    // can find them (and to avoid conflicts with unshaded Netty). The loader prepends
+    // the shaded package prefix, with dots replaced by underscores, to the library
+    // name, so the prefix has to be inserted right before "netty" (after the "lib"
+    // prefix that .so/.jnilib files carry). For example
+    //   META-INF/native/libnetty_transport_native_epoll_x86_64.so
+    // must be renamed to
+    //   META-INF/native/liborg_apache_pulsar_shade_netty_transport_native_epoll_x86_64.so
+    val nettyNativePrefix = shadePrefix.replace('.', '_') + "_"
     filesMatching("META-INF/native/**") {
-        if (name.matches(Regex("netty.+\\.(so|jnilib|dll)"))) {
-            path = path.replace(name, "org_apache_pulsar_shade_$name")
+        if (name.matches(Regex("(lib)?netty.+\\.(so|jnilib|dll)"))) {
+            path = path.replace(name, name.replaceFirst("netty", "${nettyNativePrefix}netty"))
+        }
+    }
+}
+
+// ---- Shaded jar content validation ----
+// Safety net: after the shaded jar is built, fail the build if it contains any file outside the
+// allowed package roots. This catches dependencies that ended up in the jar without being relocated
+// (e.g. leaked javax.* classes) or native libraries whose filenames were not rewritten. Directory
+// entries (the parent directories of the allowed roots) are ignored.
+val allowedShadedRoots = listOf(
+    "META-INF/",
+    "org/apache/pulsar/",
+    "com/scurrilous/circe/",
+    // Avro reflection annotations are intentionally excluded from the org.apache.avro relocation so
+    // that Avro recognizes them by their original name on user-supplied classes.
+    "org/apache/avro/reflect/",
+    // Bundled native libraries (libcirce-checksum.so, libcpu-affinity.so).
+    "lib/",
+)
+val verifyShadedJarContents = tasks.register("verifyShadedJarContents") {
+    description = "Fails the build if the shaded jar contains files outside the allowed package roots."
+    group = "verification"
+    val shadedJar = tasks.named<ShadowJar>("shadowJar").flatMap { it.archiveFile }
+    inputs.file(shadedJar).withPropertyName("shadedJar").withPathSensitivity(PathSensitivity.NONE)
+    inputs.property("allowedRoots", allowedShadedRoots)
+    val report = layout.buildDirectory.file("verification/verifyShadedJarContents.txt")
+    outputs.file(report).withPropertyName("report")
+    val allowedRoots = allowedShadedRoots
+    doLast {
+        val jar = shadedJar.get().asFile
+        val violations = mutableListOf<String>()
+        ZipFile(jar).use { zip ->
+            val entries = zip.entries()
+            while (entries.hasMoreElements()) {
+                val entry = entries.nextElement()
+                if (entry.isDirectory) continue
+                if (allowedRoots.none { entry.name.startsWith(it) }) {
+                    violations.add(entry.name)
+                }
+            }
+        }
+        violations.sort()
+        if (violations.isNotEmpty()) {
+            throw GradleException(
+                "Shaded jar ${jar.name} contains ${violations.size} file(s) outside the allowed roots " +
+                    "$allowedRoots:\n" + violations.joinToString("\n") { "  $it" }
+            )
+        }
+        report.get().asFile.run {
+            parentFile.mkdirs()
+            writeText(
+                "OK: ${jar.name} contents are within the allowed roots:\n" +
+                    allowedRoots.joinToString("\n") { "  $it" } + "\n"
+            )
         }
     }
 }
+tasks.named<ShadowJar>("shadowJar") {
+    finalizedBy(verifyShadedJarContents)
+}
diff --git a/build-logic/conventions/src/main/kotlin/pulsar.public-java-library-conventions.gradle.kts b/build-logic/conventions/src/main/kotlin/pulsar.public-java-library-conventions.gradle.kts
index 3bb57c41f91..5841e7d70ba 100644
--- a/build-logic/conventions/src/main/kotlin/pulsar.public-java-library-conventions.gradle.kts
+++ b/build-logic/conventions/src/main/kotlin/pulsar.public-java-library-conventions.gradle.kts
@@ -31,7 +31,7 @@ plugins {
 // Test/compileOnly scoped dependencies are excluded since they don't appear in the POM.
 // NAR modules are not validated here — they bundle all dependencies and have empty POMs.
 run {
-    val publishedScopes = listOf("api", "implementation", "runtimeOnly", "shadow")
+    val publishedScopes = listOf("api", "implementation", "runtimeOnly", "shadow", "shadowApi")
     val configsToCheck = publishedScopes.mapNotNull { name ->
         configurations.findByName(name)?.let { name to it }
     }
diff --git a/build-logic/conventions/src/main/kotlin/pulsar.publish-conventions.gradle.kts b/build-logic/conventions/src/main/kotlin/pulsar.publish-conventions.gradle.kts
index 67436adb31a..a81b00d5792 100644
--- a/build-logic/conventions/src/main/kotlin/pulsar.publish-conventions.gradle.kts
+++ b/build-logic/conventions/src/main/kotlin/pulsar.publish-conventions.gradle.kts
@@ -17,17 +17,13 @@
  * under the License.
  */
 
-// Convention plugin for publishing Pulsar modules to Maven repositories.
-// Configures maven-publish, GPG signing, POM metadata, sources/javadoc JARs,
-// the ASF Nexus release/snapshot repositories, and a local deploy repository
-// for testing.
-
-import org.gradle.api.services.BuildService
-import org.gradle.api.services.BuildServiceParameters
+// Convention plugin for the Maven PUBLICATION of each published Pulsar module: the software
+// component, POM metadata, sources/javadoc JARs, and the artifactId. The publish repositories
+// (local + ASF Nexus), GPG signing, the upload-serialization lock and the snapshot/release
+// validation are shared with the root parent-POM project via pulsar.publish-repositories-conventions.
 
 plugins {
-    `maven-publish`
-    signing
+    id("pulsar.publish-repositories-conventions")
 }
 
 // --- java-library projects: JAR + sources + javadoc ---
@@ -108,11 +104,9 @@ run {
     // Capture values in a local scope so withXml closures don't capture the script object
     // (which would break configuration cache serialization)
     val projectName = project.name
-    val archivesNameValue = the<BasePluginExtension>().archivesName.get()
     val isPlatformProject = plugins.hasPlugin("java-platform")
     val isRootProject = project == rootProject
     val pulsarVersion = version.toString()
-    val localDeployRepoDir = rootProject.layout.buildDirectory.dir("local-deploy-repo")
 
     // Per-module POM name and description. Read in afterEvaluate so that a description
     // assigned in a module's build script body is picked up, and captured as plain strings
@@ -129,11 +123,21 @@ run {
         }
     }
 
+    // Set the published artifactId from archivesName. Deferred to afterEvaluate (per the Gradle
+    // maven-publish "deferred configuration" guidance) so a module can override archivesName in its
+    // build script body — e.g. the shaded client modules publish under their historical Maven
+    // artifactId ("pulsar-client", "pulsar-client-admin"). Read eagerly at plugin-application time
+    // the override would be missed. Captured as a plain string for configuration-cache safety.
+    afterEvaluate {
+        val archivesNameValue = the<BasePluginExtension>().archivesName.get()
+        publishing.publications.withType<MavenPublication>().configureEach {
+            artifactId = archivesNameValue
+        }
+    }
+
     publishing {
         publications {
             withType<MavenPublication>().configureEach {
-                artifactId = archivesNameValue
-
                 pom {
                     // Clean up POM XML and inject <parent> reference
                     withXml {
@@ -172,140 +176,9 @@ run {
                 }
             }
         }
-
-        // Local Maven repository for testing/comparison
-        repositories {
-            maven {
-                name = "localDeploy"
-                url = uri(localDeployRepoDir)
-            }
-        }
-    }
-}
-
-// --- Apache distribution repositories (ASF Nexus) ---
-// Repository names follow the ASF parent POM (apache.releases.https / apache.snapshots.https).
-// Publish with one of:
-//   ./gradlew publishAllPublicationsToApacheSnapshotsRepository   (for -SNAPSHOT versions)
-//   ./gradlew publishAllPublicationsToApacheReleasesRepository    (for release versions)
-// Uploads to the Apache staging repository are serialized by the mavenPublishLock shared build
-// service (defined below) so they all land in a single Nexus staging repository: Nexus creates an
-// implicit staging repository on first upload, and concurrent per-module uploads could otherwise be
-// split across multiple implicitly-created staging repositories. Because the lock handles this,
-// --no-parallel is not required.
-// Credentials are resolved by Gradle at execution time from the apacheReleasesUsername /
-// apacheReleasesPassword and apacheSnapshotsUsername / apacheSnapshotsPassword Gradle properties
-// (the credentials(PasswordCredentials::class) form, which keeps the publish tasks
-// configuration-cache compatible — explicitly assigned credentials would not be). Pass them as
-// ORG_GRADLE_PROJECT_-prefixed environment variables on the publish command line so the password
-// doesn't have to be stored in ~/.gradle/gradle.properties where it could leak to unrelated
-// builds; start the command line with a space to keep the password out of shell history:
-//    ORG_GRADLE_PROJECT_apacheReleasesUsername=$APACHE_USER \
-//    ORG_GRADLE_PROJECT_apacheReleasesPassword="<your ASF password>" \
-//    ./gradlew publishAllPublicationsToApacheReleasesRepository ...
-// The URLs can be overridden with the apacheReleasesRepoUrl / apacheSnapshotsRepoUrl Gradle
-// properties (e.g. a file:// URL for testing the publication layout).
-run {
-    fun MavenArtifactRepository.configureApacheRepository(urlProperty: String, defaultUrl: String) {
-        val repositoryUrl = uri(providers.gradleProperty(urlProperty).getOrElse(defaultUrl))
-        url = repositoryUrl
-        // The file transport (an URL overridden to file:// for testing) rejects credentials,
-        // and Gradle's credentials validation would fail when the properties aren't set.
-        if (repositoryUrl.scheme != "file") {
-            credentials(PasswordCredentials::class)
-        }
-    }
-
-    publishing {
-        repositories {
-            maven {
-                name = "apacheReleases"
-                configureApacheRepository(
-                    "apacheReleasesRepoUrl",
-                    "https://repository.apache.org/service/local/staging/deploy/maven2"
-                )
-            }
-            maven {
-                name = "apacheSnapshots"
-                configureApacheRepository(
-                    "apacheSnapshotsRepoUrl",
-                    "https://repository.apache.org/content/repositories/snapshots"
-                )
-            }
-        }
-    }
-
-    // Validate before any upload: only -SNAPSHOT versions may go to apacheSnapshots and only
-    // release versions to apacheReleases. (Maven's deploy picks the repository from the version;
-    // in Gradle the task name picks the repository, so the version must be checked instead.)
-    // The task's repository property is discarded by configuration cache serialization, so
-    // capture the repository name at configuration time and only register the validation
-    // action (capturing plain strings/booleans) for the Apache repositories.
-    val projectVersion = version.toString()
-    val isSnapshotVersion = projectVersion.endsWith("-SNAPSHOT")
-    tasks.withType<PublishToMavenRepository>().configureEach {
-        val repositoryName = repository.name
-        if (repositoryName == "apacheReleases" || repositoryName == "apacheSnapshots") {
-            doFirst {
-                if (repositoryName == "apacheSnapshots" && !isSnapshotVersion) {
-                    throw GradleException(
-                        "Refusing to publish non-snapshot version '$projectVersion' to the " +
-                            "'apacheSnapshots' repository. Use " +
-                            "publishAllPublicationsToApacheReleasesRepository for release versions."
-                    )
-                }
-                if (repositoryName == "apacheReleases" && isSnapshotVersion) {
-                    throw GradleException(
-                        "Refusing to publish snapshot version '$projectVersion' to the " +
-                            "'apacheReleases' repository. Use " +
-                            "publishAllPublicationsToApacheSnapshotsRepository for -SNAPSHOT versions."
-                    )
-                }
-            }
-        }
     }
 }
 
-// --- Serialize uploads to Maven repositories ---
-// Nexus creates an implicit staging repository on the first upload of a release, and concurrent
-// per-module uploads can end up split across multiple implicitly-created staging repositories
-// instead of being collected into a single one. A shared build service with maxParallelUsages = 1
-// ensures at most one PublishToMavenRepository upload runs at a time across the whole build, so the
-// rest of the build (compilation, jars, signing) can still run in parallel and --no-parallel is not
-// required.
-abstract class MavenPublishLock : BuildService<BuildServiceParameters.None>
-
-val mavenPublishLock = gradle.sharedServices.registerIfAbsent(
-    "mavenPublishLock", MavenPublishLock::class
-) {
-    maxParallelUsages = 1
-}
-
-tasks.withType<PublishToMavenRepository>().configureEach {
-    usesService(mavenPublishLock)
-}
-
-// --- GPG signing ---
-signing {
-    isRequired = !version.toString().endsWith("-SNAPSHOT")
-
-    val useGpgCmd = providers.gradleProperty("useGpgCmd").orNull?.toBoolean() ?: false
-    if (useGpgCmd) {
-        useGpgCmd()
-    }
-
-    sign(publishing.publications)
-}
-
-// Disable signing tasks when no signing configuration is present (local dev without signing).
-// With -PuseGpgCmd=true, an explicit key isn't needed: the gpg command uses its default key
-// unless -Psigning.gnupg.keyName=<key id> selects one.
-tasks.withType<Sign>().configureEach {
-    enabled = providers.gradleProperty("signing.keyId").isPresent ||
-        providers.gradleProperty("signing.gnupg.keyName").isPresent ||
-        (providers.gradleProperty("useGpgCmd").orNull?.toBoolean() ?: false)
-}
-
 // NOTE: the enforced-platform validation error is intentionally NOT suppressed. The internal
 // version-alignment platform (:pulsar-dependencies) is declared on the non-published
 // `internalPlatform` bucket in pulsar.java-conventions (only the resolvable build classpaths
diff --git a/build-logic/conventions/src/main/kotlin/pulsar.publish-repositories-conventions.gradle.kts b/build-logic/conventions/src/main/kotlin/pulsar.publish-repositories-conventions.gradle.kts
new file mode 100644
index 00000000000..4fe98e0e1ad
--- /dev/null
+++ b/build-logic/conventions/src/main/kotlin/pulsar.publish-repositories-conventions.gradle.kts
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Convention plugin for the Maven publishing TARGETS shared by every published Pulsar module:
+// the publish repositories (local + ASF Nexus), the snapshot/release validation, the upload
+// serialization lock, and GPG signing. It deliberately does NOT create any publication or touch the
+// `base`/`java` plugins, so it can be applied both to the root project (which publishes only the
+// POM-only `org.apache.pulsar:pulsar` parent artifact) and, via pulsar.publish-conventions, to every
+// java-library / java-platform module. Applying it everywhere keeps the parent POM and all modules
+// pointing at the same repositories and sharing the same upload lock, so a single `publish...` run
+// uploads the whole set into one Nexus staging repository.
+
+import org.gradle.api.services.BuildService
+import org.gradle.api.services.BuildServiceParameters
+
+plugins {
+    `maven-publish`
+    signing
+}
+
+// Local Maven repository for testing/comparison.
+publishing {
+    repositories {
+        maven {
+            name = "localDeploy"
+            url = uri(rootProject.layout.buildDirectory.dir("local-deploy-repo"))
+        }
+    }
+}
+
+// --- Apache distribution repositories (ASF Nexus) ---
+// Repository names follow the ASF parent POM (apache.releases.https / apache.snapshots.https).
+// Publish with one of:
+//   ./gradlew publishAllPublicationsToApacheSnapshotsRepository   (for -SNAPSHOT versions)
+//   ./gradlew publishAllPublicationsToApacheReleasesRepository    (for release versions)
+// Uploads to the Apache staging repository are serialized by the mavenPublishLock shared build
+// service (defined below) so they all land in a single Nexus staging repository: Nexus creates an
+// implicit staging repository on first upload, and concurrent per-module uploads could otherwise be
+// split across multiple implicitly-created staging repositories. Because the lock handles this,
+// --no-parallel is not required.
+// Credentials are resolved by Gradle at execution time from the apacheReleasesUsername /
+// apacheReleasesPassword and apacheSnapshotsUsername / apacheSnapshotsPassword Gradle properties
+// (the credentials(PasswordCredentials::class) form, which keeps the publish tasks
+// configuration-cache compatible — explicitly assigned credentials would not be). Pass them as
+// ORG_GRADLE_PROJECT_-prefixed environment variables on the publish command line so the password
+// doesn't have to be stored in ~/.gradle/gradle.properties where it could leak to unrelated
+// builds; start the command line with a space to keep the password out of shell history:
+//    ORG_GRADLE_PROJECT_apacheReleasesUsername=$APACHE_USER \
+//    ORG_GRADLE_PROJECT_apacheReleasesPassword="<your ASF password>" \
+//    ./gradlew publishAllPublicationsToApacheReleasesRepository ...
+// The URLs can be overridden with the apacheReleasesRepoUrl / apacheSnapshotsRepoUrl Gradle
+// properties (e.g. a file:// URL for testing the publication layout).
+run {
+    fun MavenArtifactRepository.configureApacheRepository(urlProperty: String, defaultUrl: String) {
+        val repositoryUrl = uri(providers.gradleProperty(urlProperty).getOrElse(defaultUrl))
+        url = repositoryUrl
+        // The file transport (an URL overridden to file:// for testing) rejects credentials,
+        // and Gradle's credentials validation would fail when the properties aren't set.
+        if (repositoryUrl.scheme != "file") {
+            credentials(PasswordCredentials::class)
+        }
+    }
+
+    publishing {
+        repositories {
+            maven {
+                name = "apacheReleases"
+                configureApacheRepository(
+                    "apacheReleasesRepoUrl",
+                    "https://repository.apache.org/service/local/staging/deploy/maven2"
+                )
+            }
+            maven {
+                name = "apacheSnapshots"
+                configureApacheRepository(
+                    "apacheSnapshotsRepoUrl",
+                    "https://repository.apache.org/content/repositories/snapshots"
+                )
+            }
+        }
+    }
+
+    // Validate before any upload: only -SNAPSHOT versions may go to apacheSnapshots and only
+    // release versions to apacheReleases. (Maven's deploy picks the repository from the version;
+    // in Gradle the task name picks the repository, so the version must be checked instead.)
+    // Match on the task name (publish<Pub>PublicationTo<Repo>Repository) rather than reading
+    // task.repository, which can still be null while the task is being created (e.g. for the root
+    // project's POM-only publication). The captured booleans/strings keep this CC-compatible.
+    val projectVersion = version.toString()
+    val isSnapshotVersion = projectVersion.endsWith("-SNAPSHOT")
+    tasks.withType<PublishToMavenRepository>().configureEach {
+        val targetsApacheSnapshots = name.endsWith("ToApacheSnapshotsRepository")
+        val targetsApacheReleases = name.endsWith("ToApacheReleasesRepository")
+        if (targetsApacheSnapshots || targetsApacheReleases) {
+            doFirst {
+                if (targetsApacheSnapshots && !isSnapshotVersion) {
+                    throw GradleException(
+                        "Refusing to publish non-snapshot version '$projectVersion' to the " +
+                            "'apacheSnapshots' repository. Use " +
+                            "publishAllPublicationsToApacheReleasesRepository for release versions."
+                    )
+                }
+                if (targetsApacheReleases && isSnapshotVersion) {
+                    throw GradleException(
+                        "Refusing to publish snapshot version '$projectVersion' to the " +
+                            "'apacheReleases' repository. Use " +
+                            "publishAllPublicationsToApacheSnapshotsRepository for -SNAPSHOT versions."
+                    )
+                }
+            }
+        }
+    }
+}
+
+// --- Serialize uploads to Maven repositories ---
+// Nexus creates an implicit staging repository on the first upload of a release, and concurrent
+// per-module uploads can end up split across multiple implicitly-created staging repositories
+// instead of being collected into a single one. A shared build service with maxParallelUsages = 1
+// ensures at most one PublishToMavenRepository upload runs at a time across the whole build, so the
+// rest of the build (compilation, jars, signing) can still run in parallel and --no-parallel is not
+// required.
+abstract class MavenPublishLock : BuildService<BuildServiceParameters.None>
+
+val mavenPublishLock = gradle.sharedServices.registerIfAbsent(
+    "mavenPublishLock", MavenPublishLock::class
+) {
+    maxParallelUsages = 1
+}
+
+tasks.withType<PublishToMavenRepository>().configureEach {
+    usesService(mavenPublishLock)
+}
+
+// --- GPG signing ---
+signing {
+    isRequired = !version.toString().endsWith("-SNAPSHOT")
+
+    val useGpgCmd = providers.gradleProperty("useGpgCmd").orNull?.toBoolean() ?: false
+    if (useGpgCmd) {
+        useGpgCmd()
+    }
+
+    sign(publishing.publications)
+}
+
+// Disable signing tasks when no signing configuration is present (local dev without signing).
+// With -PuseGpgCmd=true, an explicit key isn't needed: the gpg command uses its default key
+// unless -Psigning.gnupg.keyName=<key id> selects one.
+tasks.withType<Sign>().configureEach {
+    enabled = providers.gradleProperty("signing.keyId").isPresent ||
+        providers.gradleProperty("signing.gnupg.keyName").isPresent ||
+        (providers.gradleProperty("useGpgCmd").orNull?.toBoolean() ?: false)
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 624e5259b11..9f4c7770ce7 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -27,8 +27,9 @@ plugins {
     alias(libs.plugins.crlf) apply false
     alias(libs.plugins.idea.ext)
     alias(libs.plugins.spotless) apply false // workaround for https://github.com/diffplug/spotless/issues/2877
-    `maven-publish`
-    signing
+    // Publish repositories (local + ASF Nexus), signing, upload lock and snapshot/release validation
+    // for the org.apache.pulsar:pulsar parent POM published below — shared with every module.
+    id("pulsar.publish-repositories-conventions")
 }
 
 versionCatalogUpdate {
@@ -48,8 +49,6 @@ tasks.named<com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask>("
     }
 }
 
-val pulsarVersion = version.toString()
-
 // ── Apache RAT (Release Audit Tool) ─────────────────────────────────────────
 tasks.named<org.nosphere.apache.rat.RatTask>("rat").configure {
     // Honour .gitignore exclusions so RAT skips untracked/generated files.
@@ -170,25 +169,4 @@ publishing {
             }
         }
     }
-
-    repositories {
-        maven {
-            name = "localDeploy"
-            url = uri(layout.buildDirectory.dir("local-deploy-repo"))
-        }
-    }
 }
-
-signing {
-    isRequired = !pulsarVersion.endsWith("-SNAPSHOT")
-    val useGpgCmd = providers.gradleProperty("useGpgCmd").orNull?.toBoolean() ?: false
-    if (useGpgCmd) {
-        useGpgCmd()
-    }
-    sign(publishing.publications)
-}
-
-tasks.withType<Sign>().configureEach {
-    enabled = providers.gradleProperty("signing.keyId").isPresent ||
-        providers.gradleProperty("signing.gnupg.keyName").isPresent
-}
\ No newline at end of file
diff --git a/distribution/shell/src/assemble/LICENSE.bin.txt b/distribution/shell/src/assemble/LICENSE.bin.txt
index 115851264bf..bb8322148a1 100644
--- a/distribution/shell/src/assemble/LICENSE.bin.txt
+++ b/distribution/shell/src/assemble/LICENSE.bin.txt
@@ -432,7 +432,6 @@ The Apache Software License, Version 2.0
 
 BSD 3-clause "New" or "Revised" License
  * JLine3 -- jline-3.21.0.jar -- ../licenses/LICENSE-JLine.txt
- * JSR305 -- jsr305-3.0.2.jar -- ../licenses/LICENSE-JSR305.txt
 
 MIT License
  * SLF4J -- ../licenses/LICENSE-SLF4J.txt
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e0c801ed654..2d689c92282 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -175,7 +175,7 @@ thrift = "0.23.0"
 datasketches-memory = "4.1.0"
 datasketches-java = "7.0.1"
 # Shading
-shadow = "9.4.1"
+shadow = "9.4.2"
 
 [libraries]
 # SLF4J
diff --git a/microbench/build.gradle.kts b/microbench/build.gradle.kts
index 9a71b6397b6..9cea5eb6bb8 100644
--- a/microbench/build.gradle.kts
+++ b/microbench/build.gradle.kts
@@ -19,7 +19,9 @@
 
 plugins {
     id("pulsar.java-conventions")
-    alias(libs.plugins.shadow)
+    // No version: the Shadow plugin is already on the classpath via the build-logic conventions,
+    // so a versioned request cannot be reconciled with it.
+    id("com.gradleup.shadow")
 }
 
 dependencies {
diff --git a/pulsar-client-admin-shaded/build.gradle.kts b/pulsar-client-admin-shaded/build.gradle.kts
index 8fe0120d3a2..ce67ac210f5 100644
--- a/pulsar-client-admin-shaded/build.gradle.kts
+++ b/pulsar-client-admin-shaded/build.gradle.kts
@@ -22,22 +22,35 @@ plugins {
     id("pulsar.client-shade-conventions")
 }
 
+// Published under the historical Maven artifactId "pulsar-client-admin" (the shaded admin client).
+// archivesName drives the shaded jar base name, the published artifactId, and this project's module
+// identity (so the BOM and NAR exclusions reference "pulsar-client-admin").
+base {
+    archivesName.set("pulsar-client-admin")
+}
+
 dependencies {
     // Bundled into the shaded jar (kept on the runtime classpath so shadowJar packs them):
     implementation(project(":pulsar-client-admin-original"))
     implementation(project(":pulsar-client-messagecrypto-bc"))
 
-    // Non-bundled runtime dependencies for the dependency-reduced published POM/GMM (the `shadow`
-    // component). Logging is slf4j + slog only — never log4j.
-    "shadow"(project(":pulsar-client-api"))
-    "shadow"(project(":pulsar-client-admin-api"))
-    "shadow"(libs.protobuf.java)
+    // Non-bundled dependencies for the dependency-reduced published POM/GMM. Logging is slf4j + slog
+    // only — never log4j. `shadowApi` -> compile scope, `shadow` -> runtime scope (see
+    // pulsar.client-shade-conventions). compile = exposed in the public API a consumer compiles
+    // against (the *-api modules, OpenTelemetry via ClientBuilder/Stats); runtime = used only
+    // internally by the bundled code (BouncyCastle, the unshaded Jackson annotations, the OTel
+    // incubator, slog/slf4j logging, jspecify CLASS-retention annotations).
+    //
+    // protobuf-java is intentionally NOT published (it was `provided` in the pre-Gradle Maven build):
+    // consumers using Schema.PROTOBUF / PROTOBUF_NATIVE must add protobuf-java themselves.
+    "shadowApi"(project(":pulsar-client-api"))
+    "shadowApi"(project(":pulsar-client-admin-api"))
+    "shadowApi"(libs.opentelemetry.api)
     "shadow"(libs.jackson.annotations)
     "shadow"(libs.bcprov.jdk18on)
     "shadow"(libs.bcpkix.jdk18on)
-    "shadow"(libs.opentelemetry.api)
     "shadow"(libs.opentelemetry.api.incubator)
-    "shadow"(libs.jspecify)
-    "shadow"(libs.slf4j.api)
     "shadow"(libs.slog)
+    "shadow"(libs.slf4j.api)
+    "shadow"(libs.jspecify)
 }
diff --git a/pulsar-client-all/build.gradle.kts b/pulsar-client-all/build.gradle.kts
index 7c49a7d5465..c7a6d934624 100644
--- a/pulsar-client-all/build.gradle.kts
+++ b/pulsar-client-all/build.gradle.kts
@@ -28,20 +28,26 @@ dependencies {
     implementation(project(":pulsar-client-admin-original"))
     implementation(project(":pulsar-client-messagecrypto-bc"))
 
-    // Non-bundled runtime dependencies for the dependency-reduced published POM/GMM (the `shadow`
-    // component) — union of the client and admin shaded sets. Logging is slf4j + slog only — never
-    // log4j (the log4j entries below are test-only and never reach the publication).
-    "shadow"(project(":pulsar-client-api"))
-    "shadow"(project(":pulsar-client-admin-api"))
-    "shadow"(libs.protobuf.java)
+    // Non-bundled dependencies for the dependency-reduced published POM/GMM — union of the client
+    // and admin shaded sets. Logging is slf4j + slog only — never log4j (the log4j entries below are
+    // test-only and never reach the publication). `shadowApi` -> compile scope, `shadow` -> runtime
+    // scope (see pulsar.client-shade-conventions). compile = exposed in the public API a consumer
+    // compiles against (the *-api modules, OpenTelemetry via ClientBuilder/Stats); runtime = used
+    // only internally by the bundled code (BouncyCastle, the unshaded Jackson annotations, the OTel
+    // incubator, slog/slf4j logging, jspecify CLASS-retention annotations).
+    //
+    // protobuf-java is intentionally NOT published (it was `provided` in the pre-Gradle Maven build):
+    // consumers using Schema.PROTOBUF / PROTOBUF_NATIVE must add protobuf-java themselves.
+    "shadowApi"(project(":pulsar-client-api"))
+    "shadowApi"(project(":pulsar-client-admin-api"))
+    "shadowApi"(libs.opentelemetry.api)
     "shadow"(libs.jackson.annotations)
     "shadow"(libs.bcprov.jdk18on)
     "shadow"(libs.bcpkix.jdk18on)
-    "shadow"(libs.opentelemetry.api)
     "shadow"(libs.opentelemetry.api.incubator)
-    "shadow"(libs.jspecify)
-    "shadow"(libs.slf4j.api)
     "shadow"(libs.slog)
+    "shadow"(libs.slf4j.api)
+    "shadow"(libs.jspecify)
 
     testImplementation(libs.log4j.api)
     testImplementation(libs.log4j.core)
diff --git a/pulsar-client-shaded/build.gradle.kts b/pulsar-client-shaded/build.gradle.kts
index 654e50f7273..5967e502258 100644
--- a/pulsar-client-shaded/build.gradle.kts
+++ b/pulsar-client-shaded/build.gradle.kts
@@ -22,25 +22,45 @@ plugins {
     id("pulsar.client-shade-conventions")
 }
 
+// Published under the historical Maven artifactId "pulsar-client" (the shaded client). The Gradle
+// project is ":pulsar-client-shaded" because the "pulsar-client" directory holds pulsar-client-original.
+// archivesName drives the shaded jar base name, the published artifactId, and this project's module
+// identity (so the BOM and NAR exclusions reference "pulsar-client").
+base {
+    archivesName.set("pulsar-client")
+}
+
 dependencies {
     // Bundled into the shaded jar (kept on the runtime classpath so shadowJar packs them):
     implementation(project(":pulsar-client-original"))
     implementation(project(":pulsar-client-messagecrypto-bc"))
 
-    // Non-bundled runtime dependencies. These are the ONLY entries in the dependency-reduced
-    // published POM/Gradle Module Metadata (the `shadow` component). The bundled modules above and
-    // their relocated third-party deps live inside the jar and must NOT appear as POM dependencies.
-    // pulsar-client-api, protobuf, jackson-annotations, the BouncyCastle jars (signed, not
-    // relocatable), OpenTelemetry and jspecify are deliberately not bundled. Logging is slf4j + slog
-    // only — never log4j.
-    "shadow"(project(":pulsar-client-api"))
-    "shadow"(libs.protobuf.java)
+    // Non-bundled dependencies. These are the ONLY entries in the dependency-reduced published
+    // POM/Gradle Module Metadata. The bundled modules above and their relocated third-party deps
+    // live inside the jar and must NOT appear as POM dependencies. pulsar-client-api,
+    // jackson-annotations, the BouncyCastle jars (signed, not relocatable), OpenTelemetry and
+    // jspecify are deliberately not bundled. Logging is slf4j + slog only — never log4j.
+    //
+    // `shadowApi` -> compile scope, `shadow` -> runtime scope (see pulsar.client-shade-conventions).
+    // The split follows what the SHADED artifact's public API exposes vs. what is only used
+    // internally by the bundled code:
+    //  - compile: types reachable from the public API a consumer compiles against —
+    //    pulsar-client-api and io.opentelemetry:opentelemetry-api (ClientBuilder.openTelemetry / *Stats).
+    //  - runtime: only used internally by the bundled implementation (BouncyCastle crypto, relocated
+    //    Jackson's unshaded annotations, the OpenTelemetry incubator, slog/slf4j logging, jspecify
+    //    CLASS-retention annotations) — never on a consumer's compile classpath.
+    //
+    // protobuf-java is intentionally NOT published (neither bundled nor declared as a dependency):
+    // the generated protobuf classes reference unshaded com.google.protobuf at runtime, but Pulsar
+    // does not force a protobuf version on consumers. Anyone using Schema.PROTOBUF / PROTOBUF_NATIVE
+    // must add protobuf-java themselves — matching the `provided` scope of the pre-Gradle Maven build.
+    "shadowApi"(project(":pulsar-client-api"))
+    "shadowApi"(libs.opentelemetry.api)
     "shadow"(libs.jackson.annotations)
     "shadow"(libs.bcprov.jdk18on)
     "shadow"(libs.bcpkix.jdk18on)
-    "shadow"(libs.opentelemetry.api)
     "shadow"(libs.opentelemetry.api.incubator)
-    "shadow"(libs.jspecify)
-    "shadow"(libs.slf4j.api)
     "shadow"(libs.slog)
+    "shadow"(libs.slf4j.api)
+    "shadow"(libs.jspecify)
 }
diff --git a/pulsar-client/build.gradle.kts b/pulsar-client/build.gradle.kts
index c6c623a9b82..50a06a5a8dc 100644
--- a/pulsar-client/build.gradle.kts
+++ b/pulsar-client/build.gradle.kts
@@ -58,7 +58,6 @@ dependencies {
         exclude(group = "com.google.protobuf", module = "protobuf-java")
     }
     implementation(libs.jackson.module.jsonSchema)
-    implementation(libs.jsr305)
     api(libs.jspecify)
     implementation(libs.roaringbitmap)
 
@@ -66,6 +65,11 @@ dependencies {
     compileOnly(libs.protobuf.java)
     compileOnly(libs.joda.time)
     compileOnly(libs.spotbugs.annotations)
+    // JSR-305 (javax.annotation.*) is a compile-time/static-analysis-only annotation library with no
+    // runtime use. Keep it off the runtime classpath so it is not bundled into the shaded client jar
+    // (Pulsar 5.0 drops javax.* from the runtime; JSpecify covers nullness). Consumers that want to run
+    // SpotBugs/FindBugs analysis can add jsr305 themselves.
+    compileOnly(libs.jsr305)
 
     testImplementation(libs.jsonassert)
     testImplementation(libs.awaitility)
diff --git a/pulsar-common/build.gradle.kts b/pulsar-common/build.gradle.kts
index e3b9595ab1f..ad7b37ca3f6 100644
--- a/pulsar-common/build.gradle.kts
+++ b/pulsar-common/build.gradle.kts
@@ -181,6 +181,7 @@ dependencies {
     implementation(variantOf(libs.netty.tcnative.boringssl.static) { classifier("linux-aarch_64") })
     implementation(variantOf(libs.netty.tcnative.boringssl.static) { classifier("osx-x86_64") })
     implementation(variantOf(libs.netty.tcnative.boringssl.static) { classifier("osx-aarch_64") })
+    implementation(variantOf(libs.netty.tcnative.boringssl.static) { classifier("windows-x86_64") })
     implementation(libs.netty.transport.classes.io.uring)
     implementation(variantOf(libs.netty.transport.native.io.uring) { classifier("linux-x86_64") })
     implementation(variantOf(libs.netty.transport.native.io.uring) { classifier("linux-aarch_64") })
diff --git a/pulsar-functions/runtime-all/build.gradle.kts b/pulsar-functions/runtime-all/build.gradle.kts
index 767f9f5eff3..5e56512904e 100644
--- a/pulsar-functions/runtime-all/build.gradle.kts
+++ b/pulsar-functions/runtime-all/build.gradle.kts
@@ -19,7 +19,9 @@
 
 plugins {
     id("pulsar.java-conventions")
-    alias(libs.plugins.shadow)
+    // No version: the Shadow plugin is already on the classpath via the build-logic conventions,
+    // so a versioned request cannot be reconciled with it.
+    id("com.gradleup.shadow")
 }
 
 dependencies {