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 <YOUR_NAME>'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><settings>
+ <servers>
+ <server>
+ <id>apache.releases.https</id>
+ <username>YOUR_APACHE_ID</username>
+ <password>YOUR_NEXUS_PASSWORD</password>
+ </server>
+ </servers>
+</settings></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> — dry-run check (does
not publish for RC tags)</li>
- <li><strong>Java release</strong> — builds native JNI
libraries for 4 platforms, deploys JAR to Apache Nexus staging</li>
+ <li><strong>Java release</strong> — 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> — builds wheels for 4
platforms</li>
<li><strong>Python publish</strong> — 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/&/\&/g' \
+ -e 's/</\</g' \
+ -e 's/>/\>/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: