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

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-mosaic.git


The following commit(s) were added to refs/heads/main by this push:
     new 251757a  release: move Java Nexus staging to local RM (#86)
251757a is described below

commit 251757ad0016fbe8aa271f6d0255f16675c108a7
Author: jianguotian <[email protected]>
AuthorDate: Wed Sep 9 10:31:49 2026 +0800

    release: move Java Nexus staging to local RM (#86)
---
 .github/workflows/release-java.yml                 | 122 +++-
 .github/workflows/release-vote-gate.yml            |  16 +
 .github/workflows/release.yml                      |   1 -
 docs/creating-a-release.html                       |  33 +-
 docs/verifying-a-release-candidate.html            |   2 +-
 .../paimon/mosaic/MosaicNativeLoaderSmokeTest.java |  43 ++
 tools/deploy_java_staging.sh                       | 616 +++++++++++++++++++++
 tools/tests/deploy_java_staging_test.sh            | 259 +++++++++
 tools/tests/test_release_vote_workflow.py          | 142 ++++-
 9 files changed, 1178 insertions(+), 56 deletions(-)

diff --git a/.github/workflows/release-java.yml 
b/.github/workflows/release-java.yml
index 5f78999..b3d2a7f 100644
--- a/.github/workflows/release-java.yml
+++ b/.github/workflows/release-java.yml
@@ -123,7 +123,7 @@ jobs:
           name: native-${{ matrix.os_name }}-${{ matrix.arch }}
           path: target/${{ matrix.target }}/release/${{ matrix.lib_name }}
 
-  deploy-staging:
+  package-java:
     if: >-
       github.event_name != 'workflow_dispatch' &&
       github.repository == 'apache/paimon-mosaic' &&
@@ -166,31 +166,109 @@ jobs:
         with:
           java-version: ${{ env.JDK_VERSION }}
           distribution: 'temurin'
-          server-id: apache.releases.https
-          server-username: MAVEN_USERNAME
-          server-password: MAVEN_PASSWORD
-          gpg-private-key: ${{ secrets.GPG_SECRET_KEY }}
-          gpg-passphrase: MAVEN_GPG_PASSPHRASE
+          cache: maven
 
       - name: Show Maven version
         run: mvn --version
 
-      - name: Deploy to Apache Nexus staging
+      - name: Package Java artifacts
         working-directory: java
+        run: mvn clean verify -Prelease -Dgpg.skip=true -DskipTests
+
+      - name: Verify multi-platform Java package
+        shell: bash
         run: |
-          REF="${TAG_NAME}"
-          VERSION="${REF#v}"
-          if [[ "$VERSION" == *-rc* ]]; then
-            DESC="Apache Paimon Mosaic, version ${VERSION%-rc*}, release 
candidate ${VERSION#*-rc}"
-          else
-            DESC="Apache Paimon Mosaic, version ${VERSION}"
+          set -euo pipefail
+          version="$(sed -n 's#.*<version>\([^<]*\)</version>.*#\1#p' 
java/pom.xml | sed -n '2p')"
+          jar_file="java/target/mosaic-${version}.jar"
+          sources_jar="java/target/mosaic-${version}-sources.jar"
+          javadoc_jar="java/target/mosaic-${version}-javadoc.jar"
+
+          for artifact in "$jar_file" "$sources_jar" "$javadoc_jar"; do
+            test -s "$artifact"
+          done
+
+          if jar tf "$sources_jar" | grep -Eq '^native/'; then
+            echo "Sources JAR contains native resources" >&2
+            exit 1
           fi
-          mvn clean deploy \
-            -Prelease \
-            -DskipTests \
-            -DstagingDescription="$DESC"
-        env:
-          TAG_NAME: ${{ github.ref_name }}
-          MAVEN_USERNAME: ${{ secrets.NEXUS_STAGE_DEPLOYER_USER }}
-          MAVEN_PASSWORD: ${{ secrets.NEXUS_STAGE_DEPLOYER_PW }}
-          MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
+
+          for entry in \
+            org/apache/paimon/mosaic/NativeLib.class \
+            native/linux/x86_64/libpaimon_mosaic_jni.so \
+            native/linux/aarch64/libpaimon_mosaic_jni.so \
+            native/macos/aarch64/libpaimon_mosaic_jni.dylib \
+            native/windows/x86_64/paimon_mosaic_jni.dll \
+            META-INF/LICENSE \
+            META-INF/NOTICE \
+            META-INF/DEPENDENCIES
+          do
+            jar tf "$jar_file" | grep -qx "$entry"
+          done
+
+          java -cp "$jar_file:java/target/test-classes" \
+            org.apache.paimon.mosaic.MosaicNativeLoaderSmokeTest
+
+      - name: Upload Java package
+        uses: actions/upload-artifact@v5
+        with:
+          name: java-package
+          path: java/target/*.jar
+          if-no-files-found: error
+
+  smoke-java-package:
+    if: >-
+      github.event_name != 'workflow_dispatch' &&
+      github.repository == 'apache/paimon-mosaic' &&
+      startsWith(github.ref, 'refs/tags/') &&
+      contains(github.ref_name, '-rc')
+    needs: [release-preflight, package-java]
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - os: ubuntu-latest
+            classpath_separator: ':'
+            java_version: 8
+          - os: ubuntu-24.04-arm
+            classpath_separator: ':'
+            java_version: 8
+          # Temurin does not provide JDK 8 for GitHub's macOS arm64 runners.
+          - os: macos-latest
+            classpath_separator: ':'
+            java_version: 17
+          - os: windows-latest
+            classpath_separator: ';'
+            java_version: 8
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Set up JDK ${{ matrix.java_version }}
+        uses: actions/setup-java@v4
+        with:
+          java-version: ${{ matrix.java_version }}
+          distribution: 'temurin'
+
+      - name: Download final Java package
+        uses: actions/download-artifact@v5
+        with:
+          name: java-package
+          path: java-package
+
+      - name: Load the bundled JNI library from the final JAR
+        shell: bash
+        run: |
+          set -euo pipefail
+          jar_file="$(
+            find java-package -type f -name '*.jar' \
+              ! -name '*-sources.jar' \
+              ! -name '*-javadoc.jar' \
+              -print -quit
+          )"
+          test -n "$jar_file"
+          mkdir -p smoke-classes
+          javac -cp "$jar_file" -d smoke-classes \
+            
java/src/test/java/org/apache/paimon/mosaic/MosaicNativeLoaderSmokeTest.java
+          java -cp "$jar_file${{ matrix.classpath_separator }}smoke-classes" \
+            org.apache.paimon.mosaic.MosaicNativeLoaderSmokeTest
diff --git a/.github/workflows/release-vote-gate.yml 
b/.github/workflows/release-vote-gate.yml
index 871ad06..87c0dad 100644
--- a/.github/workflows/release-vote-gate.yml
+++ b/.github/workflows/release-vote-gate.yml
@@ -24,11 +24,16 @@ on:
       - ".gitattributes"
       - ".github/workflows/**"
       - "docs/creating-a-release.html"
+      - "docs/verifying-a-release-candidate.html"
+      - "java/pom.xml"
+      - 
"java/src/test/java/org/apache/paimon/mosaic/MosaicNativeLoaderSmokeTest.java"
       - "tools/create_source_release.sh"
+      - "tools/deploy_java_staging.sh"
       - "tools/update_branch_version.sh"
       - "tools/verify_release_versions.py"
       - "tools/verify_source_archive.py"
       - "tools/tests/test_create_source_release.py"
+      - "tools/tests/deploy_java_staging_test.sh"
       - "tools/tests/test_release_vote_workflow.py"
       - "tools/tests/test_update_branch_version.py"
       - "tools/tests/test_verify_release_versions.py"
@@ -39,11 +44,16 @@ on:
       - ".gitattributes"
       - ".github/workflows/**"
       - "docs/creating-a-release.html"
+      - "docs/verifying-a-release-candidate.html"
+      - "java/pom.xml"
+      - 
"java/src/test/java/org/apache/paimon/mosaic/MosaicNativeLoaderSmokeTest.java"
       - "tools/create_source_release.sh"
+      - "tools/deploy_java_staging.sh"
       - "tools/update_branch_version.sh"
       - "tools/verify_release_versions.py"
       - "tools/verify_source_archive.py"
       - "tools/tests/test_create_source_release.py"
+      - "tools/tests/deploy_java_staging_test.sh"
       - "tools/tests/test_release_vote_workflow.py"
       - "tools/tests/test_update_branch_version.py"
       - "tools/tests/test_verify_release_versions.py"
@@ -78,6 +88,10 @@ jobs:
             tools/tests/test_verify_release_versions.py \
             tools/tests/test_verify_source_archive.py
 
+      - name: Test local Java staging
+        shell: bash
+        run: bash tools/tests/deploy_java_staging_test.sh
+
       - name: Verify current source tree
         shell: bash
         run: |
@@ -110,7 +124,9 @@ jobs:
             tools/tests/test_verify_release_versions.py \
             tools/tests/test_verify_source_archive.py
           bash -n tools/create_source_release.sh
+          bash -n tools/deploy_java_staging.sh
           bash -n tools/update_branch_version.sh
+          bash -n tools/tests/deploy_java_staging_test.sh
           if [[ -n "${GITHUB_BASE_REF:-}" ]]; then
             comparison_ref="origin/${GITHUB_BASE_REF}"
           else
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f2e3352..96fb4d0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -50,7 +50,6 @@ jobs:
     name: Java release
     needs: [release-preflight]
     uses: ./.github/workflows/release-java.yml
-    secrets: inherit
 
   python-wheels:
     name: Python wheels
diff --git a/docs/creating-a-release.html b/docs/creating-a-release.html
index 18fbe15..eb68767 100644
--- a/docs/creating-a-release.html
+++ b/docs/creating-a-release.html
@@ -69,19 +69,19 @@
                 <li><a href="#promote-the-release">Promote the release</a></li>
             </ol>
 
-            <h3>Automated Publishing</h3>
-            <p>When a version tag is pushed, the <code>Release</code> workflow 
orchestrates language-specific release jobs:</p>
+            <h3>Automated and Local Publishing</h3>
+            <p>When a version tag is pushed, the <code>Release</code> workflow 
orchestrates language-specific release jobs. Java signing and Nexus deployment 
remain on the Release Manager's machine:</p>
             <table>
                 <thead>
                     <tr><th>Component</th><th>Tag Pattern</th><th>Published 
To</th><th>Pre-release (<code>-rc</code>) Behavior</th><th>Ordering</th></tr>
                 </thead>
                 <tbody>
                     <tr><td>Rust 
crate</td><td><code>v0.1.0</code></td><td>crates.io</td><td>Dry-run 
only</td><td>Runs in parallel</td></tr>
-                    <tr><td>Java 
binding</td><td><code>v0.1.0</code></td><td>Apache Nexus 
staging</td><td>Deploys to staging</td><td>Runs in parallel</td></tr>
+                    <tr><td>Java 
binding</td><td><code>v0.1.0-rc1</code></td><td>CI artifacts, then Apache Nexus 
staging</td><td>CI packages and smoke-tests without signing; the Release 
Manager deploys locally</td><td>Local deploy follows a successful RC 
run</td></tr>
                     <tr><td>Python 
binding</td><td><code>v0.1.0</code></td><td>PyPI</td><td>Publishes to 
TestPyPI</td><td>Publishes only after Rust and Java jobs succeed</td></tr>
                 </tbody>
             </table>
-            <p>The Release Manager's primary responsibility is managing the 
<strong>source release</strong> (tarball + signature) and coordinating the 
community vote. Language artifact publishing is handled by CI once the tag is 
pushed.</p>
+            <p>The Release Manager manages the <strong>source release</strong> 
(tarball + signature), signs and deploys Java artifacts locally, and 
coordinates the community vote. Rust and Python publishing remain handled by 
CI.</p>
 
             <!-- ============================================================ 
-->
             <h2 id="decide-to-release">Decide to Release</h2>
@@ -140,14 +140,20 @@ svn ci -m "Add &lt;YOUR_NAME&gt;'s public 
key"</code></pre>
                 </thead>
                 <tbody>
                     
<tr><td><code>CARGO_REGISTRY_TOKEN</code></td><td>crates.io publishing</td></tr>
-                    
<tr><td><code>NEXUS_STAGE_DEPLOYER_USER</code></td><td>Apache Nexus 
staging</td></tr>
-                    
<tr><td><code>NEXUS_STAGE_DEPLOYER_PW</code></td><td>Apache Nexus 
staging</td></tr>
-                    <tr><td><code>GPG_SECRET_KEY</code></td><td>Java artifact 
signing</td></tr>
-                    <tr><td><code>GPG_PASSPHRASE</code></td><td>Java artifact 
signing</td></tr>
                     <tr><td><code>PYPI_API_TOKEN</code></td><td>PyPI 
publishing</td></tr>
                     <tr><td><code>TEST_PYPI_API_TOKEN</code></td><td>TestPyPI 
publishing</td></tr>
                 </tbody>
             </table>
+            <p>Do not store Java signing material or Nexus credentials in 
GitHub Actions. Keep the signing key in the Release Manager's local GPG keyring 
and configure the Nexus account under server id 
<code>apache.releases.https</code> in local Maven settings:</p>
+<pre><code>&lt;settings&gt;
+  &lt;servers&gt;
+    &lt;server&gt;
+      &lt;id&gt;apache.releases.https&lt;/id&gt;
+      &lt;username&gt;YOUR_APACHE_ID&lt;/username&gt;
+      &lt;password&gt;YOUR_NEXUS_PASSWORD&lt;/password&gt;
+    &lt;/server&gt;
+  &lt;/servers&gt;
+&lt;/settings&gt;</code></pre>
 
             <h3>Clone into a Fresh Workspace</h3>
 <pre><code>git clone https://github.com/apache/paimon-mosaic.git
@@ -220,12 +226,21 @@ git push origin ${RC_TAG}</code></pre>
             <p>The release preflight validates the signed tag and component 
versions, then creates and verifies a temporary source archive before any 
language release job starts.</p>
             <ul>
                 <li><strong>Rust release</strong> &mdash; dry-run check (does 
not publish for RC tags)</li>
-                <li><strong>Java release</strong> &mdash; builds native JNI 
libraries for 4 platforms, deploys JAR to Apache Nexus staging</li>
+                <li><strong>Java release</strong> &mdash; builds native JNI 
libraries for 4 platforms, packages an unsigned <code>java-package</code>, and 
smoke-tests that package on all 4 platforms without receiving Java GPG or Nexus 
credentials</li>
                 <li><strong>Python wheels</strong> &mdash; builds wheels for 4 
platforms</li>
                 <li><strong>Python publish</strong> &mdash; publishes to 
TestPyPI only after the Rust, Java, and Python wheel jobs succeed</li>
             </ul>
             <p>If only the Python publish job fails due to a transient PyPI or 
TestPyPI issue, use <strong>Re-run failed jobs</strong> on the 
<code>Release</code> workflow. The successful Rust, Java, and Python wheel jobs 
do not need to be repeated.</p>
 
+            <h3>Deploy Java Artifacts to Nexus Staging</h3>
+            <p>Check out the exact RC tag and copy the numeric run id from its 
successful push-triggered <code>Release</code> workflow. Run the local 
preflight first, then repeat without <code>--dry-run</code>:</p>
+<pre><code>RELEASE_RUN_ID="12345678901"
+git checkout ${RC_TAG}
+
+./tools/deploy_java_staging.sh --release-version ${RELEASE_VERSION} --rc 
${RC_NUM} --run-id ${RELEASE_RUN_ID} --dry-run
+./tools/deploy_java_staging.sh --release-version ${RELEASE_VERSION} --rc 
${RC_NUM} --run-id ${RELEASE_RUN_ID}</code></pre>
+            <p>The script downloads the four native artifacts and the CI 
<code>java-package</code>, checks and smoke-tests the CI JAR, then locally 
rebuilds and signs the Maven artifacts with the Release Manager's GPG and 
<code>apache.releases.https</code> settings. The CI main JAR is a validation 
reference and is not directly uploaded to Nexus; the local Maven rebuild is 
deployed. Record the resulting <code>orgapachepaimon-XXXX</code> repository id 
and leave it staged for the vote.</p>
+
             <h3>Create Source Release Artifacts</h3>
             <p>Create source release artifacts from the same commit as the RC 
tag:</p>
 <pre><code>git checkout ${RC_TAG}
diff --git a/docs/verifying-a-release-candidate.html 
b/docs/verifying-a-release-candidate.html
index c7c3c72..7bad56a 100644
--- a/docs/verifying-a-release-candidate.html
+++ b/docs/verifying-a-release-candidate.html
@@ -158,7 +158,7 @@ make</code></pre>
 paimon-mosaic-core = { git = "https://github.com/apache/paimon-mosaic";, tag = 
"v${RELEASE_VERSION}-rc${RC_NUM}" }</code></pre>
 
             <h3>Java (Apache Nexus Staging)</h3>
-            <p>The RC tag deploys to Apache Nexus staging. To test:</p>
+            <p>After the RC workflow succeeds, the Release Manager locally 
rebuilds, signs, and stages the Java artifacts in Apache Nexus. The CI 
<code>java-package</code> is a smoke-test reference, not the exact JAR uploaded 
to Nexus. To test:</p>
             <ol>
                 <li>Find the staging repository at <a 
href="https://repository.apache.org/#stagingRepositories";>repository.apache.org</a>.</li>
                 <li>Add the staging repository URL to your 
<code>pom.xml</code>:
diff --git 
a/java/src/test/java/org/apache/paimon/mosaic/MosaicNativeLoaderSmokeTest.java 
b/java/src/test/java/org/apache/paimon/mosaic/MosaicNativeLoaderSmokeTest.java
new file mode 100644
index 0000000..d449486
--- /dev/null
+++ 
b/java/src/test/java/org/apache/paimon/mosaic/MosaicNativeLoaderSmokeTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.mosaic;
+
+/** Standalone smoke test for loading the packaged Mosaic JNI library. */
+public final class MosaicNativeLoaderSmokeTest {
+
+    private MosaicNativeLoaderSmokeTest() {}
+
+    public static void main(String[] args) {
+        try {
+            Class.forName(
+                    "org.apache.paimon.mosaic.NativeLib",
+                    true,
+                    MosaicNativeLoaderSmokeTest.class.getClassLoader());
+        } catch (ClassNotFoundException e) {
+            throw new AssertionError("NativeLib is missing", e);
+        }
+
+        long estimatedSize = NativeLib.nativeWriterEstimatedSize(0L);
+        if (estimatedSize != 0L) {
+            throw new AssertionError(
+                    "Expected nativeWriterEstimatedSize(0L) to return 0, got " 
+ estimatedSize);
+        }
+    }
+}
diff --git a/tools/deploy_java_staging.sh b/tools/deploy_java_staging.sh
new file mode 100755
index 0000000..8b5dee9
--- /dev/null
+++ b/tools/deploy_java_staging.sh
@@ -0,0 +1,616 @@
+#!/usr/bin/env bash
+
+#
+# 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.
+#
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+MVN=${MVN:-mvn}
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+REPO_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
+
+RELEASE_VERSION=
+RC_NUMBER=
+TAG=
+NATIVE_DIR=
+RUN_ID=
+DRY_RUN=false
+SKIP_TESTS=true
+CLEANUP_NATIVE_RESOURCES=true
+MAVEN_SETTINGS=
+STAGING_DESCRIPTION=
+CHECK_NATIVE_FILES=true
+
+usage() {
+  cat <<'EOF'
+Usage:
+  deploy_java_staging.sh --release-version VERSION --rc N --run-id RUN_ID 
[options]
+
+Deploy Apache Paimon Mosaic Java RC artifacts to Apache Nexus staging from a
+committer/RM machine. Pass the GitHub Actions run id that built the RC native
+libraries; the script verifies the run, downloads the native artifacts, and 
then
+runs the local Maven deploy.
+
+Required:
+  --release-version VERSION  Release version in java/pom.xml, for example 
0.3.0.
+  --rc N                     RC number, for example 1 for v0.3.0-rc1.
+  --run-id RUN_ID            GitHub Actions run id containing native-* 
artifacts.
+
+Options:
+  --tag TAG                  RC tag. Defaults to vVERSION-rcN.
+  --repo REPO                GitHub repository. Defaults to 
apache/paimon-mosaic.
+  --dry-run                  Build and verify release artifacts locally only.
+                             Does not sign or deploy to Nexus.
+  --maven-settings FILE      Maven settings.xml containing 
apache.releases.https.
+  --staging-description TXT  Nexus staging description.
+  --no-skip-tests            Run Maven tests.
+  --no-cleanup               Keep java/src/main/resources/native after exit.
+  --skip-native-file-check   Do not check native binary file formats.
+  -h, --help                 Show this help.
+
+Validate with the real RC artifacts before publishing:
+  ./tools/deploy_java_staging.sh --release-version 0.3.0 --rc 1 \
+    --run-id 12345678901 --dry-run
+
+Publish staging after the dry run succeeds:
+  ./tools/deploy_java_staging.sh --release-version 0.3.0 --rc 1 \
+    --run-id 12345678901
+
+Maven/GPG requirements:
+  Real deploy uses the committer's local GPG setup and Maven credentials for
+  server id apache.releases.https. Configure ~/.m2/settings.xml, pass
+  --maven-settings FILE, or set NEXUS_STAGE_DEPLOYER_USER and
+  NEXUS_STAGE_DEPLOYER_PW for a temporary settings.xml.
+
+gh CLI requirement:
+  --run-id uses the GitHub CLI to check the workflow run and fetch native
+  artifacts. Run `gh auth login` first.
+EOF
+}
+
+require_option_value() {
+  local option=$1
+  local value=${2-}
+  if [[ $# -lt 2 || -z "$value" ]]; then
+    echo "$option requires a value" >&2
+    usage >&2
+    exit 1
+  fi
+}
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --release-version)
+      require_option_value "$@"
+      RELEASE_VERSION=$2
+      shift 2
+      ;;
+    --rc)
+      require_option_value "$@"
+      RC_NUMBER=$2
+      shift 2
+      ;;
+    --tag)
+      require_option_value "$@"
+      TAG=$2
+      shift 2
+      ;;
+    --run-id)
+      require_option_value "$@"
+      RUN_ID=$2
+      shift 2
+      ;;
+    --repo)
+      require_option_value "$@"
+      REPO=$2
+      shift 2
+      ;;
+    --dry-run)
+      DRY_RUN=true
+      shift
+      ;;
+    --maven-settings)
+      require_option_value "$@"
+      MAVEN_SETTINGS=$2
+      shift 2
+      ;;
+    --staging-description)
+      require_option_value "$@"
+      STAGING_DESCRIPTION=$2
+      shift 2
+      ;;
+    --no-skip-tests)
+      SKIP_TESTS=false
+      shift
+      ;;
+    --no-cleanup)
+      CLEANUP_NATIVE_RESOURCES=false
+      shift
+      ;;
+    --skip-native-file-check)
+      CHECK_NATIVE_FILES=false
+      shift
+      ;;
+    -h|--help)
+      usage
+      exit 0
+      ;;
+    *)
+      echo "Unknown argument: $1" >&2
+      usage >&2
+      exit 1
+      ;;
+  esac
+done
+
+require_value() {
+  local name=$1
+  local value=$2
+  if [[ -z "$value" ]]; then
+    echo "$name is required" >&2
+    usage >&2
+    exit 1
+  fi
+}
+
+REPO=${REPO:-apache/paimon-mosaic}
+
+require_value "--release-version" "$RELEASE_VERSION"
+
+if [[ ! "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+  echo "--release-version must be a semantic version such as 0.3.0" >&2
+  exit 1
+fi
+
+if [[ -n "$RC_NUMBER" && ! "$RC_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
+  echo "--rc must be a positive integer" >&2
+  exit 1
+fi
+
+if [[ -z "$TAG" ]]; then
+  require_value "--rc" "$RC_NUMBER"
+  TAG="v${RELEASE_VERSION}-rc${RC_NUMBER}"
+fi
+
+if [[ ! "$TAG" =~ ^v${RELEASE_VERSION//./\\.}-rc[1-9][0-9]*$ ]]; then
+  echo "--tag must match v${RELEASE_VERSION}-rcN" >&2
+  exit 1
+fi
+
+if [[ -n "$RC_NUMBER" && "$TAG" != "v${RELEASE_VERSION}-rc${RC_NUMBER}" ]]; 
then
+  echo "--tag does not match --release-version and --rc" >&2
+  exit 1
+fi
+
+if [[ -z "$STAGING_DESCRIPTION" ]]; then
+  if [[ -n "$RC_NUMBER" ]]; then
+    STAGING_DESCRIPTION="Apache Paimon Mosaic, version ${RELEASE_VERSION}, 
release candidate ${RC_NUMBER}"
+  else
+    STAGING_DESCRIPTION="Apache Paimon Mosaic, version ${RELEASE_VERSION}, 
release candidate ${TAG#*-rc}"
+  fi
+fi
+
+if [[ -z "$RUN_ID" ]]; then
+  echo "--run-id is required" >&2
+  usage >&2
+  exit 1
+fi
+
+if [[ ! "$RUN_ID" =~ ^[1-9][0-9]*$ ]]; then
+  echo "--run-id must be a positive integer" >&2
+  exit 1
+fi
+
+NATIVE_DIR="$SCRIPT_DIR/release/java-native-${TAG}"
+
+if [[ -n "$MAVEN_SETTINGS" && ! -f "$MAVEN_SETTINGS" ]]; then
+  echo "--maven-settings does not exist: $MAVEN_SETTINGS" >&2
+  exit 1
+fi
+
+POM_VERSION=$(
+  sed -n 's#.*<version>\([^<]*\)</version>.*#\1#p' "$REPO_DIR/java/pom.xml" |
+    sed -n '2p'
+)
+if [[ "$POM_VERSION" != "$RELEASE_VERSION" ]]; then
+  echo "java/pom.xml version is $POM_VERSION, expected $RELEASE_VERSION" >&2
+  echo "Check out the RC tag after bumping versions, then run this script 
again." >&2
+  exit 1
+fi
+
+if ! git -C "$REPO_DIR" rev-parse -q --verify "$TAG^{commit}" >/dev/null; then
+  echo "Tag $TAG does not exist locally." >&2
+  echo "Run: git fetch --tags && git checkout $TAG" >&2
+  exit 1
+else
+  TAG_COMMIT=$(git -C "$REPO_DIR" rev-parse "$TAG^{commit}")
+  HEAD_COMMIT=$(git -C "$REPO_DIR" rev-parse HEAD)
+  if [[ "$TAG_COMMIT" != "$HEAD_COMMIT" ]]; then
+    echo "Current HEAD is not $TAG." >&2
+    echo "Run: git checkout $TAG" >&2
+    exit 1
+  fi
+fi
+
+check_java_package_inputs_clean() {
+  local paths=(java tools/deploy_java_staging.sh)
+  local untracked
+
+  if ! git -C "$REPO_DIR" diff --quiet -- "${paths[@]}" ||
+     ! git -C "$REPO_DIR" diff --cached --quiet -- "${paths[@]}"; then
+    echo "Java package inputs have local changes. Commit or revert them before 
publishing." >&2
+    git -C "$REPO_DIR" status --short -- "${paths[@]}" >&2
+    exit 1
+  fi
+
+  untracked=$(git -C "$REPO_DIR" ls-files --others --exclude-standard -- 
"${paths[@]}")
+  if [[ -n "$untracked" ]]; then
+    echo "Java package inputs contain untracked files. Remove or commit them 
before publishing." >&2
+    printf '%s\n' "$untracked" >&2
+    exit 1
+  fi
+}
+
+check_java_package_inputs_clean
+
+validate_github_run() {
+  local run_output
+  if ! run_output=$(
+    gh run view "$RUN_ID" \
+      --repo "$REPO" \
+      --json status,conclusion,headSha,headBranch,workflowName,event \
+      --template '{{printf "%s\n%s\n%s\n%s\n%s\n%s\n" .status .conclusion 
.headSha (or .headBranch "") (or .workflowName "") (or .event "")}}'
+  ); then
+    echo "Failed to read GitHub Actions run: $RUN_ID" >&2
+    exit 1
+  fi
+
+  local run_status
+  local run_conclusion
+  local run_head_sha
+  local run_head_branch
+  local run_workflow_name
+  local run_event
+  run_status=$(printf '%s\n' "$run_output" | sed -n '1p')
+  run_conclusion=$(printf '%s\n' "$run_output" | sed -n '2p')
+  run_head_sha=$(printf '%s\n' "$run_output" | sed -n '3p')
+  run_head_branch=$(printf '%s\n' "$run_output" | sed -n '4p')
+  run_workflow_name=$(printf '%s\n' "$run_output" | sed -n '5p')
+  run_event=$(printf '%s\n' "$run_output" | sed -n '6p')
+
+  if [[ "$run_status" != "completed" || "$run_conclusion" != "success" ]]; then
+    echo "GitHub Actions run $RUN_ID is not a successful completed run." >&2
+    echo "status=$run_status conclusion=$run_conclusion" >&2
+    exit 1
+  fi
+
+  if [[ "$run_workflow_name" != "Release" ]]; then
+    echo "GitHub Actions run $RUN_ID is from workflow '$run_workflow_name', 
expected 'Release'." >&2
+    exit 1
+  fi
+
+  if [[ "$run_event" != "push" ]]; then
+    echo "GitHub Actions run $RUN_ID was triggered by '$run_event', expected a 
tag push." >&2
+    exit 1
+  fi
+
+  if [[ "$run_head_sha" != "$TAG_COMMIT" ]]; then
+    echo "GitHub Actions run $RUN_ID does not match $TAG." >&2
+    echo "run headSha: $run_head_sha" >&2
+    echo "tag commit:  $TAG_COMMIT" >&2
+    exit 1
+  fi
+
+  echo "Using GitHub Actions run $RUN_ID for native artifacts:"
+  echo "  workflow: ${run_workflow_name:-unknown}"
+  echo "  event:    ${run_event:-unknown}"
+  echo "  ref:      ${run_head_branch:-unknown}"
+  echo "  headSha:  ${run_head_sha:-unknown}"
+}
+
+if ! command -v gh >/dev/null 2>&1; then
+  echo "gh CLI is required when --run-id is used" >&2
+  exit 1
+fi
+
+validate_github_run
+
+rm -rf "$NATIVE_DIR"
+mkdir -p "$NATIVE_DIR"
+for artifact in \
+  native-linux-x86_64 \
+  native-linux-aarch64 \
+  native-macos-aarch64 \
+  native-windows-x86_64 \
+  java-package
+do
+  gh run download "$RUN_ID" \
+    --repo "$REPO" \
+    --name "$artifact" \
+    --dir "$NATIVE_DIR/$artifact"
+done
+
+if [[ ! -d "$NATIVE_DIR" ]]; then
+  echo "Native artifact download directory does not exist: $NATIVE_DIR" >&2
+  exit 1
+fi
+
+find_native() {
+  local artifact_layout=$1
+  local resource_layout=$2
+  local resource_without_native=${resource_layout#native/}
+
+  for candidate in \
+    "$NATIVE_DIR/$artifact_layout" \
+    "$NATIVE_DIR/$resource_layout" \
+    "$NATIVE_DIR/$resource_without_native"
+  do
+    if [[ -f "$candidate" ]]; then
+      printf '%s\n' "$candidate"
+      return 0
+    fi
+  done
+
+  echo "Missing native artifact. Tried:" >&2
+  echo "  $NATIVE_DIR/$artifact_layout" >&2
+  echo "  $NATIVE_DIR/$resource_layout" >&2
+  echo "  $NATIVE_DIR/$resource_without_native" >&2
+  exit 1
+}
+
+validate_native_file() {
+  local source_file=$1
+  local label=$2
+
+  if [[ "$CHECK_NATIVE_FILES" != "true" ]]; then
+    return
+  fi
+
+  if ! command -v file >/dev/null 2>&1; then
+    echo "WARNING: 'file' command not found; skipping native file format 
checks." >&2
+    return
+  fi
+
+  local info
+  info=$(file "$source_file")
+  case "$label" in
+    linux-x86_64)
+      if ! grep -Eq 'ELF 64-bit.*(x86-64|x86_64)' <<<"$info"; then
+        echo "Unexpected linux x86_64 native file: $info" >&2
+        exit 1
+      fi
+      ;;
+    linux-aarch64)
+      if ! grep -Eq 'ELF 64-bit.*(ARM aarch64|AArch64|aarch64|ARM64)' 
<<<"$info"; then
+        echo "Unexpected linux aarch64 native file: $info" >&2
+        exit 1
+      fi
+      ;;
+    macos-aarch64)
+      if ! grep -Eq 'Mach-O 64-bit.*(arm64|aarch64)' <<<"$info"; then
+        echo "Unexpected macOS aarch64 native file: $info" >&2
+        exit 1
+      fi
+      ;;
+    windows-x86_64)
+      if ! grep -Eq 'PE32\+.*(x86-64|x86_64)' <<<"$info"; then
+        echo "Unexpected windows x86_64 native file: $info" >&2
+        exit 1
+      fi
+      ;;
+    *)
+      echo "Unknown native file label: $label" >&2
+      exit 1
+      ;;
+  esac
+}
+
+copy_native() {
+  local source_file=$1
+  local target_rel=$2
+  local label=$3
+  local target_file="$REPO_DIR/java/src/main/resources/$target_rel"
+
+  validate_native_file "$source_file" "$label"
+  mkdir -p "$(dirname "$target_file")"
+  cp "$source_file" "$target_file"
+}
+
+cleanup_native_resources() {
+  if [[ "$CLEANUP_NATIVE_RESOURCES" == "true" ]]; then
+    rm -rf "$REPO_DIR/java/src/main/resources/native"
+  fi
+}
+
+TEMP_SETTINGS=
+cleanup_temp_settings() {
+  if [[ -n "$TEMP_SETTINGS" ]]; then
+    rm -f "$TEMP_SETTINGS"
+  fi
+}
+
+xml_escape() {
+  printf '%s' "$1" |
+    sed \
+      -e 's/&/\&amp;/g' \
+      -e 's/</\&lt;/g' \
+      -e 's/>/\&gt;/g'
+}
+
+cleanup_all() {
+  cleanup_native_resources
+  cleanup_temp_settings
+}
+trap cleanup_all EXIT
+
+rm -rf "$REPO_DIR/java/src/main/resources/native"
+
+copy_native \
+  "$(find_native native-linux-x86_64/libpaimon_mosaic_jni.so 
native/linux/x86_64/libpaimon_mosaic_jni.so)" \
+  native/linux/x86_64/libpaimon_mosaic_jni.so \
+  linux-x86_64
+copy_native \
+  "$(find_native native-linux-aarch64/libpaimon_mosaic_jni.so 
native/linux/aarch64/libpaimon_mosaic_jni.so)" \
+  native/linux/aarch64/libpaimon_mosaic_jni.so \
+  linux-aarch64
+copy_native \
+  "$(find_native native-macos-aarch64/libpaimon_mosaic_jni.dylib 
native/macos/aarch64/libpaimon_mosaic_jni.dylib)" \
+  native/macos/aarch64/libpaimon_mosaic_jni.dylib \
+  macos-aarch64
+copy_native \
+  "$(find_native native-windows-x86_64/paimon_mosaic_jni.dll 
native/windows/x86_64/paimon_mosaic_jni.dll)" \
+  native/windows/x86_64/paimon_mosaic_jni.dll \
+  windows-x86_64
+
+echo "Native libraries staged for Java package:"
+find "$REPO_DIR/java/src/main/resources/native" -type f | sort
+
+if [[ "$DRY_RUN" != "true" &&
+      -z "$MAVEN_SETTINGS" &&
+      ( -n "${NEXUS_STAGE_DEPLOYER_USER:-}" || -n 
"${NEXUS_STAGE_DEPLOYER_PW:-}" ) ]]; then
+  if [[ -z "${NEXUS_STAGE_DEPLOYER_USER:-}" || -z 
"${NEXUS_STAGE_DEPLOYER_PW:-}" ]]; then
+    echo "Both NEXUS_STAGE_DEPLOYER_USER and NEXUS_STAGE_DEPLOYER_PW are 
required" >&2
+    exit 1
+  fi
+
+  TEMP_SETTINGS=$(mktemp)
+  NEXUS_STAGE_DEPLOYER_USER_XML=$(xml_escape "$NEXUS_STAGE_DEPLOYER_USER")
+  NEXUS_STAGE_DEPLOYER_PW_XML=$(xml_escape "$NEXUS_STAGE_DEPLOYER_PW")
+  cat > "$TEMP_SETTINGS" <<EOF
+<settings>
+  <servers>
+    <server>
+      <id>apache.releases.https</id>
+      <username>${NEXUS_STAGE_DEPLOYER_USER_XML}</username>
+      <password>${NEXUS_STAGE_DEPLOYER_PW_XML}</password>
+    </server>
+  </servers>
+</settings>
+EOF
+  MAVEN_SETTINGS="$TEMP_SETTINGS"
+fi
+
+MVN_BASE_CMD=("$MVN")
+if [[ -n "$MAVEN_SETTINGS" ]]; then
+  MVN_BASE_CMD+=("-s" "$MAVEN_SETTINGS")
+fi
+
+VERIFY_CMD=("${MVN_BASE_CMD[@]}" clean verify -Prelease -Dgpg.skip=true)
+if [[ "$SKIP_TESTS" == "true" ]]; then
+  VERIFY_CMD+=(-DskipTests)
+fi
+
+validate_maven_artifacts() {
+  local jar_file="$REPO_DIR/java/target/mosaic-${RELEASE_VERSION}.jar"
+  local 
sources_jar="$REPO_DIR/java/target/mosaic-${RELEASE_VERSION}-sources.jar"
+  local 
javadoc_jar="$REPO_DIR/java/target/mosaic-${RELEASE_VERSION}-javadoc.jar"
+  local ci_jar="$NATIVE_DIR/java-package/mosaic-${RELEASE_VERSION}.jar"
+  local 
ci_sources_jar="$NATIVE_DIR/java-package/mosaic-${RELEASE_VERSION}-sources.jar"
+  local 
ci_javadoc_jar="$NATIVE_DIR/java-package/mosaic-${RELEASE_VERSION}-javadoc.jar"
+  local artifact
+  local main_jar
+  local entry
+
+  for artifact in \
+    "$jar_file" \
+    "$sources_jar" \
+    "$javadoc_jar" \
+    "$ci_jar" \
+    "$ci_sources_jar" \
+    "$ci_javadoc_jar"
+  do
+    if [[ ! -s "$artifact" ]]; then
+      echo "Expected non-empty Maven artifact is missing: $artifact" >&2
+      exit 1
+    fi
+  done
+
+  for artifact in "$sources_jar" "$ci_sources_jar"; do
+    if grep -Eq '^native/' <<<"$(jar tf "$artifact")"; then
+      echo "Sources jar contains binary-only resources: $artifact" >&2
+      exit 1
+    fi
+  done
+
+  for main_jar in "$jar_file" "$ci_jar"
+  do
+    for entry in \
+      org/apache/paimon/mosaic/NativeLib.class \
+      native/linux/x86_64/libpaimon_mosaic_jni.so \
+      native/linux/aarch64/libpaimon_mosaic_jni.so \
+      native/macos/aarch64/libpaimon_mosaic_jni.dylib \
+      native/windows/x86_64/paimon_mosaic_jni.dll \
+      META-INF/LICENSE \
+      META-INF/NOTICE \
+      META-INF/DEPENDENCIES
+    do
+      if ! jar tf "$main_jar" | grep -qx "$entry"; then
+        echo "Packaged jar is missing required entry: $main_jar: $entry" >&2
+        exit 1
+      fi
+    done
+  done
+
+  local test_classes="$REPO_DIR/java/target/test-classes"
+  if [[ ! -f 
"$test_classes/org/apache/paimon/mosaic/MosaicNativeLoaderSmokeTest.class" ]]; 
then
+    echo "Packaged JAR loader smoke test class is missing from test output." 
>&2
+    exit 1
+  fi
+  java -cp "$jar_file:$test_classes" \
+    org.apache.paimon.mosaic.MosaicNativeLoaderSmokeTest
+  java -cp "$ci_jar:$test_classes" \
+    org.apache.paimon.mosaic.MosaicNativeLoaderSmokeTest
+}
+
+if [[ "$DRY_RUN" == "true" ]]; then
+  echo "Dry-running Java staging build. No artifacts will be deployed to 
Nexus."
+else
+  echo "Running Java staging preflight before deploying to Apache Nexus."
+  echo "Staging description: $STAGING_DESCRIPTION"
+  echo "The local Maven rebuild will be signed and deployed; the CI JAR is a 
validation reference."
+fi
+
+(
+  cd "$REPO_DIR/java"
+  "${VERIFY_CMD[@]}"
+)
+
+validate_maven_artifacts
+
+echo ""
+if [[ "$DRY_RUN" == "true" ]]; then
+  echo "Java staging dry run finished successfully."
+else
+  DEPLOY_CMD=("${MVN_BASE_CMD[@]}" deploy -Prelease 
"-DstagingDescription=$STAGING_DESCRIPTION")
+  if [[ "$SKIP_TESTS" == "true" ]]; then
+    DEPLOY_CMD+=(-DskipTests)
+  fi
+
+  echo "Preflight passed. Deploying Java artifacts to Apache Nexus staging."
+  (
+    cd "$REPO_DIR/java"
+    "${DEPLOY_CMD[@]}"
+  )
+  validate_maven_artifacts
+
+  echo ""
+  echo "Java staging deploy finished."
+  echo "Check the Maven output for the orgapachepaimon-XXXX staging repository 
id."
+fi
diff --git a/tools/tests/deploy_java_staging_test.sh 
b/tools/tests/deploy_java_staging_test.sh
new file mode 100755
index 0000000..ee6922b
--- /dev/null
+++ b/tools/tests/deploy_java_staging_test.sh
@@ -0,0 +1,259 @@
+#!/usr/bin/env bash
+
+#
+# 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.
+#
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+SOURCE_REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
+TEST_ROOT=$(mktemp -d)
+trap 'rm -rf "$TEST_ROOT"' EXIT
+TESTS=0
+FIXTURES=0
+
+fail() {
+  echo "FAIL: $*" >&2
+  exit 1
+}
+
+assert_contains() {
+  local file=$1
+  local text=$2
+  grep -F -- "$text" "$file" >/dev/null || fail "$file does not contain: $text"
+}
+
+assert_not_contains() {
+  local file=$1
+  local text=$2
+  if grep -F -- "$text" "$file" >/dev/null; then
+    fail "$file unexpectedly contains: $text"
+  fi
+}
+
+new_fixture() {
+  FIXTURE="$TEST_ROOT/fixture-$FIXTURES"
+  FIXTURES=$((FIXTURES + 1))
+  MOCK_BIN="$FIXTURE/mock-bin"
+  MOCK_LOG="$FIXTURE/mock.log"
+  mkdir -p "$FIXTURE/tools" 
"$FIXTURE/java/src/test/java/org/apache/paimon/mosaic" "$MOCK_BIN"
+  cp "$SOURCE_REPO/tools/deploy_java_staging.sh" "$FIXTURE/tools/"
+  cp 
"$SOURCE_REPO/java/src/test/java/org/apache/paimon/mosaic/MosaicNativeLoaderSmokeTest.java"
 \
+    "$FIXTURE/java/src/test/java/org/apache/paimon/mosaic/"
+  cat > "$FIXTURE/java/pom.xml" <<'POM'
+<project>
+  <parent><version>23</version></parent>
+  <artifactId>mosaic</artifactId>
+  <version>1.2.3</version>
+</project>
+POM
+
+  cat > "$MOCK_BIN/gh" <<'MOCK'
+#!/usr/bin/env bash
+set -euo pipefail
+printf 'gh %s\n' "$*" >> "$MOCK_LOG"
+if [[ "$1 $2" == "run view" ]]; then
+  printf 'completed\nsuccess\n%s\n%s\nRelease\npush\n' "$MOCK_RUN_SHA" 
"$MOCK_TAG"
+  exit 0
+fi
+if [[ "$1 $2" != "run download" ]]; then
+  exit 2
+fi
+name=
+dir=
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --name) name=$2; shift 2 ;;
+    --dir) dir=$2; shift 2 ;;
+    *) shift ;;
+  esac
+done
+mkdir -p "$dir"
+case "$name" in
+  native-linux-x86_64) file=libpaimon_mosaic_jni.so ;;
+  native-linux-aarch64) file=libpaimon_mosaic_jni.so ;;
+  native-macos-aarch64) file=libpaimon_mosaic_jni.dylib ;;
+  native-windows-x86_64) file=paimon_mosaic_jni.dll ;;
+  java-package)
+    printf jar > "$dir/mosaic-1.2.3.jar"
+    printf sources > "$dir/mosaic-1.2.3-sources.jar"
+    if [[ "${OMIT_CI_JAVADOC:-0}" != 1 ]]; then
+      printf javadoc > "$dir/mosaic-1.2.3-javadoc.jar"
+    fi
+    exit 0
+    ;;
+  *) exit 3 ;;
+esac
+printf native > "$dir/$file"
+MOCK
+
+  cat > "$MOCK_BIN/mvn" <<'MOCK'
+#!/usr/bin/env bash
+set -euo pipefail
+printf 'mvn %s\n' "$*" >> "$MOCK_LOG"
+mkdir -p target/test-classes/org/apache/paimon/mosaic
+printf class > 
target/test-classes/org/apache/paimon/mosaic/MosaicNativeLoaderSmokeTest.class
+if [[ "${OMIT_LOCAL_MAIN:-0}" != 1 ]]; then
+  printf jar > target/mosaic-1.2.3.jar
+fi
+printf sources > target/mosaic-1.2.3-sources.jar
+printf javadoc > target/mosaic-1.2.3-javadoc.jar
+MOCK
+
+  cat > "$MOCK_BIN/jar" <<'MOCK'
+#!/usr/bin/env bash
+set -euo pipefail
+file=${2:?}
+if [[ "$file" == *-sources.jar ]]; then
+  printf '%s\n' org/apache/paimon/mosaic/NativeLib.java
+  exit 0
+fi
+cat <<'ENTRIES'
+org/apache/paimon/mosaic/NativeLib.class
+native/linux/x86_64/libpaimon_mosaic_jni.so
+native/linux/aarch64/libpaimon_mosaic_jni.so
+native/macos/aarch64/libpaimon_mosaic_jni.dylib
+native/windows/x86_64/paimon_mosaic_jni.dll
+META-INF/LICENSE
+META-INF/NOTICE
+META-INF/DEPENDENCIES
+ENTRIES
+if [[ "${OMIT_NATIVE_ENTRY:-0}" == 1 ]]; then
+  exit 0
+fi
+MOCK
+  # Rewrite the mock when an entry must be omitted; doing it here keeps the
+  # normal listing easy to audit.
+  cat > "$MOCK_BIN/java" <<'MOCK'
+#!/usr/bin/env bash
+set -euo pipefail
+printf 'java %s\n' "$*" >> "$MOCK_LOG"
+MOCK
+  cat > "$MOCK_BIN/file" <<'MOCK'
+#!/usr/bin/env bash
+set -euo pipefail
+case "$1" in
+  *linux-x86_64*) echo "$1: ELF 64-bit LSB shared object, x86-64" ;;
+  *linux-aarch64*) echo "$1: ELF 64-bit LSB shared object, ARM aarch64" ;;
+  *macos-aarch64*) echo "$1: Mach-O 64-bit dynamically linked shared library 
arm64" ;;
+  *windows-x86_64*) echo "$1: PE32+ executable (DLL) (console) x86-64" ;;
+  *) exit 2 ;;
+esac
+MOCK
+  chmod +x "$MOCK_BIN"/* "$FIXTURE/tools/deploy_java_staging.sh"
+
+  git -C "$FIXTURE" init -q
+  git -C "$FIXTURE" config user.name "Java Staging Test"
+  git -C "$FIXTURE" config user.email "[email protected]"
+  git -C "$FIXTURE" add .
+  git -C "$FIXTURE" commit -q -m initial
+  git -C "$FIXTURE" tag v1.2.3-rc1
+  HEAD_SHA=$(git -C "$FIXTURE" rev-parse HEAD)
+  : > "$MOCK_LOG"
+}
+
+omit_native_from_mock_jar() {
+  python3 - "$MOCK_BIN/jar" <<'PY'
+from pathlib import Path
+import sys
+path = Path(sys.argv[1])
+text = path.read_text()
+text = text.replace("native/windows/x86_64/paimon_mosaic_jni.dll\n", "")
+path.write_text(text)
+PY
+}
+
+run_stage() {
+  env \
+    PATH="$MOCK_BIN:$PATH" \
+    MOCK_LOG="$MOCK_LOG" \
+    MOCK_RUN_SHA="${MOCK_RUN_SHA:-$HEAD_SHA}" \
+    MOCK_TAG=v1.2.3-rc1 \
+    OMIT_CI_JAVADOC="${OMIT_CI_JAVADOC:-0}" \
+    OMIT_LOCAL_MAIN="${OMIT_LOCAL_MAIN:-0}" \
+    "$FIXTURE/tools/deploy_java_staging.sh" \
+      --release-version 1.2.3 \
+      --rc 1 \
+      --run-id 12345 \
+      --skip-native-file-check \
+      "$@"
+}
+
+pass() {
+  TESTS=$((TESTS + 1))
+  echo "ok $TESTS - $1"
+}
+
+new_fixture
+run_stage --dry-run > "$FIXTURE/output" 2>&1
+for artifact in native-linux-x86_64 native-linux-aarch64 native-macos-aarch64 
native-windows-x86_64 java-package; do
+  assert_contains "$MOCK_LOG" "--name $artifact"
+done
+assert_contains "$MOCK_LOG" "mvn clean verify -Prelease -Dgpg.skip=true 
-DskipTests"
+assert_not_contains "$MOCK_LOG" "mvn deploy"
+[[ $(grep -c '^java ' "$MOCK_LOG") -eq 2 ]] || fail "dry-run must smoke local 
and CI JARs"
+pass "successful dry-run validates all five artifacts and both JARs"
+
+new_fixture
+settings="$FIXTURE/settings.xml"
+printf '<settings/>\n' > "$settings"
+run_stage --maven-settings "$settings" --staging-description "Mosaic RC 
staging" > "$FIXTURE/output" 2>&1
+verify_line=$(grep -n '^mvn .*clean verify ' "$MOCK_LOG" | cut -d: -f1)
+deploy_line=$(grep -n '^mvn .*deploy ' "$MOCK_LOG" | cut -d: -f1)
+[[ -n "$verify_line" && -n "$deploy_line" && "$verify_line" -lt "$deploy_line" 
]] || fail "verify must run before deploy"
+assert_contains "$MOCK_LOG" "-s $settings clean verify"
+assert_contains "$MOCK_LOG" "-s $settings deploy -Prelease 
-DstagingDescription=Mosaic RC staging"
+pass "successful real run verifies before deploy and forwards Maven options"
+
+new_fixture
+MOCK_RUN_SHA=0000000000000000000000000000000000000000
+if run_stage --dry-run > "$FIXTURE/output" 2>&1; then
+  fail "wrong run SHA should fail"
+fi
+assert_not_contains "$MOCK_LOG" "mvn "
+unset MOCK_RUN_SHA
+new_fixture
+printf '\n<!-- dirty -->\n' >> "$FIXTURE/java/pom.xml"
+if run_stage --dry-run > "$FIXTURE/output" 2>&1; then
+  fail "dirty Java input should fail"
+fi
+assert_not_contains "$MOCK_LOG" "mvn "
+new_fixture
+printf '\n# dirty\n' >> "$FIXTURE/tools/deploy_java_staging.sh"
+if run_stage --dry-run > "$FIXTURE/output" 2>&1; then
+  fail "dirty deploy script should fail"
+fi
+assert_not_contains "$MOCK_LOG" "mvn "
+pass "wrong run SHA and dirty Java/script inputs fail before Maven"
+
+new_fixture
+OMIT_CI_JAVADOC=1
+if run_stage > "$FIXTURE/output" 2>&1; then
+  fail "missing CI Javadoc JAR should fail"
+fi
+assert_not_contains "$MOCK_LOG" "mvn deploy"
+unset OMIT_CI_JAVADOC
+new_fixture
+omit_native_from_mock_jar
+if run_stage > "$FIXTURE/output" 2>&1; then
+  fail "missing native JAR entry should fail"
+fi
+assert_not_contains "$MOCK_LOG" "mvn deploy"
+pass "missing required JAR or native entry blocks deploy"
+
+echo "PASS: $TESTS focused Java staging tests"
diff --git a/tools/tests/test_release_vote_workflow.py 
b/tools/tests/test_release_vote_workflow.py
index e796101..ab83434 100644
--- a/tools/tests/test_release_vote_workflow.py
+++ b/tools/tests/test_release_vote_workflow.py
@@ -34,7 +34,6 @@ PYTHON_PUBLISH_WORKFLOW = ROOT / 
".github/workflows/release-python-publish.yml"
 RELEASE_DOCUMENTATION = ROOT / "docs/creating-a-release.html"
 CREDENTIALED_RELEASE_WORKFLOWS = (
     (RUST_RELEASE_WORKFLOW, "publish"),
-    (JAVA_RELEASE_WORKFLOW, "deploy-staging"),
     (PYTHON_PUBLISH_WORKFLOW, "publish"),
 )
 TAG_CONDITION = "startsWith(github.ref, 'refs/tags/')"
@@ -44,7 +43,7 @@ RUST_PUBLISH_CONDITION = (
     "startsWith(github.ref, 'refs/tags/') && "
     "!contains(github.ref_name, '-')"
 )
-JAVA_DEPLOY_CONDITION = (
+JAVA_PACKAGE_CONDITION = (
     "github.event_name != 'workflow_dispatch' && "
     "github.repository == 'apache/paimon-mosaic' && "
     "startsWith(github.ref, 'refs/tags/') && "
@@ -62,11 +61,16 @@ REQUIRED_GATE_PATHS = {
     ".gitattributes",
     ".github/workflows/**",
     "docs/creating-a-release.html",
+    "docs/verifying-a-release-candidate.html",
+    "java/pom.xml",
+    
"java/src/test/java/org/apache/paimon/mosaic/MosaicNativeLoaderSmokeTest.java",
     "tools/create_source_release.sh",
+    "tools/deploy_java_staging.sh",
     "tools/update_branch_version.sh",
     "tools/verify_release_versions.py",
     "tools/verify_source_archive.py",
     "tools/tests/test_create_source_release.py",
+    "tools/tests/deploy_java_staging_test.sh",
     "tools/tests/test_release_vote_workflow.py",
     "tools/tests/test_update_branch_version.py",
     "tools/tests/test_verify_release_versions.py",
@@ -139,7 +143,9 @@ python -m compileall -q \\
   tools/tests/test_verify_release_versions.py \\
   tools/tests/test_verify_source_archive.py
 bash -n tools/create_source_release.sh
+bash -n tools/deploy_java_staging.sh
 bash -n tools/update_branch_version.sh
+bash -n tools/tests/deploy_java_staging_test.sh
 if [[ -n "${GITHUB_BASE_REF:-}" ]]; then
   comparison_ref="origin/${GITHUB_BASE_REF}"
 else
@@ -183,6 +189,13 @@ def gate_step(workflow: dict, name: str) -> dict:
     return matches[0]
 
 
+def job_step(workflow: dict, job_name: str, name: str) -> dict:
+    steps = workflow["jobs"][job_name]["steps"]
+    matches = [step for step in steps if step.get("name") == name]
+    assert len(matches) == 1
+    return matches[0]
+
+
 def assert_gate_contract(workflow: dict) -> None:
     triggers = workflow["on"]
     assert "workflow_dispatch" in triggers
@@ -197,14 +210,23 @@ def assert_gate_contract(workflow: dict) -> None:
 
     install_step = gate_step(workflow, "Install test dependencies")
     test_step = gate_step(workflow, "Run release vote tests")
+    staging_step = gate_step(workflow, "Test local Java staging")
     source_tree_step = gate_step(workflow, "Verify current source tree")
     static_step = gate_step(workflow, "Run static checks")
-    for step in (install_step, test_step, source_tree_step, static_step):
+    for step in (
+        install_step,
+        test_step,
+        staging_step,
+        source_tree_step,
+        static_step,
+    ):
         assert "if" not in step
         assert "continue-on-error" not in step
 
     assert install_step["run"] == "python -m pip install pytest PyYAML"
     assert test_step["run"] == GATE_TEST_COMMAND
+    assert staging_step["shell"] == "bash"
+    assert staging_step["run"] == "bash 
tools/tests/deploy_java_staging_test.sh"
     assert source_tree_step["shell"] == "bash"
     assert source_tree_step["run"] == GATE_SOURCE_TREE_COMMAND
     assert static_step["shell"] == "bash"
@@ -220,8 +242,9 @@ def assert_release_contract(workflow: dict) -> None:
     assert "runs-on" not in preflight
     assert "continue-on-error" not in preflight
 
-    for job_name in ("rust", "java", "python-wheels", "python-publish"):
+    for job_name in ("rust", "python-wheels", "python-publish"):
         assert jobs[job_name].get("secrets") == "inherit"
+    assert "secrets" not in jobs["java"]
 
     for job_name in ("rust", "java", "python-wheels"):
         release_job = jobs[job_name]
@@ -346,11 +369,11 @@ def test_manual_rust_dispatch_cannot_publish() -> None:
     assert publish_steps[0]["if"] == RUST_PUBLISH_CONDITION
 
 
-def test_manual_java_dispatch_cannot_deploy_staging() -> None:
+def test_manual_java_dispatch_cannot_package_java() -> None:
     workflow = load_workflow(JAVA_RELEASE_WORKFLOW)
-    deploy_job = workflow["jobs"]["deploy-staging"]
+    package_job = workflow["jobs"]["package-java"]
 
-    assert deploy_job["if"] == JAVA_DEPLOY_CONDITION
+    assert package_job["if"] == JAVA_PACKAGE_CONDITION
 
 
 def test_manual_release_dispatch_cannot_publish_python() -> None:
@@ -382,19 +405,76 @@ def 
test_credentialed_release_jobs_require_reusable_preflight(
     assert_leaf_release_contract(workflow, credentialed_job)
 
 
-def test_java_release_tag_context_is_not_interpolated_into_shell() -> None:
+def test_java_release_packages_and_smokes_unsigned_artifact() -> None:
     workflow = load_workflow(JAVA_RELEASE_WORKFLOW)
-    deploy_steps = [
-        step
-        for step in workflow["jobs"]["deploy-staging"]["steps"]
-        if step.get("name") == "Deploy to Apache Nexus staging"
-    ]
+    assert workflow["jobs"]["release-preflight"]["uses"] == (
+        "./.github/workflows/release-preflight.yml"
+    )
+    assert needs(workflow["jobs"]["build-native"]) == {"release-preflight"}
+
+    package_job = workflow["jobs"]["package-java"]
+    assert package_job["if"] == JAVA_PACKAGE_CONDITION
+    assert needs(package_job) == {"release-preflight", "build-native"}
+    package_step = job_step(workflow, "package-java", "Package Java artifacts")
+    assert package_step["working-directory"] == "java"
+    assert package_step["run"] == (
+        "mvn clean verify -Prelease -Dgpg.skip=true -DskipTests"
+    )
+
+    verify_step = job_step(
+        workflow,
+        "package-java",
+        "Verify multi-platform Java package",
+    )
+    assert "META-INF/DEPENDENCIES" in verify_step["run"]
+    assert "org/apache/paimon/mosaic/NativeLib.class" in verify_step["run"]
+    assert "org.apache.paimon.mosaic.MosaicNativeLoaderSmokeTest" in (
+        verify_step["run"]
+    )
+
+    upload_step = job_step(workflow, "package-java", "Upload Java package")
+    assert upload_step["uses"] == "actions/upload-artifact@v5"
+    assert upload_step["with"] == {
+        "name": "java-package",
+        "path": "java/target/*.jar",
+        "if-no-files-found": "error",
+    }
+
+    smoke_job = workflow["jobs"]["smoke-java-package"]
+    assert smoke_job["if"] == JAVA_PACKAGE_CONDITION
+    assert needs(smoke_job) == {"release-preflight", "package-java"}
+    matrix = smoke_job["strategy"]["matrix"]["include"]
+    assert {
+        (entry["os"], entry["java_version"])
+        for entry in matrix
+    } == {
+        ("ubuntu-latest", "8"),
+        ("ubuntu-24.04-arm", "8"),
+        ("macos-latest", "17"),
+        ("windows-latest", "8"),
+    }
+    smoke_step = job_step(
+        workflow,
+        "smoke-java-package",
+        "Load the bundled JNI library from the final JAR",
+    )
+    assert "MosaicNativeLoaderSmokeTest.java" in smoke_step["run"]
+    assert "javac -cp \"$jar_file\"" in smoke_step["run"]
+
+
+def test_java_release_never_receives_signing_or_nexus_credentials() -> None:
+    release = load_workflow(RELEASE_WORKFLOW)
+    assert "secrets" not in release["jobs"]["java"]
 
-    assert len(deploy_steps) == 1
-    deploy_step = deploy_steps[0]
-    assert deploy_step["env"]["TAG_NAME"] == "${{ github.ref_name }}"
-    assert 'REF="${TAG_NAME}"' in deploy_step["run"]
-    assert "${{ github.ref_name }}" not in deploy_step["run"]
+    source = JAVA_RELEASE_WORKFLOW.read_text(encoding="utf-8")
+    for forbidden in (
+        "secrets.",
+        "GPG_SECRET_KEY",
+        "GPG_PASSPHRASE",
+        "NEXUS_STAGE_DEPLOYER_USER",
+        "NEXUS_STAGE_DEPLOYER_PW",
+    ):
+        assert forbidden not in source
 
 
 @pytest.mark.parametrize("job_name", ("rust", "java", "python-wheels"))
@@ -444,7 +524,10 @@ def 
test_contract_rejects_wrong_reusable_workflow(job_name: str) -> None:
         assert_release_contract(workflow)
 
 
[email protected]("job_name", RELEASE_WORKFLOW_BY_JOB)
[email protected](
+    "job_name",
+    ("rust", "python-wheels", "python-publish"),
+)
 def test_contract_rejects_missing_secret_inheritance(job_name: str) -> None:
     workflow = copy.deepcopy(load_workflow(RELEASE_WORKFLOW))
     workflow["jobs"][job_name].pop("secrets")
@@ -453,6 +536,14 @@ def 
test_contract_rejects_missing_secret_inheritance(job_name: str) -> None:
         assert_release_contract(workflow)
 
 
+def test_contract_rejects_java_secret_inheritance() -> None:
+    workflow = copy.deepcopy(load_workflow(RELEASE_WORKFLOW))
+    workflow["jobs"]["java"]["secrets"] = "inherit"
+
+    with pytest.raises(AssertionError):
+        assert_release_contract(workflow)
+
+
 @pytest.mark.parametrize(
     ("workflow_path", "credentialed_job"),
     CREDENTIALED_RELEASE_WORKFLOWS,
@@ -605,10 +696,15 @@ def 
test_source_release_documentation_passes_rc_tag_explicitly() -> None:
     assert invocation in source
 
     assert "Cargo path dependency constraints and Cargo.lock" in source
-    assert (
-        "<tr><td><code>GPG_SECRET_KEY</code></td>"
-        "<td>Java artifact signing</td></tr>"
-    ) in source
+    assert "Do not store Java signing material or Nexus credentials" in source
+
+
+def test_release_documentation_describes_local_java_staging() -> None:
+    source = RELEASE_DOCUMENTATION.read_text(encoding="utf-8")
+    assert "--run-id ${RELEASE_RUN_ID} --dry-run" in source
+    assert "--run-id ${RELEASE_RUN_ID}</code></pre>" in source
+    assert "locally rebuilds and signs the Maven artifacts" in source
+    assert "not directly uploaded to Nexus" in source
 
 
 def test_release_documentation_describes_source_archive_preflight() -> None:

Reply via email to