diff --git a/.github/workflows/release-java.yml b/.github/workflows/release-java.yml
new file mode 100644
index 000000000000..ade3ea7e3bbd
--- /dev/null
+++ b/.github/workflows/release-java.yml
@@ -0,0 +1,227 @@
+################################################################################
+# 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.
+################################################################################
+
+name: Release Java
+
+on:
+ workflow_call:
+ workflow_dispatch:
+
+concurrency:
+ group: release-java-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+jobs:
+ package:
+ name: Package ${{ matrix.lane }}
+ if: >-
+ github.repository == 'apache/paimon' &&
+ startsWith(github.ref, 'refs/tags/') &&
+ contains(github.ref_name, '-rc')
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - lane: jdk8
+ java_version: 8
+ - lane: jdk11
+ java_version: 11
+ - lane: jdk17
+ java_version: 17
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up JDK ${{ matrix.java_version }}
+ uses: actions/setup-java@v5
+ with:
+ java-version: ${{ matrix.java_version }}
+ distribution: temurin
+ cache: maven
+
+ - name: Set up Maven
+ uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5
+ with:
+ maven-version: 3.8.8
+
+ - name: Validate release tag and version
+ id: release
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ release_version="$(
+ mvn -q -DforceStdout help:evaluate \
+ -Dexpression=project.version
+ )"
+ rc_number="${GITHUB_REF_NAME##*-rc}"
+ expected_tag="release-${release_version}-rc${rc_number}"
+
+ if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then
+ echo "Tag ${GITHUB_REF_NAME} does not match Maven version ${release_version}" >&2
+ exit 1
+ fi
+ if [[ "${release_version}" == *-SNAPSHOT ]]; then
+ echo "Release version must not contain -SNAPSHOT" >&2
+ exit 1
+ fi
+
+ echo "version=${release_version}" >> "${GITHUB_OUTPUT}"
+ echo "rc_number=${rc_number}" >> "${GITHUB_OUTPUT}"
+
+ java -version
+ mvn -version
+
+ - name: Reject SNAPSHOT project versions and dependencies
+ shell: bash
+ env:
+ LANE: ${{ matrix.lane }}
+ run: |
+ set -euo pipefail
+
+ enforcer_goal="org.apache.maven.plugins:maven-enforcer-plugin:3.6.3:enforce"
+ enforcer_rules="requireReleaseVersion,requireReleaseDeps"
+ case "${LANE}" in
+ jdk8)
+ mvn -ntp -B "${enforcer_goal}" \
+ -Denforcer.rules="${enforcer_rules}" \
+ -Papache-release,docs-and-source,spark3,flink1
+ ;;
+ jdk11)
+ mvn -ntp -B "${enforcer_goal}" \
+ -Denforcer.rules="${enforcer_rules}" \
+ -Papache-release,docs-and-source,flink2 \
+ -pl org.apache.paimon:paimon-flink-2.0,org.apache.paimon:paimon-flink-2.1,org.apache.paimon:paimon-flink-2.2,org.apache.paimon:paimon-flink2-common,org.apache.paimon:paimon-iceberg \
+ -am
+ ;;
+ jdk17)
+ mvn -ntp -B "${enforcer_goal}" \
+ -Denforcer.rules="${enforcer_rules}" \
+ -Papache-release,docs-and-source,spark4 \
+ -pl org.apache.paimon:paimon-spark-common_2.13,org.apache.paimon:paimon-spark4-common_2.13,org.apache.paimon:paimon-spark-4.0_2.13,org.apache.paimon:paimon-spark-4.1_2.13 \
+ -am
+ ;;
+ *)
+ echo "Unknown Java release lane: ${LANE}" >&2
+ exit 1
+ ;;
+ esac
+
+ - name: Package Java release artifacts
+ shell: bash
+ env:
+ LANE: ${{ matrix.lane }}
+ MAVEN_OPTS: -Xmx4096m
+ run: |
+ set -euo pipefail
+
+ mkdir -p release-java
+ log="release-java/${LANE}.log"
+ install_log="release-java/${LANE}-install.log"
+ case "${LANE}" in
+ jdk8)
+ mvn clean install -ntp -B \
+ -Papache-release,docs-and-source,spark3,flink1 \
+ -DskipTests -Dgpg.skip=true -Dstyle.color=never \
+ 2>&1 | tee "${log}" "${install_log}"
+ ;;
+ jdk11)
+ mvn clean install -ntp -B \
+ -Pdocs-and-source,flink2 \
+ -DskipTests -Dstyle.color=never \
+ -pl org.apache.paimon:paimon-flink-2.0,org.apache.paimon:paimon-flink-2.1,org.apache.paimon:paimon-flink-2.2,org.apache.paimon:paimon-iceberg \
+ -am \
+ 2>&1 | tee "${log}"
+ mvn install -ntp -B \
+ -Papache-release,docs-and-source,flink2 \
+ -DskipTests -Dgpg.skip=true -Dstyle.color=never \
+ -pl org.apache.paimon:paimon-flink-2.0,org.apache.paimon:paimon-flink-2.1,org.apache.paimon:paimon-flink-2.2,org.apache.paimon:paimon-flink2-common,org.apache.paimon:paimon-iceberg \
+ 2>&1 | tee -a "${log}" "${install_log}"
+ ;;
+ jdk17)
+ mvn clean install -ntp -B \
+ -Pdocs-and-source,spark4 \
+ -DskipTests -Dstyle.color=never \
+ -pl paimon-spark/paimon-spark-4.0,paimon-spark/paimon-spark-4.1 \
+ -am \
+ 2>&1 | tee "${log}"
+ mvn install -ntp -B \
+ -Papache-release,docs-and-source,spark4 \
+ -DskipTests -Dgpg.skip=true -Dstyle.color=never \
+ -pl org.apache.paimon:paimon-spark-common_2.13,org.apache.paimon:paimon-spark4-common_2.13,org.apache.paimon:paimon-spark-4.0_2.13,org.apache.paimon:paimon-spark-4.1_2.13 \
+ 2>&1 | tee -a "${log}" "${install_log}"
+ ;;
+ *)
+ echo "Unknown Java release lane: ${LANE}" >&2
+ exit 1
+ ;;
+ esac
+
+ files="release-java/${LANE}-files.txt"
+ while IFS= read -r artifact; do
+ if [[ "${artifact}" == "${GITHUB_WORKSPACE}/"* ]]; then
+ artifact="./${artifact#"${GITHUB_WORKSPACE}/"}"
+ elif [[ "${artifact}" != ./* ]]; then
+ echo "Unexpected installed artifact path: ${artifact}" >&2
+ exit 1
+ fi
+ if [[ ! -f "${artifact}" ]]; then
+ echo "Installed artifact does not exist: ${artifact}" >&2
+ exit 1
+ fi
+ printf '%s\n' "${artifact}"
+ done < <(
+ sed -n \
+ 's/^\[INFO\] Installing \(.*\.jar\) to .*$/\1/p' \
+ "${install_log}"
+ ) | LC_ALL=C sort -u > "${files}"
+ if [[ ! -s "${files}" ]]; then
+ echo "No installed Java release artifacts were found for ${LANE}" >&2
+ exit 1
+ fi
+
+ while IFS= read -r artifact; do
+ sha512sum "${artifact}"
+ done < "${files}" \
+ > "release-java/${LANE}-sha512.txt"
+
+ tar -czf "release-java/${LANE}-packages.tar.gz" \
+ -T "${files}"
+ sha512sum "release-java/${LANE}-packages.tar.gz" \
+ > "release-java/${LANE}-packages.tar.gz.sha512"
+
+ {
+ echo "lane=${LANE}"
+ echo "jdk=${{ matrix.java_version }}"
+ echo "version=${{ steps.release.outputs.version }}"
+ echo "rc=${{ steps.release.outputs.rc_number }}"
+ echo "commit=${GITHUB_SHA}"
+ echo "signed=false"
+ echo "staged=false"
+ echo "artifact_source=maven-install-log"
+ } > "release-java/${LANE}-manifest.txt"
+
+ - name: Upload Java packages
+ uses: actions/upload-artifact@v5
+ with:
+ name: java-package-${{ matrix.lane }}
+ path: release-java/
+ if-no-files-found: error
diff --git a/.github/workflows/release-python-publish.yml b/.github/workflows/release-python-publish.yml
new file mode 100644
index 000000000000..eaa2af9eb1fc
--- /dev/null
+++ b/.github/workflows/release-python-publish.yml
@@ -0,0 +1,118 @@
+################################################################################
+# 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.
+################################################################################
+
+name: Release PyPaimon Publish
+
+on:
+ workflow_call:
+
+concurrency:
+ group: release-python-publish-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ actions: read
+ contents: read
+
+jobs:
+ publish:
+ name: Publish PyPaimon
+ if: >-
+ github.repository == 'apache/paimon' &&
+ startsWith(github.ref, 'refs/tags/')
+ environment:
+ name: ${{ contains(github.ref_name, '-rc') && 'testpypi' || 'pypi' }}
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/download-artifact@v5
+ with:
+ name: pypaimon-distributions
+ path: dist
+
+ - name: Verify package versions
+ id: package_version
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ base_version="$(
+ sed -n 's/^VERSION = "\(.*\)"/\1/p' \
+ paimon-python/setup.py
+ )"
+ expected_version="${base_version}"
+ if [[ "${GITHUB_REF_NAME}" == *-rc* ]]; then
+ expected_version="${base_version}rc${GITHUB_REF_NAME##*-rc}"
+ fi
+
+ test -f "dist/pypaimon-${expected_version}.tar.gz"
+ compgen -G "dist/pypaimon-${expected_version}-*.whl" > /dev/null
+
+ shopt -s nullglob
+ distributions=(dist/*)
+ if [[ "${#distributions[@]}" -ne 2 ]]; then
+ echo "Expected one sdist and one wheel, found ${#distributions[@]} files" >&2
+ printf '%s\n' "${distributions[@]}" >&2
+ exit 1
+ fi
+
+ echo "version=${expected_version}" >> "${GITHUB_OUTPUT}"
+
+ - name: Reject existing TestPyPI RC version
+ if: contains(github.ref_name, '-rc')
+ shell: bash
+ env:
+ EXPECTED_VERSION: ${{ steps.package_version.outputs.version }}
+ run: |
+ set -euo pipefail
+
+ status="$(
+ curl --silent --show-error --output /dev/null \
+ --write-out '%{http_code}' \
+ "https://test.pypi.org/pypi/pypaimon/${EXPECTED_VERSION}/json"
+ )"
+
+ case "${status}" in
+ 404)
+ ;;
+ 200)
+ echo "pypaimon ${EXPECTED_VERSION} already exists on TestPyPI; create a new RC" >&2
+ exit 1
+ ;;
+ *)
+ echo "TestPyPI version check returned HTTP ${status}; refusing to publish" >&2
+ exit 1
+ ;;
+ esac
+
+ - name: Publish RC to TestPyPI
+ if: contains(github.ref_name, '-rc')
+ uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
+ with:
+ repository-url: https://test.pypi.org/legacy/
+ skip-existing: false
+ packages-dir: dist
+ password: ${{ secrets.TEST_PYPI_API_TOKEN }}
+
+ - name: Publish final release to PyPI
+ if: ${{ !contains(github.ref_name, '-rc') }}
+ uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
+ with:
+ packages-dir: dist
+ password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml
new file mode 100644
index 000000000000..5f264aa1377a
--- /dev/null
+++ b/.github/workflows/release-python.yml
@@ -0,0 +1,129 @@
+################################################################################
+# 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.
+################################################################################
+
+# Build the PyPaimon source distribution and universal wheel. Publishing is
+# handled by release-python-publish.yml after all required release jobs pass.
+
+name: Release PyPaimon Package
+
+on:
+ workflow_call:
+ workflow_dispatch:
+
+concurrency:
+ group: release-python-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+jobs:
+ package:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ cache: pip
+ cache-dependency-path: paimon-python/dev/requirements.txt
+
+ - name: Set package version
+ id: package_version
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ base_version="$(
+ sed -n 's/^VERSION = "\(.*\)"/\1/p' \
+ paimon-python/setup.py
+ )"
+ if [[ -z "${base_version}" || "${base_version}" == *dev* || "${base_version}" == *rc* ]]; then
+ echo "PyPaimon release base version is invalid: ${base_version:-missing}" >&2
+ exit 1
+ fi
+
+ package_version="${base_version}"
+ if [[ "${GITHUB_REF}" == refs/tags/*-rc* ]]; then
+ rc_number="${GITHUB_REF_NAME##*-rc}"
+ package_version="${base_version}rc${rc_number}"
+ sed -i \
+ "s/^VERSION = \".*\"/VERSION = \"${package_version}\"/" \
+ paimon-python/setup.py
+ fi
+
+ echo "version=${package_version}" >> "${GITHUB_OUTPUT}"
+ echo "Building PyPaimon ${package_version}"
+
+ - name: Build distributions
+ working-directory: paimon-python
+ run: |
+ python -m pip install --upgrade pip setuptools wheel twine
+ python setup.py sdist bdist_wheel
+ python -m twine check dist/*
+
+ - name: Verify distribution contents
+ working-directory: paimon-python
+ env:
+ EXPECTED_VERSION: ${{ steps.package_version.outputs.version }}
+ run: |
+ python - <<'PY'
+ import glob
+ import os
+ import tarfile
+ import zipfile
+
+ expected = os.environ["EXPECTED_VERSION"]
+ sdist = "dist/pypaimon-{}.tar.gz".format(expected)
+ wheels = glob.glob("dist/pypaimon-{}-*.whl".format(expected))
+ if not os.path.isfile(sdist):
+ raise SystemExit("Missing expected sdist: {}".format(sdist))
+ if len(wheels) != 1:
+ raise SystemExit("Expected one wheel, found: {}".format(wheels))
+
+ with tarfile.open(sdist, "r:gz") as archive:
+ names = archive.getnames()
+ prefix = "pypaimon-{}/".format(expected)
+ for required in ("LICENSE", "NOTICE", "README.md", "setup.py"):
+ if prefix + required not in names:
+ raise SystemExit("Missing {} from sdist".format(required))
+
+ with zipfile.ZipFile(wheels[0]) as archive:
+ names = archive.namelist()
+ license_files = [
+ name for name in names
+ if ".dist-info/" in name and name.endswith("/LICENSE")
+ ]
+ notice_files = [
+ name for name in names
+ if ".dist-info/" in name and name.endswith("/NOTICE")
+ ]
+ if not license_files or not notice_files:
+ raise SystemExit("Wheel is missing LICENSE or NOTICE")
+ PY
+
+ - name: Upload PyPaimon distributions
+ uses: actions/upload-artifact@v5
+ with:
+ name: pypaimon-distributions
+ path: |
+ paimon-python/dist/*.tar.gz
+ paimon-python/dist/*.whl
+ if-no-files-found: error
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000000..e9f14bf48397
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,129 @@
+################################################################################
+# 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.
+################################################################################
+
+# Orchestrate Java and PyPaimon packaging. Java signing and Nexus staging, plus
+# the signed ASF source archives, remain Release Manager responsibilities.
+
+name: Release
+
+on:
+ push:
+ tags:
+ - "release-[0-9]+.[0-9]+.[0-9]+"
+ - "release-[0-9]+.[0-9]+.[0-9]+-rc[0-9]+"
+ workflow_dispatch:
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ actions: read
+ contents: read
+
+jobs:
+ validation:
+ name: Validate release tag
+ runs-on: ubuntu-latest
+ outputs:
+ release_version: ${{ steps.release.outputs.release_version }}
+ release_kind: ${{ steps.release.outputs.release_kind }}
+ rc_number: ${{ steps.release.outputs.rc_number }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up JDK 8
+ uses: actions/setup-java@v5
+ with:
+ java-version: 8
+ distribution: temurin
+ cache: maven
+
+ - name: Set up Maven
+ uses: stCarolas/setup-maven@d6af6abeda15e98926a57b5aa970a96bb37f97d1 # v5
+ with:
+ maven-version: 3.8.8
+
+ - name: Validate tag against project version
+ id: release
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ if [[ "${GITHUB_REF}" != refs/tags/* ]]; then
+ echo "Release workflow must run from a tag, got ${GITHUB_REF}" >&2
+ exit 1
+ fi
+
+ release_version="$(
+ mvn -q -DforceStdout help:evaluate \
+ -Dexpression=project.version
+ )"
+ if [[ -z "${release_version}" || "${release_version}" == *-SNAPSHOT ]]; then
+ echo "Project release version is invalid: ${release_version:-missing}" >&2
+ exit 1
+ fi
+
+ final_tag="release-${release_version}"
+ rc_prefix="${final_tag}-rc"
+ release_kind=""
+ rc_number=""
+ if [[ "${GITHUB_REF_NAME}" == "${final_tag}" ]]; then
+ release_kind="final"
+ elif [[ "${GITHUB_REF_NAME}" == "${rc_prefix}"* ]]; then
+ rc_number="${GITHUB_REF_NAME#"${rc_prefix}"}"
+ if [[ ! "${rc_number}" =~ ^[0-9]+$ ]]; then
+ echo "RC tag must be ${rc_prefix}N, got ${GITHUB_REF_NAME}" >&2
+ exit 1
+ fi
+ release_kind="rc"
+ else
+ echo "Tag must be ${final_tag} or ${rc_prefix}N, got ${GITHUB_REF_NAME}" >&2
+ exit 1
+ fi
+
+ echo "release_version=${release_version}" >> "${GITHUB_OUTPUT}"
+ echo "release_kind=${release_kind}" >> "${GITHUB_OUTPUT}"
+ echo "rc_number=${rc_number}" >> "${GITHUB_OUTPUT}"
+
+ echo "Validated ${release_kind} tag ${GITHUB_REF_NAME}"
+ java -version
+ mvn -version
+
+ java:
+ name: Java packages
+ needs: validation
+ if: needs.validation.outputs.release_kind == 'rc'
+ uses: ./.github/workflows/release-java.yml
+
+ python-package:
+ name: PyPaimon package
+ needs: validation
+ uses: ./.github/workflows/release-python.yml
+
+ python-publish:
+ name: PyPaimon publish
+ needs: [validation, java, python-package]
+ if: >-
+ always() &&
+ needs.validation.result == 'success' &&
+ startsWith(github.ref, 'refs/tags/') &&
+ needs.python-package.result == 'success' &&
+ (needs.java.result == 'success' ||
+ needs.validation.outputs.release_kind == 'final')
+ uses: ./.github/workflows/release-python-publish.yml
diff --git a/docs/docs/project/creating-a-release.md b/docs/docs/project/creating-a-release.md
new file mode 100644
index 000000000000..bfe20952e6c1
--- /dev/null
+++ b/docs/docs/project/creating-a-release.md
@@ -0,0 +1,466 @@
+---
+title: "Creating a Release"
+sidebar_position: 2
+---
+
+
+
+# Creating a Release
+
+This guide is for the Release Manager (RM) of Apache Paimon and PyPaimon. It
+follows the [ASF Release Policy](https://www.apache.org/legal/release-policy.html)
+and the [ASF Release Distribution Policy](https://infra.apache.org/release-distribution).
+
+:::warning
+
+The signed source archives are the Apache releases. Maven and PyPI packages are
+convenience artifacts. The final release must promote the source files and Java
+staging repositories approved by the community; do not rebuild or replace them
+after the vote.
+
+:::
+
+## Release model
+
+Paimon contains two independently versioned deliverables. Do not assume that
+their version numbers are equal.
+
+| Deliverable | Candidate | Published location |
+| --- | --- | --- |
+| Paimon source | `apache-paimon-PAIMON_VERSION-src.tgz`, `.asc`, `.sha512` | ASF distribution |
+| Java convenience artifacts | Maven artifacts built in the JDK 8, 11, and 17 lanes | Apache Nexus staging, then Maven Central |
+| PyPaimon source | `pypaimon-PYPAIMON_VERSION.tar.gz`, `.asc`, `.sha512` | ASF distribution |
+| Python convenience package | `pypaimon==PYPAIMON_VERSIONrcRC_NUMBER` for an RC | TestPyPI, then `pypaimon==PYPAIMON_VERSION` on PyPI |
+
+A combined release vote covers both signed source candidates. This guide does
+not define an independent PyPaimon release. Before releasing PyPaimon
+separately, the PMC must define a Python-only tag and workflow which do not
+depend on the Maven version or Java jobs, and must provide a signed source
+package which is independently sufficient to build and test the release.
+
+### Java build matrix
+
+The three Java lanes are different release targets, not interchangeable build
+JDKs:
+
+| JDK | Maven profiles and scope | Main artifacts |
+| --- | --- | --- |
+| 8 | `spark3,flink1` and the default reactor | Paimon core, Flink 1.x, Spark 3.x, Hive, filesystems, bundles, and other Java 8 artifacts |
+| 11 | `flink2` plus `paimon-iceberg` | Flink 2.x, `paimon-flink2-common`, and Iceberg integration |
+| 17 | `spark4` | Spark 4.x and its Scala 2.13 common artifacts |
+
+Each lane must use the matching JDK. Building everything on JDK 17 with a lower
+compiler target is not a substitute for running the JDK 8 and JDK 11 lanes.
+
+## GitHub Actions release workflow
+
+The release process uses the
+[Release workflow](https://github.com/apache/paimon/actions/workflows/release.yml)
+to package Java and PyPaimon from every signed RC tag. The RM signs and stages
+Java locally, and creates and signs the two ASF source archives locally from
+the same tag. The RM's GPG private key is never stored in GitHub Actions.
+
+The workflow has the following contract:
+
+| Job | Required behavior |
+| --- | --- |
+| Validation | Require an RC tag named `release-PAIMON_VERSION-rcN` or a final tag named `release-PAIMON_VERSION`, where `PAIMON_VERSION` exactly equals the root Maven `project.version` |
+| Java 8 | Use Temurin 8 to package the default reactor with Spark 3 and Flink 1, then upload the package, checksums, manifest, and log |
+| Java 11 | Use Temurin 11 to package Flink 2 and Iceberg, then upload the package, checksums, manifest, and log |
+| Java 17 | Use Temurin 17 to package Spark 4, then upload the package, checksums, manifest, and log |
+| Python package | Build and validate the PyPaimon source distribution and universal wheel, then upload them as workflow artifacts |
+| Python publish | Publish an RC to TestPyPI only after all Java and Python packaging jobs pass; publish a final tag to PyPI |
+
+Before packaging, every Java lane runs Maven Enforcer's
+`requireReleaseVersion` and `requireReleaseDeps` rules over its complete reactor
+scope. The latter includes transitive dependencies. Any remaining
+`-SNAPSHOT` project, parent, direct dependency, or transitive dependency is a
+release blocker.
+
+The Java jobs use `-Dgpg.skip=true` and never receive Nexus credentials or a
+GPG private key. Their artifacts are build evidence, not the Maven staging
+repositories used for the vote. The Python RC job uses the protected
+`testpypi` environment to publish `PYPAIMON_VERSIONrcRC_NUMBER` to TestPyPI.
+The final job uses the separate protected `pypi` environment.
+
+Configure both environments with an RM approval and a deployment policy which
+allows only the corresponding signed release tags. Store
+`TEST_PYPI_API_TOKEN` only in `testpypi` and `PYPI_API_TOKEN` only in `pypi`.
+Do not store either token as a repository or organization secret, and do not
+pass unrelated secrets to the reusable publishing workflow.
+
+## One-time RM setup
+
+Before managing the first release:
+
+1. Create a GPG key associated with your `@apache.org` identity and publish it
+ to a public key server.
+2. Append the public key to the Paimon
+ [KEYS](https://downloads.apache.org/paimon/KEYS) file. Never remove keys
+ required to verify an older release.
+3. Configure Git and local GPG to sign tags and Maven artifacts with the same
+ key.
+4. Configure the local Maven server ID `apache.releases.https` with the RM's
+ Apache Nexus credentials. Do not copy the GPG key or Nexus credentials into
+ GitHub Actions.
+5. Confirm access to the ASF distribution SVN repository, Apache Nexus,
+ TestPyPI, and PyPI.
+6. Create the protected GitHub environments `testpypi` and `pypi`. Configure
+ required RM reviewers and release-tag deployment policies, store only the
+ matching token in each environment, and remove any repository- or
+ organization-level copies of those tokens.
+7. Verify that the Python release secrets are configured without printing
+ their values in an Actions log.
+
+```shell
+gpg --list-secret-keys --keyid-format LONG
+git config user.signingkey
+svn --version
+```
+
+## Prepare the release
+
+### Agree on the release
+
+Discuss the release on `dev@paimon.apache.org`, select an RM, resolve release
+blockers, review incompatible changes and upgrade notes, and prepare release
+notes. Ensure CI is green on the commit from which the candidate will be cut.
+
+### Set the release variables
+
+The following example deliberately uses different Java and Python versions:
+
+```shell
+PAIMON_VERSION="2.0.0"
+PYPAIMON_VERSION="1.5.0"
+RC_NUMBER="1"
+
+RC_REF="release-${PAIMON_VERSION}-rc${RC_NUMBER}"
+RELEASE_TAG="release-${PAIMON_VERSION}"
+```
+
+Use these exact values in the branch, tag, workflow inputs, SVN directories,
+vote email, and Java package manifests.
+
+### Work from a clean clone
+
+```shell
+git clone https://github.com/apache/paimon.git paimon-release
+cd paimon-release
+git checkout master
+git pull --ff-only origin master
+git status --short
+```
+
+The last command must produce no output.
+
+### Create the RC branch and set versions
+
+Create the RC branch with the existing helper:
+
+```shell
+RELEASE_VERSION="${PAIMON_VERSION}" \
+RELEASE_CANDIDATE="${RC_NUMBER}" \
+ ./tools/releasing/create_release_branch.sh
+```
+
+This creates `release-PAIMON_VERSION-rcRC_NUMBER`.
+
+Change all Maven modules from `PAIMON_VERSION-SNAPSHOT` to
+`PAIMON_VERSION`. The helper commits the Maven version change:
+
+```shell
+NEW_VERSION="${PAIMON_VERSION}" \
+ ./tools/releasing/update_branch_version.sh
+```
+
+Set `VERSION` in `paimon-python/setup.py` to the final
+`PYPAIMON_VERSION`, without `.dev` or an RC suffix. The release workflow
+derives the TestPyPI version by appending `rcRC_NUMBER`; the source candidate
+keeps the final version.
+
+Review and commit the Python version, dependency declarations, release notes,
+`LICENSE`, `NOTICE`, and any generated legal files. Then confirm that the
+candidate contains no snapshot or development version:
+
+```shell
+mvn -q -DforceStdout help:evaluate -Dexpression=project.version
+python3 paimon-python/setup.py --version
+rg --glob 'pom.xml' "${PAIMON_VERSION}-SNAPSHOT"
+rg '^VERSION = ".*\.dev' paimon-python/setup.py
+git status --short
+```
+
+The first two commands must print the requested release versions, the `rg`
+commands must find no release-version marker, and the worktree must be clean.
+
+### Sign and push the RC tag
+
+The historical Paimon convention uses the same name for the RC branch and tag.
+Use explicit refs when pushing so Git cannot select the wrong one:
+
+```shell
+git tag -s "${RC_REF}" \
+ -m "Apache Paimon ${PAIMON_VERSION} and PyPaimon ${PYPAIMON_VERSION} RC${RC_NUMBER}"
+git tag -v "${RC_REF}"
+
+git push origin \
+ "refs/heads/${RC_REF}:refs/heads/${RC_REF}"
+git push origin \
+ "refs/tags/${RC_REF}:refs/tags/${RC_REF}"
+```
+
+Pushing the signed tag starts the Release workflow. The common validation job
+must succeed before any Java or Python packaging or publishing job can run.
+Wait for every required job to succeed. Record:
+
+- the workflow run URL and `head_sha`;
+- the JDK 8, 11, and 17 package artifact names, manifests, and SHA-512
+ checksums;
+- the TestPyPI project/version URL.
+
+Do not start the vote when a required lane is missing or has been rerun from a
+different commit.
+
+## Stage the Java convenience artifacts locally
+
+Check out the exact signed RC tag in a clean clone. Use the RM machine's local
+GPG key and Maven credentials; do not download a private key into a CI runner.
+Use Maven 3.8.8, matching the Release workflow. Export `GPG_TTY` when the local
+GPG agent needs terminal access:
+
+```shell
+git checkout --detach "refs/tags/${RC_REF}"
+export GPG_TTY="$(tty)"
+
+gpg --list-secret-keys --keyid-format LONG
+mvn -q -DforceStdout help:evaluate -Dexpression=project.version
+```
+
+Download the three Java package artifacts from the recorded workflow run and
+verify each `*-packages.tar.gz.sha512` file. Keep the downloaded
+`jdk8-sha512.txt`, `jdk11-sha512.txt`, and `jdk17-sha512.txt` files for the
+reproducibility check after each local build.
+
+Run each existing staging script under its required JDK. Confirm the output of
+`java -version` and `mvn -version` before every command:
+
+```shell
+# JDK 8: default reactor, Flink 1.x, and Spark 3.x
+./tools/releasing/deploy_staging_jars.sh
+sha512sum -c /path/to/java-package-jdk8/jdk8-sha512.txt
+
+# JDK 11: Flink 2.x and Iceberg
+./tools/releasing/deploy_staging_jars_for_jdk11.sh
+sha512sum -c /path/to/java-package-jdk11/jdk11-sha512.txt
+
+# JDK 17: Spark 4.x
+./tools/releasing/deploy_staging_jars_for_jdk17.sh
+sha512sum -c /path/to/java-package-jdk17/jdk17-sha512.txt
+```
+
+On macOS, replace `sha512sum -c` with `shasum -a 512 -c`. A checksum mismatch
+between the CI package and the locally signed build is a release blocker.
+Maven signs the release artifacts with the RM's local GPG key and deploys them
+using the local `apache.releases.https` server credentials. After each command,
+record the `orgapachepaimon-XXXX` repository ID and confirm that it contains
+only the intended lane. Close each repository and resolve every close-time rule
+failure before starting the vote. Closing freezes the candidate that voters
+inspect. Do not release a repository before the vote passes.
+
+## Stage the source candidates
+
+Create both source candidates locally from the exact signed tag in a fresh
+clone. The Paimon helper creates, signs, and checksums the main source archive.
+Build the PyPaimon source distribution separately and sign it with the same RM
+key:
+
+```shell
+git checkout --detach "refs/tags/${RC_REF}"
+
+RELEASE_VERSION="${PAIMON_VERSION}" \
+ ./tools/releasing/create_source_release.sh
+
+cd paimon-python
+python3 setup.py sdist
+gpg --armor --detach-sig \
+ "dist/pypaimon-${PYPAIMON_VERSION}.tar.gz"
+
+if command -v sha512sum >/dev/null 2>&1; then
+ sha512sum "dist/pypaimon-${PYPAIMON_VERSION}.tar.gz" \
+ > "dist/pypaimon-${PYPAIMON_VERSION}.tar.gz.sha512"
+else
+ shasum -a 512 "dist/pypaimon-${PYPAIMON_VERSION}.tar.gz" \
+ > "dist/pypaimon-${PYPAIMON_VERSION}.tar.gz.sha512"
+fi
+
+cp "dist/pypaimon-${PYPAIMON_VERSION}.tar.gz"* ../release/
+cd ..
+```
+
+Verify both signatures and checksum files locally before uploading them to ASF
+dist dev.
+
+```shell
+svn checkout --depth=immediates \
+ https://dist.apache.org/repos/dist/dev/paimon/ paimon-dist-dev
+
+mkdir "paimon-dist-dev/paimon-${PAIMON_VERSION}-rc${RC_NUMBER}"
+cp "release/apache-paimon-${PAIMON_VERSION}-src.tgz"* \
+ "paimon-dist-dev/paimon-${PAIMON_VERSION}-rc${RC_NUMBER}/"
+
+mkdir "paimon-dist-dev/pypaimon-${PYPAIMON_VERSION}-rc${RC_NUMBER}"
+cp "release/pypaimon-${PYPAIMON_VERSION}.tar.gz"* \
+ "paimon-dist-dev/pypaimon-${PYPAIMON_VERSION}-rc${RC_NUMBER}/"
+
+svn add \
+ "paimon-dist-dev/paimon-${PAIMON_VERSION}-rc${RC_NUMBER}" \
+ "paimon-dist-dev/pypaimon-${PYPAIMON_VERSION}-rc${RC_NUMBER}"
+svn commit -m \
+ "Stage Paimon ${PAIMON_VERSION} and PyPaimon ${PYPAIMON_VERSION} RC${RC_NUMBER}" \
+ paimon-dist-dev
+```
+
+Never overwrite an existing RC directory. TestPyPI versions are immutable for
+this release process as well: the workflow checks for an existing version before
+upload and fails instead of skipping files. The uploader also rejects duplicate
+files to cover races after the check. If any byte changes, create a new RC with
+a new RC number.
+
+## Call the vote
+
+Send a plain-text vote to `dev@paimon.apache.org`. Keep it open for at least
+72 hours. The vote must receive at least three binding `+1` votes and more
+binding `+1` than binding `-1` votes.
+
+```text
+Subject: [VOTE] Release Apache Paimon ${PAIMON_VERSION} and PyPaimon ${PYPAIMON_VERSION} (RC${RC_NUMBER})
+
+Hi everyone,
+
+Please review and vote on Apache Paimon ${PAIMON_VERSION} and
+PyPaimon ${PYPAIMON_VERSION}, release candidate ${RC_NUMBER}.
+
+[ ] +1 Approve
+[ ] 0 No opinion
+[ ] -1 Do not approve (please explain)
+
+Paimon source candidate:
+https://dist.apache.org/repos/dist/dev/paimon/paimon-${PAIMON_VERSION}-rc${RC_NUMBER}/
+
+PyPaimon source candidate:
+https://dist.apache.org/repos/dist/dev/paimon/pypaimon-${PYPAIMON_VERSION}-rc${RC_NUMBER}/
+
+Signed Git tag:
+release-${PAIMON_VERSION}-rc${RC_NUMBER}
+
+Commit:
+https://github.com/apache/paimon/commit/
+
+GitHub Actions release run:
+
+
+KEYS:
+https://downloads.apache.org/paimon/KEYS
+
+Closed Java staging repositories:
+
+
+
+
+PyPaimon RC:
+https://test.pypi.org/project/pypaimon/${PYPAIMON_VERSION}rc${RC_NUMBER}/
+
+Verification guide:
+https://github.com/apache/paimon/blob/release-${PAIMON_VERSION}-rc${RC_NUMBER}/docs/docs/project/verifying-a-release-candidate.md
+
+The vote will remain open for at least 72 hours.
+```
+
+After the deadline, tally binding and non-binding votes separately and send
+`[RESULT][VOTE]` to the same thread.
+
+## Replace a failed candidate
+
+If the vote finds a problem:
+
+1. Fix it through the normal review process.
+2. Drop every Nexus staging repository belonging to the failed RC.
+3. Remove the superseded dist-dev directories, or retain them temporarily when
+ useful to the vote discussion. Never replace their contents.
+4. Increment `RC_NUMBER`; never reuse the failed candidate's TestPyPI version.
+5. Create a new RC branch and signed tag, run every workflow lane again, stage
+ new source candidates, and start a new 72-hour vote.
+
+## Finalize an approved release
+
+### Create the final signed tag
+
+The final tag must point to exactly the approved RC commit:
+
+```shell
+git tag -s "${RELEASE_TAG}" "refs/tags/${RC_REF}^{commit}" \
+ -m "Release Apache Paimon ${PAIMON_VERSION}"
+
+test "$(git rev-parse "refs/tags/${RC_REF}^{commit}")" = \
+ "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")"
+git tag -v "${RELEASE_TAG}"
+git push origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
+```
+
+### Promote the source releases
+
+Move, rather than copy or rebuild, both approved candidate directories:
+
+```shell
+svn mv -m "Release Apache Paimon ${PAIMON_VERSION}" \
+ "https://dist.apache.org/repos/dist/dev/paimon/paimon-${PAIMON_VERSION}-rc${RC_NUMBER}" \
+ "https://dist.apache.org/repos/dist/release/paimon/paimon-${PAIMON_VERSION}"
+
+svn mv -m "Release PyPaimon ${PYPAIMON_VERSION}" \
+ "https://dist.apache.org/repos/dist/dev/paimon/pypaimon-${PYPAIMON_VERSION}-rc${RC_NUMBER}" \
+ "https://dist.apache.org/repos/dist/release/paimon/pypaimon-${PYPAIMON_VERSION}"
+```
+
+### Promote convenience artifacts
+
+1. In Nexus, confirm that every recorded JDK 8, 11, and 17 staging repository
+ is still closed and has the exact artifact tree approved by the vote.
+2. Release those exact closed repositories to Maven Central. Do not run Maven
+ deploy again.
+3. Approve the protected `pypi` promotion job for the final tag. It must build
+ `pypaimon==PYPAIMON_VERSION` from the approved tag commit and must not change
+ project source.
+4. Verify Maven Central and PyPI before announcing the release.
+
+### Publish and announce
+
+Create a GitHub release from `release-PAIMON_VERSION`, review the generated
+notes, and link both source releases. Update the Paimon download page and
+versioned documentation. After ASF mirrors, Maven Central, and PyPI are all
+available, announce the release to `dev@paimon.apache.org` and
+`announce@apache.org` from an `@apache.org` address.
+
+Remove superseded releases from the live ASF distribution area when required;
+they remain available from the
+[Apache archive](https://archive.apache.org/dist/paimon/).
+
+See [Verifying a Release Candidate](./verifying-a-release-candidate.md) for the
+voter checklist.
diff --git a/docs/docs/project/verifying-a-release-candidate.md b/docs/docs/project/verifying-a-release-candidate.md
new file mode 100644
index 000000000000..6910c909f442
--- /dev/null
+++ b/docs/docs/project/verifying-a-release-candidate.md
@@ -0,0 +1,380 @@
+---
+title: "Verifying a Release Candidate"
+sidebar_position: 3
+---
+
+
+
+# Verifying a Release Candidate
+
+Verify a release candidate independently before voting. The signed Paimon and
+PyPaimon source archives are the release. Maven, TestPyPI, and GitHub Actions
+checks supplement source verification but do not replace it.
+
+Report only the checks, platforms, and tool versions that you actually used.
+
+## Collect the candidate inputs
+
+Take all values and URLs from the vote email:
+
+```shell
+PAIMON_VERSION="2.0.0"
+PYPAIMON_VERSION="1.5.0"
+RC_NUMBER="1"
+RC_TAG="release-${PAIMON_VERSION}-rc${RC_NUMBER}"
+
+PAIMON_RC_DIR="paimon-${PAIMON_VERSION}-rc${RC_NUMBER}"
+PAIMON_ARCHIVE="apache-paimon-${PAIMON_VERSION}-src.tgz"
+PYPAIMON_RC_DIR="pypaimon-${PYPAIMON_VERSION}-rc${RC_NUMBER}"
+PYPAIMON_ARCHIVE="pypaimon-${PYPAIMON_VERSION}.tar.gz"
+```
+
+Download the candidates from ASF dist dev, not from a third-party mirror:
+
+```shell
+curl -O "https://dist.apache.org/repos/dist/dev/paimon/${PAIMON_RC_DIR}/${PAIMON_ARCHIVE}"
+curl -O "https://dist.apache.org/repos/dist/dev/paimon/${PAIMON_RC_DIR}/${PAIMON_ARCHIVE}.asc"
+curl -O "https://dist.apache.org/repos/dist/dev/paimon/${PAIMON_RC_DIR}/${PAIMON_ARCHIVE}.sha512"
+
+curl -O "https://dist.apache.org/repos/dist/dev/paimon/${PYPAIMON_RC_DIR}/${PYPAIMON_ARCHIVE}"
+curl -O "https://dist.apache.org/repos/dist/dev/paimon/${PYPAIMON_RC_DIR}/${PYPAIMON_ARCHIVE}.asc"
+curl -O "https://dist.apache.org/repos/dist/dev/paimon/${PYPAIMON_RC_DIR}/${PYPAIMON_ARCHIVE}.sha512"
+
+curl -O https://downloads.apache.org/paimon/KEYS
+```
+
+Keep the workflow run URL, announced commit SHA, three Nexus staging URLs, and
+TestPyPI URL beside these files. All of them must identify this RC.
+
+## Verify signatures and checksums
+
+```shell
+gpg --import KEYS
+gpg --verify "${PAIMON_ARCHIVE}.asc" "${PAIMON_ARCHIVE}"
+gpg --verify "${PYPAIMON_ARCHIVE}.asc" "${PYPAIMON_ARCHIVE}"
+```
+
+Confirm that both signatures are good and that the full signing-key fingerprint
+belongs to the RM and appears in the downloaded `KEYS` file.
+
+On Linux:
+
+```shell
+sha512sum -c "${PAIMON_ARCHIVE}.sha512"
+sha512sum -c "${PYPAIMON_ARCHIVE}.sha512"
+```
+
+On macOS:
+
+```shell
+shasum -a 512 -c "${PAIMON_ARCHIVE}.sha512"
+shasum -a 512 -c "${PYPAIMON_ARCHIVE}.sha512"
+```
+
+## Verify Git provenance
+
+```shell
+git clone https://github.com/apache/paimon.git paimon-candidate-git
+git -C paimon-candidate-git fetch --tags
+git -C paimon-candidate-git tag -v "${RC_TAG}"
+git -C paimon-candidate-git rev-parse "refs/tags/${RC_TAG}^{commit}"
+```
+
+The resolved commit must equal both the SHA in the vote email and the
+`head_sha` of the successful Release workflow run. Inspect the commits since the
+previous release and confirm that the RC tag is attached to the intended branch.
+
+## Inspect the source archives
+
+List the archives before extracting them:
+
+```shell
+tar tzf "${PAIMON_ARCHIVE}" | sed -n '1,100p'
+tar tzf "${PYPAIMON_ARCHIVE}" | sed -n '1,100p'
+```
+
+Check at least the following:
+
+- The Paimon archive has exactly one top-level directory named
+ `paimon-PAIMON_VERSION`.
+- The PyPaimon archive has exactly one top-level directory named
+ `pypaimon-PYPAIMON_VERSION`.
+- `LICENSE`, `NOTICE`, README files, build files, dependency declarations, and
+ required source files are present and correct.
+- No Git metadata, IDE state, credentials, Maven `target` directories, Python
+ `dist` or `__pycache__` directories, compiled classes, generated JARs, or
+ unrelated binary files are included.
+- All bundled third-party material is compatible with the Apache License 2.0
+ and is recorded in `LICENSE` or `NOTICE` where required.
+- Maven POMs use `PAIMON_VERSION` without `-SNAPSHOT`.
+- `paimon-python/setup.py` and PyPaimon package metadata use
+ `PYPAIMON_VERSION` without `.dev`.
+
+Extract the Paimon candidate:
+
+```shell
+tar xzf "${PAIMON_ARCHIVE}"
+```
+
+Run the repository licensing checks. The PyPaimon checker temporarily extracts
+its source package and removes that temporary directory when it finishes:
+
+```shell
+cd "paimon-${PAIMON_VERSION}"
+mvn -ntp -DskipTests apache-rat:check
+cd ..
+
+SOURCE_PACKAGE="${PYPAIMON_ARCHIVE}" \
+ "./paimon-${PAIMON_VERSION}/paimon-python/dev/check-licensing.sh"
+```
+
+Review the generated reports rather than relying only on the exit codes.
+After the licensing check, extract a fresh PyPaimon source tree for the build
+and test steps below:
+
+```shell
+tar xzf "${PYPAIMON_ARCHIVE}"
+```
+
+## Build Java from the source archive
+
+Use three clean extracted copies or three clean containers so that class files
+and Maven state from one JDK cannot leak into another lane. Record `java
+-version` and `mvn -version` for each build.
+
+### JDK 8 lane
+
+This lane covers the default reactor, Flink 1.x, and Spark 3.x:
+
+```shell
+(
+ cd "paimon-${PAIMON_VERSION}"
+ mvn -ntp clean verify -Pspark3,flink1
+)
+```
+
+For supplementary or non-binding verification, you may build the same scope
+used for staging and state explicitly that tests were skipped:
+
+```shell
+(
+ cd "paimon-${PAIMON_VERSION}"
+ mvn -ntp clean install -DskipTests \
+ -Pdocs-and-source,spark3,flink1
+)
+```
+
+### JDK 11 lane
+
+This lane covers Flink 2.x and Iceberg:
+
+```shell
+(
+ cd "paimon-${PAIMON_VERSION}"
+ mvn -ntp clean verify -Pflink2 \
+ -pl org.apache.paimon:paimon-flink-2.0,org.apache.paimon:paimon-flink-2.1,org.apache.paimon:paimon-flink-2.2,org.apache.paimon:paimon-iceberg \
+ -am
+)
+```
+
+The packaging-equivalent build is:
+
+```shell
+(
+ cd "paimon-${PAIMON_VERSION}"
+ mvn -ntp clean install -DskipTests -Pdocs-and-source,flink2 \
+ -pl org.apache.paimon:paimon-flink-2.0,org.apache.paimon:paimon-flink-2.1,org.apache.paimon:paimon-flink-2.2,org.apache.paimon:paimon-iceberg \
+ -am
+)
+```
+
+### JDK 17 lane
+
+This lane covers Spark 4.x:
+
+```shell
+(
+ cd "paimon-${PAIMON_VERSION}"
+ mvn -ntp clean verify -Pspark4 \
+ -pl paimon-spark/paimon-spark-4.0,paimon-spark/paimon-spark-4.1 \
+ -am
+)
+```
+
+The packaging-equivalent build is:
+
+```shell
+(
+ cd "paimon-${PAIMON_VERSION}"
+ mvn -ntp clean install -DskipTests -Pdocs-and-source,spark4 \
+ -pl paimon-spark/paimon-spark-4.0,paimon-spark/paimon-spark-4.1 \
+ -am
+)
+```
+
+Investigate warnings and skipped modules. A successful compiler exit does not,
+by itself, establish that the candidate is suitable for release. The
+`-DskipTests` commands above do not by themselves satisfy the requirements for
+a binding `+1`. A binding voter must compile the signed source package and test
+the result on their own platform; run an appropriate test or smoke test against
+the artifacts produced by that source build and report the exact scope.
+
+## Verify Java staging repositories
+
+Use all three exact `orgapachepaimon-XXXX` staging URLs from the vote email.
+Do not resolve artifacts from Maven Central or a local cache when testing the
+candidate.
+
+For every staging repository:
+
+- Confirm that its status is closed, so the candidate cannot change during the
+ vote.
+- Confirm that the version is `PAIMON_VERSION`, never a snapshot.
+- Confirm that each published module has its POM, main artifact, source JAR,
+ Javadoc JAR, detached signatures, and checksums where applicable.
+- Inspect shaded and bundled JARs for the required `META-INF/LICENSE` and
+ `META-INF/NOTICE` content and for unexpected duplicate or bundled
+ dependencies.
+- Resolve representative artifacts in a clean Maven repository and run a small
+ consumer smoke test.
+
+Confirm the lane boundaries:
+
+| Lane | Must contain |
+| --- | --- |
+| JDK 8 | Default Java 8 artifacts, Flink 1.x, and Spark 3.x |
+| JDK 11 | Flink 2.x, `paimon-flink2-common`, and `paimon-iceberg` |
+| JDK 17 | Spark 4.x and its Scala 2.13 common artifacts |
+
+Check representative class-file targets. Java class major versions are 52 for
+Java 8, 55 for Java 11, and 61 for Java 17:
+
+```shell
+JAR_FILE="/path/to/a/representative.jar"
+CLASS_NAME="$(jar tf "${JAR_FILE}" | grep '\.class$' | grep -v 'module-info' | head -1 | sed 's#/#.#g;s#\.class$##')"
+javap -verbose -classpath "${JAR_FILE}" "${CLASS_NAME}" | grep 'major version'
+```
+
+Run this against at least one representative JAR from each lane. Also check
+that Java 8 artifacts can be loaded by a JDK 8 runtime; class-file inspection
+alone does not detect every use of a newer JDK API.
+
+## Build and test PyPaimon from source
+
+First build the official PyPaimon source candidate in an isolated environment:
+
+```shell
+python3 -m venv pypaimon-rc-venv
+. pypaimon-rc-venv/bin/activate
+python -m pip install --upgrade pip setuptools wheel
+
+cd "pypaimon-${PYPAIMON_VERSION}"
+python setup.py sdist bdist_wheel
+python -m pip install "dist/pypaimon-${PYPAIMON_VERSION}"*.whl
+python -c "import pypaimon; print('PyPaimon import OK')"
+cd ..
+```
+
+Inspect the generated sdist and wheel. They must contain the expected Python
+sources, `LICENSE`, `NOTICE`, README, and package metadata, with no tests,
+caches, credentials, or unrelated build output.
+
+This guide covers a combined Paimon and PyPaimon release. The complete Python
+test suite for the PyPaimon candidate is supplied in the signed Paimon source
+archive; the PyPaimon source distribution must not be used by itself for an
+independent release. Run the tests from
+`paimon-PAIMON_VERSION/paimon-python` on as many supported Python versions as
+your environment allows. The project CI selects Python 3.6, 3.7, 3.10, and
+3.11 for its main compatibility lanes:
+
+```shell
+(
+ cd "paimon-${PAIMON_VERSION}/paimon-python"
+ python -m pip install -r dev/requirements.txt
+ python -m pip install -r dev/requirements-dev.txt
+ python -m pytest pypaimon/tests -v
+)
+```
+
+Some optional integration tests need additional services or packages. Record
+which tests ran, skipped, or failed.
+
+## Verify the TestPyPI candidate
+
+Install the exact RC version in a new environment:
+
+```shell
+python3 -m venv pypaimon-testpypi-venv
+. pypaimon-testpypi-venv/bin/activate
+python -m pip install \
+ --index-url https://test.pypi.org/simple/ \
+ --extra-index-url https://pypi.org/simple/ \
+ "pypaimon==${PYPAIMON_VERSION}rc${RC_NUMBER}"
+python -c "import pypaimon; print('TestPyPI package OK')"
+```
+
+Check the available files, package metadata, Python requirement, dependencies,
+license files, and a representative read/write operation. The RC suffix is the
+only intended version change from the final PyPaimon source candidate; any
+source-code difference is a release blocker.
+
+## Verify GitHub Actions evidence
+
+Open the workflow run linked in the vote email and confirm:
+
+- it was triggered from the signed `RC_TAG`;
+- `head_sha` equals the announced commit;
+- the common validation job confirmed that `RC_TAG` is exactly
+ `release-PAIMON_VERSION-rcRC_NUMBER` and that `PAIMON_VERSION` equals the
+ root Maven `project.version`;
+- JDK 8, JDK 11, JDK 17, Python packaging, and Python publishing jobs all
+ succeeded;
+- the logs show the expected JDK and Python versions;
+- each Java package artifact contains the expected manifest and SHA-512
+ checksums for its lane;
+- the TestPyPI version equals the version in the vote email;
+- no later rerun silently replaced a failed lane with artifacts from another
+ commit.
+
+GitHub Actions evidence is useful for reviewing the complete platform matrix,
+but voters should still perform independent source, signature, build, and smoke
+checks.
+
+## Report your vote
+
+Reply to the vote thread with `+1`, `0`, or `-1`, state whether your vote is
+binding, and list the checks you completed.
+
+```text
++1 (binding/non-binding)
+
+Verified:
+- signed RC tag and announced commit SHA
+- Paimon and PyPaimon GPG signatures and SHA-512 checksums
+- LICENSE, NOTICE, source-only archive contents, and release versions
+- Paimon source build on , JDK <8/11/17>, Maven
+- Java staging repositories and representative class-file targets
+- PyPaimon source build/tests on Python
+- TestPyPI installation and smoke test
+- GitHub Actions run provenance, Java package manifests, and Python artifacts
+```
+
+For a `-1`, describe the failure precisely enough for the RM to reproduce it.
+See [Creating a Release](./creating-a-release.md) for the RM workflow.
diff --git a/docs/sidebars.js b/docs/sidebars.js
index ed0db484e7cb..56a9c3e21107 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -318,6 +318,8 @@ const sidebars = {
},
"items": [
"project/download",
+ "project/creating-a-release",
+ "project/verifying-a-release-candidate",
"project/contributing",
"project/committer",
"project/security"