chore: add and harden Apache release tooling (#156)
diff --git a/.github/workflows/release_candidate.yaml b/.github/workflows/release_candidate.yaml
new file mode 100644
index 0000000..aaba9ec
--- /dev/null
+++ b/.github/workflows/release_candidate.yaml
@@ -0,0 +1,135 @@
+# 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 Candidate
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/release_candidate.yaml"
+ - ".github/.rat-excludes"
+ - "CMakeLists.txt"
+ - "LICENSE"
+ - "NOTICE"
+ - "docs/source/conf.py"
+ - "docs/source/_static/versions.json"
+ - "scripts/releasing/**"
+ push:
+ tags:
+ - "v*-rc*"
+
+concurrency:
+ group: ${{ github.repository }}-${{ github.ref }}-${{ github.workflow }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ archive:
+ name: Create and audit source archive
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Run release tool tests
+ run: python3 -m unittest discover -s scripts/releasing/tests -v
+
+ - name: Resolve release version
+ shell: bash
+ run: |
+ version=$(
+ awk '$1 == "VERSION" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ {
+ print $2
+ exit
+ }' CMakeLists.txt
+ )
+ if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
+ tag_version=${GITHUB_REF_NAME#v}
+ tag_version=${tag_version%-rc*}
+ [[ "${tag_version}" == "${version}" ]]
+ fi
+ scripts/releasing/bump_version.py --check "${version}"
+ echo "RELEASE_VERSION=${version}" >> "${GITHUB_ENV}"
+
+ - name: Create source archive
+ run: |
+ scripts/releasing/create_source_release.sh \
+ --version "${RELEASE_VERSION}" \
+ --git-ref HEAD \
+ --output-dir release/ci
+
+ - name: Audit source archive
+ run: |
+ scripts/releasing/verify_release_candidate.sh \
+ --allow-unsigned \
+ --skip-build \
+ "release/ci/apache-paimon-cpp-${RELEASE_VERSION}-src.tgz"
+
+ - name: Upload source archive
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: source-archive
+ path: release/ci/
+ if-no-files-found: error
+
+ verify:
+ name: Verify source archive (${{ matrix.compiler }})
+ if: github.ref_type == 'tag'
+ needs: archive
+ runs-on: ubuntu-24.04
+ timeout-minutes: 180
+ strategy:
+ fail-fast: false
+ matrix:
+ compiler:
+ - gcc-14
+ - clang
+ include:
+ - compiler: gcc-14
+ cc: gcc-14
+ cxx: g++-14
+ - compiler: clang
+ cc: clang
+ cxx: clang++
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ persist-credentials: false
+
+ - name: Download source archive
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: source-archive
+ path: release/ci
+
+ - name: Verify, build, test, and install
+ shell: bash
+ env:
+ CC: ${{ matrix.cc }}
+ CXX: ${{ matrix.cxx }}
+ run: |
+ artifact=$(find release/ci -name 'apache-paimon-cpp-*-src.tgz' -print -quit)
+ scripts/releasing/verify_release_candidate.sh \
+ --allow-unsigned \
+ --skip-rat \
+ "${artifact}"
diff --git a/ci/scripts/build_paimon.sh b/ci/scripts/build_paimon.sh
index 16e6fe0..ecc83a7 100755
--- a/ci/scripts/build_paimon.sh
+++ b/ci/scripts/build_paimon.sh
@@ -21,8 +21,19 @@
enable_sanitizer=${2:-false}
check_clang_tidy=${3:-false}
build_type=${4:-Debug}
+install_smoke=${5:-false}
build_dir="${source_dir}/build"
+if [[ -n "${PAIMON_BUILD_JOBS:-}" ]]; then
+ build_jobs="${PAIMON_BUILD_JOBS}"
+elif command -v nproc >/dev/null 2>&1; then
+ build_jobs=$(nproc)
+elif command -v sysctl >/dev/null 2>&1; then
+ build_jobs=$(sysctl -n hw.ncpu)
+else
+ build_jobs=4
+fi
+
# Display ccache status if available
if command -v ccache &> /dev/null; then
echo "=== ccache found: $(ccache --version | head -1) ==="
@@ -57,13 +68,40 @@
fi
cmake "${CMAKE_ARGS[@]}" "${source_dir}"
-cmake --build . -- -j "$(nproc)"
-ctest --output-on-failure -j "$(nproc)"
+cmake --build . -- -j "${build_jobs}"
+ctest --output-on-failure -j "${build_jobs}"
if [[ "${check_clang_tidy}" == "true" ]]; then
cmake --build . --target check-clang-tidy
fi
+if [[ "${install_smoke}" == "true" ]]; then
+ install_dir="${source_dir}/install-test"
+ smoke_build_dir="${source_dir}/build-install-smoke"
+ rm -rf "${install_dir}" "${smoke_build_dir}"
+
+ cmake --install . --prefix "${install_dir}"
+ cmake -G Ninja \
+ -S "${source_dir}/scripts/releasing/install_smoke" \
+ -B "${smoke_build_dir}" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_PREFIX_PATH="${install_dir};${build_dir}/arrow_ep-install"
+ cmake --build "${smoke_build_dir}" -- -j "${build_jobs}"
+
+ runtime_library_path="${install_dir}/lib:${install_dir}/lib64"
+ runtime_library_path+=":${build_dir}/arrow_ep-install/lib"
+ runtime_library_path+=":${build_dir}/arrow_ep-install/lib64"
+ if [[ "$(uname -s)" == "Darwin" ]]; then
+ cmake -E env \
+ "DYLD_LIBRARY_PATH=${runtime_library_path}" \
+ "${smoke_build_dir}/paimon_install_smoke"
+ else
+ cmake -E env \
+ "LD_LIBRARY_PATH=${runtime_library_path}" \
+ "${smoke_build_dir}/paimon_install_smoke"
+ fi
+fi
+
# Print ccache statistics after build
if command -v ccache &> /dev/null; then
echo "=== ccache statistics after build ==="
@@ -73,3 +111,6 @@
popd
rm -rf "${build_dir}"
+if [[ "${install_smoke}" == "true" ]]; then
+ rm -rf "${install_dir}" "${smoke_build_dir}"
+fi
diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt
index 385b482..05bdee1 100644
--- a/examples/CMakeLists.txt
+++ b/examples/CMakeLists.txt
@@ -37,7 +37,8 @@
elseif(TARGET Arrow::arrow_static)
set(PAIMON_EXAMPLE_ARROW_TARGET Arrow::arrow_static)
else()
- message(FATAL_ERROR "Neither Arrow::arrow_shared nor Arrow::arrow_static is available")
+ message(FATAL_ERROR "Neither Arrow::arrow_shared nor Arrow::arrow_static is available"
+ )
endif()
add_executable(read_write_demo read_write_demo.cpp)
diff --git a/scripts/releasing/README.md b/scripts/releasing/README.md
index bf9b7dd..d7757a9 100644
--- a/scripts/releasing/README.md
+++ b/scripts/releasing/README.md
@@ -20,29 +20,91 @@
# Apache Paimon C++ release scripts
These scripts create and verify the source artifact voted on by the Apache Paimon
-PMC. They do not publish artifacts, create tags, or move files between Apache
-distribution repositories.
+PMC, stage a release candidate in the ASF distribution repository, and publish
+an approved candidate. Run release operations from a clean checkout of
+`apache/paimon-cpp`, not from a fork.
+
+The source archive is the official Apache release. Git tags, GitHub Releases,
+and binary packages are supplementary.
+
+## Prerequisites
+
+Before starting a release:
+
+- obtain an ASF code-signing key, publish it through the ASF account system,
+ and make sure it is present in
+ [Paimon KEYS](https://downloads.apache.org/paimon/KEYS);
+- install `git`, `gpg`, `svn`, `gh`, `python3`, `curl` or `wget`, Java, CMake,
+ Ninja, and the toolchain needed by `ci/scripts/build_paimon.sh` (Java is
+ required by Apache RAT);
+- authenticate `gh` with access to read GitHub Actions runs in
+ `apache/paimon-cpp`;
+- make sure the Apache Git remote points directly to
+ `apache/paimon-cpp`;
+- prepare and merge a release-preparation PR that updates the release notes and
+ all version metadata, and passes the normal and release-candidate workflows.
+
+For example, update all version locations and review the diff:
+
+```bash
+scripts/releasing/bump_version.py 0.2.2 0.2.3
+scripts/releasing/bump_version.py --check 0.2.3
+```
+
+### Signing key setup and security
+
+Complete signing-key setup well before creating the first release candidate:
+
+- new signing keys must use RSA with at least 2048 bits; ASF recommends 4096
+ bits for new keys;
+- publish the public key to the global public keyserver network;
+- append the public key to `dist/release/paimon/KEYS`. Never remove historical
+ keys because they are required to verify archived releases;
+- wait until the updated key is visible from
+ `https://downloads.apache.org/paimon/KEYS` before creating an RC; and
+- never store the private key or create release signatures on ASF machines.
+ Sign only on a secure machine controlled by the release manager.
+
+By default, updating `dist/release/paimon/KEYS` requires PMC membership. A
+non-PMC release manager should ask a PMC member to add the key.
+
+See the ASF
+[release-signing](https://infra.apache.org/release-signing.html) and
+[release-distribution](https://infra.apache.org/release-distribution.html)
+policies for the complete requirements.
+
+The release scripts use `vVERSION-rcRC` for release-candidate tags and
+`vVERSION` for the final release tag. For example, the first 0.2.3 candidate is
+`v0.2.3-rc1`.
## Create a release candidate
-Create and push a signed RC tag before creating the source artifact:
+Start from the exact clean commit approved for the candidate. Before publishing,
+the wrapper fetches the release branch and requires `HEAD` to be contained in
+its current history. It then creates and verifies a signed RC tag, creates the
+source archive and its checksum/signature, performs the full source-release
+verification, pushes the tag, waits for the tag-triggered release-candidate
+workflow to succeed, and imports the artifacts into ASF `dist/dev`:
```bash
-git tag -s release-0.2.3-rc1 -m "Apache Paimon C++ 0.2.3 RC1"
-git push upstream release-0.2.3-rc1
-```
-
-Create the source artifact, SHA-512 checksum, and detached OpenPGP signature:
-
-```bash
-scripts/releasing/create_source_release.sh \
+scripts/releasing/release_rc.sh \
--version 0.2.3 \
- --git-ref release-0.2.3-rc1 \
- --output-dir release/0.2.3-rc1 \
- --signing-key ASF_GPG_KEY_ID
+ --rc 1 \
+ --signing-key ASF_GPG_KEY_ID \
+ --remote upstream
```
-The output files are:
+The release branch defaults to `main`; use `--release-branch NAME` for a
+maintenance release from another Apache branch.
+
+Use `--prepare-only` to create and verify artifacts without pushing the tag or
+uploading to ASF infrastructure. This local-only mode does not require `HEAD`
+to match the remote release branch. Use `--dry-run` to print identifiers
+without making changes. A resumed run reuses an existing local tag or complete
+artifact set only after validating it. A prepare-only run does not print a vote
+email and must not be used to start a vote.
+
+The candidate directory contains:
```text
apache-paimon-cpp-0.2.3-src.tgz
@@ -50,32 +112,114 @@
apache-paimon-cpp-0.2.3-src.tgz.sha512
```
-Existing files are never overwritten. A changed candidate must use a new RC
-directory and a new vote.
+The wrapper prints a vote-email template. Send it to `dev@paimon.apache.org`.
+Keep the vote open for at least 72 hours. An Apache release vote requires at
+least three binding `+1` votes and more binding `+1` than binding `-1` votes.
+If the vote has not met these requirements after 72 hours, do not publish the
+release; either extend the vote or close it as unsuccessful.
+
+Before casting a binding `+1`, a PMC member must download the signed source
+artifact onto hardware they control, verify its signature and ASF policy
+compliance, compile it as provided, and test it on their platform. CI results
+do not replace this voter responsibility.
+
+After closing the vote, send a result email as a reply to the vote thread. Use
+the subject
+`[RESULT][VOTE][C++] Release Apache Paimon C++ VERSION RCNUMBER`, state whether
+the vote passed, list binding and non-binding votes and voters separately, and
+include the archived vote-thread link.
+
+If a candidate changes for any reason, cancel or close its vote, fix the
+release-preparation branch, increment the RC number, create a new signed tag
+and artifacts, and start a new vote. Never overwrite an existing candidate.
+After its vote is closed, a failed or superseded candidate may be removed from
+`dist/dev`.
## Verify a release candidate
-Download the source artifact, its `.asc` and `.sha512` files, and the Paimon
-`KEYS` file. Import `KEYS` into a temporary or dedicated GPG keyring, then run:
+Voters can download and verify an ASF-staged candidate in one command:
```bash
-RAT_JAR=/path/to/apache-rat-0.16.1.jar \
- scripts/releasing/verify_release_candidate.sh \
+scripts/releasing/verify_release_candidate.sh --version 0.2.3 --rc 1
+```
+
+To verify files that were downloaded separately, use an explicitly downloaded
+KEYS file so signature verification runs in an isolated GPG home:
+
+```bash
+scripts/releasing/verify_release_candidate.sh \
+ --keys-file /path/to/paimon-KEYS \
apache-paimon-cpp-0.2.3-src.tgz
```
The verifier checks:
- the SHA-512 checksum and detached OpenPGP signature;
-- archive paths and the single `paimon-cpp-0.2.3/` root directory;
+- archive path safety, portable filename collisions, file types, permissions,
+ and the single `paimon-cpp-0.2.3/` root directory;
- required `LICENSE`, `NOTICE`, build, and documentation files;
- the CMake and documentation versions;
-- absence of common compiled artifact types;
+- absence of compiled artifacts by filename and file magic;
- Apache RAT results;
-- a release build and the test suite from the extracted source archive.
+- a release build and the test suite from the extracted source archive; and
+- installation plus compilation and execution of an external CMake consumer.
-The `--allow-unsigned` and `--skip-rat` options are only for local development
-of the release process. A voter may use `--skip-build` when the repository's
-Linux CI build command is not suitable for their platform, but must then build
-and test the extracted source distribution separately before casting a binding
-vote.
+Pass `--git-ref v0.2.3-rc1` when the Git repository is available to regenerate
+the archive from the signed tag and compare it byte-for-byte.
+
+`--allow-unsigned`, `--skip-rat`, `--skip-build`, and `--skip-install` exist for
+CI or local development of the release process. They are not a substitute for
+the corresponding checks when voting. The release-candidate workflow creates
+an unsigned archive for deterministic CI validation; official artifacts must
+always be signed by the release manager.
+
+## Publish an approved release
+
+After closing the vote and confirming that it passed, publish the exact
+approved candidate:
+
+```bash
+scripts/releasing/publish_release.sh \
+ --version 0.2.3 \
+ --rc 1 \
+ --signing-key ASF_GPG_KEY_ID \
+ --remote upstream \
+ --confirm-vote-passed
+```
+
+By default, only PMC members can publish to `dist/release`. A non-PMC release
+manager must ask a PMC member to perform this step unless Infra has configured
+the project, following project consensus, to allow all committers to publish.
+
+The script verifies the signed RC tag, creates a signed final tag pointing to
+the same commit, moves the candidate from ASF `dist/dev` to `dist/release`, and
+creates a GitHub Release containing byte-identical copies of the ASF source
+artifacts. It then prints the remaining ASF reporting, old-release cleanup,
+documentation, mirror-propagation, and announcement steps.
+
+Download-page and release-note PRs may be prepared before publication, but do
+not merge them while they point users at an unapproved RC. After publication,
+verify the artifacts on `downloads.apache.org`, then wait at least 24 hours
+before merging public download/documentation changes and sending the release
+announcement. This follows the Paimon project convention, which is stricter
+than ASF's general one-hour minimum.
+
+## Individual tools
+
+- `bump_version.py`: consistently check or update CMake and documentation
+ version metadata.
+- `create_source_release.sh`: deterministically create an archive, SHA-512
+ checksum, and optional detached signature from an immutable Git ref.
+- `validate_source_archive.py`: reject unsafe or non-portable tar members and
+ compiled files.
+- `verify_release_candidate.sh`: perform voter-facing integrity, license,
+ build, test, and install checks.
+- `release_rc.sh`: orchestrate release-candidate tagging, verification, and ASF
+ staging.
+- `publish_release.sh`: publish an approved candidate.
+
+Run the release-tool regression tests with:
+
+```bash
+python3 -m unittest discover -s scripts/releasing/tests -v
+```
diff --git a/scripts/releasing/bump_version.py b/scripts/releasing/bump_version.py
new file mode 100755
index 0000000..f2c6df8
--- /dev/null
+++ b/scripts/releasing/bump_version.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+#
+# 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.
+
+"""Check or update the Apache Paimon C++ project and documentation version."""
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Dict, List
+
+
+VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
+
+
+class VersionError(RuntimeError):
+ """Version metadata is missing or inconsistent."""
+
+
+def require_version(value: str) -> None:
+ if VERSION_PATTERN.fullmatch(value) is None:
+ raise VersionError(f"invalid version {value!r}; expected MAJOR.MINOR.PATCH")
+
+
+def replace_once(text: str, pattern: str, replacement: str, description: str) -> str:
+ result, count = re.subn(pattern, replacement, text, flags=re.MULTILINE)
+ if count != 1:
+ raise VersionError(f"expected one {description}, found {count}")
+ return result
+
+
+def load_versions(path: Path) -> List[Dict[str, object]]:
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise VersionError(f"cannot read {path}: {error}") from error
+ if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
+ raise VersionError(f"{path} must contain a JSON array of objects")
+ return value
+
+
+def checked_files(root: Path, expected: str) -> Dict[Path, str]:
+ cmake_path = root / "CMakeLists.txt"
+ docs_path = root / "docs/source/conf.py"
+ versions_path = root / "docs/source/_static/versions.json"
+
+ cmake = cmake_path.read_text(encoding="utf-8")
+ docs = docs_path.read_text(encoding="utf-8")
+ versions = load_versions(versions_path)
+
+ cmake_matches = re.findall(
+ r"^[ \t]*VERSION[ \t]+(\d+\.\d+\.\d+)[ \t]*$", cmake, re.MULTILINE
+ )
+ docs_matches = re.findall(
+ r'^version = "(\d+\.\d+\.\d+)"$', docs, re.MULTILINE
+ )
+ json_matches = [
+ item
+ for item in versions
+ if item.get("name") == expected and item.get("version") == expected
+ ]
+
+ if cmake_matches != [expected]:
+ raise VersionError(f"CMake version is {cmake_matches}, expected [{expected!r}]")
+ if docs_matches != [expected]:
+ raise VersionError(
+ f"documentation version is {docs_matches}, expected [{expected!r}]"
+ )
+ if len(json_matches) != 1:
+ raise VersionError(
+ "versions.json must contain exactly one entry whose name and "
+ f"version are both {expected}"
+ )
+ return {
+ cmake_path: cmake,
+ docs_path: docs,
+ versions_path: json.dumps(versions, indent=4) + "\n",
+ }
+
+
+def updated_files(root: Path, current: str, new: str) -> Dict[Path, str]:
+ files = checked_files(root, current)
+ cmake_path = root / "CMakeLists.txt"
+ docs_path = root / "docs/source/conf.py"
+ versions_path = root / "docs/source/_static/versions.json"
+
+ files[cmake_path] = replace_once(
+ files[cmake_path],
+ rf"^([ \t]*VERSION[ \t]+){re.escape(current)}([ \t]*)$",
+ rf"\g<1>{new}\g<2>",
+ f"CMake VERSION {current}",
+ )
+ files[docs_path] = replace_once(
+ files[docs_path],
+ rf'^version = "{re.escape(current)}"$',
+ f'version = "{new}"',
+ f"documentation version {current}",
+ )
+
+ versions = load_versions(versions_path)
+ matches = [item for item in versions if item.get("version") == current]
+ if len(matches) != 1:
+ raise VersionError(
+ f"versions.json contains {len(matches)} entries for {current}"
+ )
+ matches[0]["name"] = new
+ matches[0]["version"] = new
+ files[versions_path] = json.dumps(versions, indent=4) + "\n"
+ return files
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("current_version", nargs="?")
+ parser.add_argument("new_version", nargs="?")
+ parser.add_argument("--check", metavar="VERSION")
+ parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument(
+ "--root", type=Path, default=Path(__file__).resolve().parents[2]
+ )
+ args = parser.parse_args()
+
+ try:
+ if args.check:
+ if args.current_version or args.new_version:
+ raise VersionError("--check cannot be combined with version arguments")
+ require_version(args.check)
+ checked_files(args.root.resolve(), args.check)
+ print(f"Release version metadata is consistent: {args.check}")
+ return 0
+
+ if not args.current_version or not args.new_version:
+ raise VersionError("CURRENT_VERSION and NEW_VERSION are required")
+ require_version(args.current_version)
+ require_version(args.new_version)
+ if args.current_version == args.new_version:
+ raise VersionError("current and new versions must differ")
+
+ files = updated_files(
+ args.root.resolve(), args.current_version, args.new_version
+ )
+ for path, content in files.items():
+ if args.dry_run:
+ print(f"Would update {path}")
+ else:
+ path.write_text(content, encoding="utf-8")
+ print(f"Updated {path}")
+ return 0
+ except (OSError, VersionError) as error:
+ print(f"Version update failed: {error}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/releasing/create_source_release.sh b/scripts/releasing/create_source_release.sh
index fb0345a..f65ae2b 100755
--- a/scripts/releasing/create_source_release.sh
+++ b/scripts/releasing/create_source_release.sh
@@ -115,8 +115,7 @@
CMAKE_VERSION=$(
git -C "${SOURCE_ROOT}" show "${GIT_REF}:CMakeLists.txt" |
- sed -n 's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*$/\1/p' |
- head -n 1
+ awk '$1 == "VERSION" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ { print $2; exit }'
)
[[ "${CMAKE_VERSION}" == "${RELEASE_VERSION}" ]] ||
fail "CMake version ${CMAKE_VERSION:-<missing>} does not match ${RELEASE_VERSION}"
diff --git a/scripts/releasing/install_smoke/CMakeLists.txt b/scripts/releasing/install_smoke/CMakeLists.txt
new file mode 100644
index 0000000..7665e30
--- /dev/null
+++ b/scripts/releasing/install_smoke/CMakeLists.txt
@@ -0,0 +1,26 @@
+# 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.
+
+cmake_minimum_required(VERSION 3.16)
+project(paimon_install_smoke LANGUAGES CXX)
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+find_package(Paimon CONFIG REQUIRED)
+
+add_executable(paimon_install_smoke main.cpp)
+target_link_libraries(paimon_install_smoke PRIVATE Paimon::paimon_shared)
diff --git a/scripts/releasing/install_smoke/main.cpp b/scripts/releasing/install_smoke/main.cpp
new file mode 100644
index 0000000..914adfc
--- /dev/null
+++ b/scripts/releasing/install_smoke/main.cpp
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+#include <string>
+
+#include "paimon/status.h"
+
+int main() {
+ const paimon::Status status = paimon::Status::Invalid("install smoke test");
+ return !status.ok() && status.ToString().find("install smoke test") != std::string::npos ? 0
+ : 1;
+}
diff --git a/scripts/releasing/publish_release.sh b/scripts/releasing/publish_release.sh
new file mode 100755
index 0000000..c3081a8
--- /dev/null
+++ b/scripts/releasing/publish_release.sh
@@ -0,0 +1,328 @@
+#!/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 -euo pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+SOURCE_ROOT=$(cd "${SCRIPT_DIR}/../.." && pwd)
+
+VERSION=""
+RC=""
+SIGNING_KEY=""
+REMOTE="origin"
+DIST_DEV_BASE_URL="https://dist.apache.org/repos/dist/dev/paimon"
+DIST_RELEASE_BASE_URL="https://dist.apache.org/repos/dist/release/paimon"
+CONFIRM_VOTE_PASSED=false
+SKIP_GITHUB_RELEASE=false
+DRY_RUN=false
+
+usage() {
+ cat <<'EOF'
+Publish an approved Apache Paimon C++ release candidate.
+
+Usage:
+ publish_release.sh --version VERSION --rc RC --signing-key KEY_ID \
+ --confirm-vote-passed [options]
+
+Required:
+ --version VERSION Approved release version
+ --rc RC Approved release candidate number
+ --signing-key KEY_ID OpenPGP key used to sign the final Git tag
+ --confirm-vote-passed Explicitly confirm that the PMC vote passed
+
+Options:
+ --remote REMOTE Apache Git remote (default: origin)
+ --dist-dev-base URL ASF dist/dev project URL
+ --dist-release-base URL ASF dist/release project URL
+ --skip-github-release Do not create the GitHub Release
+ --dry-run Print the planned publication identifiers and exit
+ -h, --help Show this help
+
+The script is resumable if the final tag, SVN move, or GitHub Release already
+completed and still points to the approved RC commit.
+EOF
+}
+
+fail() {
+ echo "Error: $*" >&2
+ exit 1
+}
+
+require_command() {
+ command -v "$1" >/dev/null 2>&1 || fail "$1 is required"
+}
+
+validate_release_directory() {
+ local directory=$1
+ local -a entries
+ local entry
+ local name
+
+ shopt -s dotglob nullglob
+ entries=("${directory}"/*)
+ shopt -u dotglob nullglob
+ [[ ${#entries[@]} -eq 3 ]] ||
+ fail "${directory} must contain exactly the archive, signature, and checksum"
+ for entry in "${entries[@]}"; do
+ [[ -f "${entry}" ]] || fail "release contains a non-file entry: ${entry}"
+ name=$(basename "${entry}")
+ case "${name}" in
+ "${ARTIFACT_NAME}" | \
+ "${ARTIFACT_NAME}.asc" | \
+ "${ARTIFACT_NAME}.sha512")
+ ;;
+ *)
+ fail "release contains an unexpected file: ${name}"
+ ;;
+ esac
+ done
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version)
+ [[ $# -ge 2 ]] || fail "--version requires a value"
+ VERSION=$2
+ shift 2
+ ;;
+ --rc)
+ [[ $# -ge 2 ]] || fail "--rc requires a value"
+ RC=$2
+ shift 2
+ ;;
+ --signing-key)
+ [[ $# -ge 2 ]] || fail "--signing-key requires a value"
+ SIGNING_KEY=$2
+ shift 2
+ ;;
+ --remote)
+ [[ $# -ge 2 ]] || fail "--remote requires a value"
+ REMOTE=$2
+ shift 2
+ ;;
+ --dist-dev-base)
+ [[ $# -ge 2 ]] || fail "--dist-dev-base requires a value"
+ DIST_DEV_BASE_URL=${2%/}
+ shift 2
+ ;;
+ --dist-release-base)
+ [[ $# -ge 2 ]] || fail "--dist-release-base requires a value"
+ DIST_RELEASE_BASE_URL=${2%/}
+ shift 2
+ ;;
+ --confirm-vote-passed)
+ CONFIRM_VOTE_PASSED=true
+ shift
+ ;;
+ --skip-github-release)
+ SKIP_GITHUB_RELEASE=true
+ shift
+ ;;
+ --dry-run)
+ DRY_RUN=true
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ fail "unknown argument: $1"
+ ;;
+ esac
+done
+
+[[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] ||
+ fail "--version must use MAJOR.MINOR.PATCH format"
+[[ "${RC}" =~ ^[0-9]+$ ]] || fail "--rc must be a non-negative integer"
+[[ -n "${SIGNING_KEY}" ]] || fail "--signing-key is required"
+[[ "${CONFIRM_VOTE_PASSED}" == true ]] ||
+ fail "--confirm-vote-passed is required"
+
+RC_TAG="v${VERSION}-rc${RC}"
+RELEASE_TAG="v${VERSION}"
+RC_ID="paimon-cpp-${VERSION}-rc${RC}"
+RELEASE_ID="paimon-cpp-${VERSION}"
+ARTIFACT_NAME="apache-paimon-cpp-${VERSION}-src.tgz"
+RC_URL="${DIST_DEV_BASE_URL}/${RC_ID}"
+RELEASE_URL="${DIST_RELEASE_BASE_URL}/${RELEASE_ID}"
+
+if [[ "${DRY_RUN}" == true ]]; then
+ cat <<EOF
+Approved RC tag: ${RC_TAG}
+Final release tag: ${RELEASE_TAG}
+ASF source: ${RC_URL}
+ASF destination: ${RELEASE_URL}
+Git remote: ${REMOTE}
+Create GitHub Release: $([[ "${SKIP_GITHUB_RELEASE}" == true ]] && echo no || echo yes)
+EOF
+ exit 0
+fi
+
+for command in git gpg svn; do
+ require_command "${command}"
+done
+if [[ "${SKIP_GITHUB_RELEASE}" == false ]]; then
+ require_command gh
+fi
+
+cd "${SOURCE_ROOT}"
+[[ -z "$(git status --porcelain)" ]] ||
+ fail "working tree must be clean before publishing a release"
+
+REMOTE_URL=$(git remote get-url "${REMOTE}" 2>/dev/null) ||
+ fail "Git remote does not exist: ${REMOTE}"
+case "${REMOTE_URL}" in
+ git@github.com:apache/paimon-cpp.git | \
+ https://github.com/apache/paimon-cpp | \
+ https://github.com/apache/paimon-cpp.git | \
+ ssh://git@github.com/apache/paimon-cpp.git)
+ ;;
+ *)
+ fail "${REMOTE} must point to apache/paimon-cpp, found ${REMOTE_URL}"
+ ;;
+esac
+
+git rev-parse --verify "${RC_TAG}^{tag}" >/dev/null 2>&1 ||
+ fail "signed RC tag is missing: ${RC_TAG}"
+git verify-tag "${RC_TAG}"
+RC_COMMIT=$(git rev-parse "${RC_TAG}^{commit}")
+
+RC_PRESENT=false
+RELEASE_PRESENT=false
+if svn info "${RC_URL}" >/dev/null 2>&1; then
+ RC_PRESENT=true
+fi
+if svn info "${RELEASE_URL}" >/dev/null 2>&1; then
+ RELEASE_PRESENT=true
+fi
+if [[ "${RC_PRESENT}" == true && "${RELEASE_PRESENT}" == true ]]; then
+ fail "both RC and final release directories exist; resolve SVN state manually"
+fi
+if [[ "${RC_PRESENT}" == false && "${RELEASE_PRESENT}" == false ]]; then
+ fail "approved artifacts are missing from both dist/dev and dist/release"
+fi
+
+TEMP_DIR=$(mktemp -d)
+trap 'rm -rf "${TEMP_DIR}"' EXIT
+RELEASE_FILES_DIR="${TEMP_DIR}/${RELEASE_ID}"
+if [[ "${RC_PRESENT}" == true ]]; then
+ svn export --quiet "${RC_URL}" "${RELEASE_FILES_DIR}"
+else
+ svn export --quiet "${RELEASE_URL}" "${RELEASE_FILES_DIR}"
+fi
+validate_release_directory "${RELEASE_FILES_DIR}"
+for suffix in "" ".asc" ".sha512"; do
+ [[ -f "${RELEASE_FILES_DIR}/${ARTIFACT_NAME}${suffix}" ]] ||
+ fail "approved release is missing ${ARTIFACT_NAME}${suffix}"
+done
+
+"${SCRIPT_DIR}/verify_release_candidate.sh" \
+ --keys-url "https://downloads.apache.org/paimon/KEYS" \
+ --git-ref "${RC_TAG}" \
+ --skip-build \
+ "${RELEASE_FILES_DIR}/${ARTIFACT_NAME}"
+
+if git rev-parse --verify "${RELEASE_TAG}^{tag}" >/dev/null 2>&1; then
+ RELEASE_COMMIT=$(git rev-parse "${RELEASE_TAG}^{commit}")
+ [[ "${RELEASE_COMMIT}" == "${RC_COMMIT}" ]] ||
+ fail "${RELEASE_TAG} does not point to the approved RC commit"
+ git verify-tag "${RELEASE_TAG}"
+ echo "Reusing verified final tag ${RELEASE_TAG}."
+else
+ git tag -s -u "${SIGNING_KEY}" \
+ -m "Release Apache Paimon C++ ${VERSION}" \
+ "${RELEASE_TAG}" "${RC_COMMIT}"
+ git verify-tag "${RELEASE_TAG}"
+fi
+git push "${REMOTE}" "${RELEASE_TAG}"
+
+if [[ "${RELEASE_PRESENT}" == true ]]; then
+ echo "Release artifacts are already present at ${RELEASE_URL}."
+else
+ svn mv "${RC_URL}" "${RELEASE_URL}" \
+ -m "Release Apache Paimon C++ ${VERSION}"
+fi
+
+if [[ "${SKIP_GITHUB_RELEASE}" == false ]]; then
+ if gh release view "${RELEASE_TAG}" --repo apache/paimon-cpp >/dev/null 2>&1; then
+ echo "Checking existing GitHub Release ${RELEASE_TAG}."
+ EXISTING_ASSETS_DIR="${TEMP_DIR}/existing-assets"
+ mkdir "${EXISTING_ASSETS_DIR}"
+ for suffix in "" ".asc" ".sha512"; do
+ ASSET_NAME="${ARTIFACT_NAME}${suffix}"
+ if gh release view "${RELEASE_TAG}" \
+ --repo apache/paimon-cpp \
+ --json assets \
+ --jq '.assets[].name' |
+ grep -Fxq "${ASSET_NAME}"; then
+ gh release download "${RELEASE_TAG}" \
+ --repo apache/paimon-cpp \
+ --dir "${EXISTING_ASSETS_DIR}" \
+ --pattern "${ASSET_NAME}"
+ cmp "${EXISTING_ASSETS_DIR}/${ASSET_NAME}" \
+ "${RELEASE_FILES_DIR}/${ASSET_NAME}" >/dev/null ||
+ fail "GitHub Release asset differs from ASF release: ${ASSET_NAME}"
+ else
+ gh release upload "${RELEASE_TAG}" \
+ --repo apache/paimon-cpp \
+ "${RELEASE_FILES_DIR}/${ASSET_NAME}"
+ fi
+ done
+ else
+ gh release create "${RELEASE_TAG}" \
+ --repo apache/paimon-cpp \
+ --verify-tag \
+ --generate-notes \
+ --title "Apache Paimon C++ ${VERSION}" \
+ "${RELEASE_FILES_DIR}/${ARTIFACT_NAME}" \
+ "${RELEASE_FILES_DIR}/${ARTIFACT_NAME}.asc" \
+ "${RELEASE_FILES_DIR}/${ARTIFACT_NAME}.sha512"
+ fi
+fi
+
+cat <<EOF
+
+Apache Paimon C++ ${VERSION} has been published.
+
+Next steps:
+ 1. Verify the release appears under https://downloads.apache.org/paimon/.
+ 2. Wait at least 24 hours after publication before updating public download
+ or documentation pages and announcing the release.
+ 3. Remove superseded Paimon C++ releases from dist/release after confirming
+ they remain available from archive.apache.org.
+ 4. Update the project download and documentation pages.
+
+After completing the 24-hour wait, send the following announcement in plain
+text from an apache.org email address.
+
+To: dev@paimon.apache.org
+CC: announce@apache.org
+Subject: [ANNOUNCE][C++] Apache Paimon C++ ${VERSION} released
+
+The Apache Paimon community is pleased to announce the release of
+Apache Paimon C++ ${VERSION}.
+
+Source release:
+https://downloads.apache.org/paimon/${RELEASE_ID}/
+
+Release notes:
+https://github.com/apache/paimon-cpp/releases/tag/${RELEASE_TAG}
+
+Thanks to everyone who contributed to this release.
+EOF
diff --git a/scripts/releasing/release_rc.sh b/scripts/releasing/release_rc.sh
new file mode 100755
index 0000000..6b5f405
--- /dev/null
+++ b/scripts/releasing/release_rc.sh
@@ -0,0 +1,339 @@
+#!/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 -euo pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+SOURCE_ROOT=$(cd "${SCRIPT_DIR}/../.." && pwd)
+
+VERSION=""
+RC=""
+SIGNING_KEY=""
+REMOTE="origin"
+RELEASE_BRANCH="main"
+OUTPUT_DIR=""
+DIST_DEV_BASE_URL="https://dist.apache.org/repos/dist/dev/paimon"
+PREPARE_ONLY=false
+DRY_RUN=false
+WORKFLOW_DISCOVERY_TIMEOUT_SECONDS=600
+
+usage() {
+ cat <<'EOF'
+Create, verify, and stage an Apache Paimon C++ release candidate.
+
+Usage:
+ release_rc.sh --version VERSION --rc RC --signing-key KEY_ID [options]
+
+Required:
+ --version VERSION Release version, for example 0.2.3
+ --rc RC Release candidate number, for example 1
+ --signing-key KEY_ID OpenPGP key used for the tag and source artifact
+
+Options:
+ --remote REMOTE Apache Git remote (default: origin)
+ --release-branch NAME Remote branch containing the RC commit (default: main)
+ --output-dir DIR Artifact directory (default: release/VERSION-rcRC)
+ --dist-dev-base URL ASF dist/dev project URL
+ --prepare-only Create and verify locally; do not push or upload
+ --dry-run Print the planned release identifiers and exit
+ -h, --help Show this help
+
+The script is resumable when the local signed tag or artifacts already exist,
+provided that they match HEAD and pass all verification checks.
+EOF
+}
+
+fail() {
+ echo "Error: $*" >&2
+ exit 1
+}
+
+require_command() {
+ command -v "$1" >/dev/null 2>&1 || fail "$1 is required"
+}
+
+validate_release_branch() {
+ local remote_branch_ref="refs/remotes/${REMOTE}/${RELEASE_BRANCH}"
+ local release_branch_commit
+
+ git check-ref-format "refs/heads/${RELEASE_BRANCH}" >/dev/null 2>&1 ||
+ fail "invalid release branch name: ${RELEASE_BRANCH}"
+
+ echo "Fetching ${REMOTE}/${RELEASE_BRANCH} before publishing the RC."
+ git fetch --no-tags "${REMOTE}" \
+ "refs/heads/${RELEASE_BRANCH}:${remote_branch_ref}"
+ release_branch_commit=$(git rev-parse --verify "${remote_branch_ref}^{commit}")
+ git merge-base --is-ancestor "${HEAD_COMMIT}" "${release_branch_commit}" ||
+ fail "HEAD ${HEAD_COMMIT} is not contained in ${REMOTE}/${RELEASE_BRANCH} (${release_branch_commit})"
+}
+
+wait_for_release_candidate_workflow() {
+ local deadline=$((SECONDS + WORKFLOW_DISCOVERY_TIMEOUT_SECONDS))
+ local run_id=""
+
+ echo "Waiting for the ${RC_TAG} Release Candidate workflow to start."
+ while [[ -z "${run_id}" ]]; do
+ if ! run_id=$(
+ gh run list \
+ --repo apache/paimon-cpp \
+ --workflow release_candidate.yaml \
+ --branch "${RC_TAG}" \
+ --commit "${HEAD_COMMIT}" \
+ --event push \
+ --limit 1 \
+ --json databaseId \
+ --jq '.[0].databaseId // empty'
+ ); then
+ fail "unable to query the Release Candidate workflow for ${RC_TAG}"
+ fi
+ if [[ -n "${run_id}" ]]; then
+ break
+ fi
+ if ((SECONDS >= deadline)); then
+ fail "timed out waiting for the Release Candidate workflow for ${RC_TAG}"
+ fi
+ sleep 10
+ done
+
+ echo "Waiting for Release Candidate workflow run ${run_id} to succeed."
+ gh run watch "${run_id}" \
+ --repo apache/paimon-cpp \
+ --compact \
+ --exit-status \
+ --interval 30 ||
+ fail "Release Candidate workflow run ${run_id} failed"
+}
+
+validate_artifact_directory() {
+ local -a entries
+ local entry
+ local name
+
+ shopt -s dotglob nullglob
+ entries=("${OUTPUT_DIR}"/*)
+ shopt -u dotglob nullglob
+ [[ ${#entries[@]} -eq 3 ]] ||
+ fail "${OUTPUT_DIR} must contain exactly the archive, signature, and checksum"
+ for entry in "${entries[@]}"; do
+ [[ -f "${entry}" ]] ||
+ fail "release candidate contains a non-file entry: ${entry}"
+ name=$(basename "${entry}")
+ case "${name}" in
+ "${ARTIFACT_NAME}" | \
+ "${ARTIFACT_NAME}.asc" | \
+ "${ARTIFACT_NAME}.sha512")
+ ;;
+ *)
+ fail "release candidate contains an unexpected file: ${name}"
+ ;;
+ esac
+ done
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version)
+ [[ $# -ge 2 ]] || fail "--version requires a value"
+ VERSION=$2
+ shift 2
+ ;;
+ --rc)
+ [[ $# -ge 2 ]] || fail "--rc requires a value"
+ RC=$2
+ shift 2
+ ;;
+ --signing-key)
+ [[ $# -ge 2 ]] || fail "--signing-key requires a value"
+ SIGNING_KEY=$2
+ shift 2
+ ;;
+ --remote)
+ [[ $# -ge 2 ]] || fail "--remote requires a value"
+ REMOTE=$2
+ shift 2
+ ;;
+ --release-branch)
+ [[ $# -ge 2 ]] || fail "--release-branch requires a value"
+ RELEASE_BRANCH=$2
+ shift 2
+ ;;
+ --output-dir)
+ [[ $# -ge 2 ]] || fail "--output-dir requires a value"
+ OUTPUT_DIR=$2
+ shift 2
+ ;;
+ --dist-dev-base)
+ [[ $# -ge 2 ]] || fail "--dist-dev-base requires a value"
+ DIST_DEV_BASE_URL=${2%/}
+ shift 2
+ ;;
+ --prepare-only)
+ PREPARE_ONLY=true
+ shift
+ ;;
+ --dry-run)
+ DRY_RUN=true
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ fail "unknown argument: $1"
+ ;;
+ esac
+done
+
+[[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] ||
+ fail "--version must use MAJOR.MINOR.PATCH format"
+[[ "${RC}" =~ ^[0-9]+$ ]] || fail "--rc must be a non-negative integer"
+[[ -n "${SIGNING_KEY}" ]] || fail "--signing-key is required"
+
+RC_TAG="v${VERSION}-rc${RC}"
+RC_ID="paimon-cpp-${VERSION}-rc${RC}"
+ARTIFACT_NAME="apache-paimon-cpp-${VERSION}-src.tgz"
+RC_URL="${DIST_DEV_BASE_URL}/${RC_ID}"
+OUTPUT_DIR=${OUTPUT_DIR:-"${SOURCE_ROOT}/release/${VERSION}-rc${RC}"}
+
+if [[ "${DRY_RUN}" == true ]]; then
+ cat <<EOF
+Release candidate tag: ${RC_TAG}
+Artifact: ${ARTIFACT_NAME}
+Output directory: ${OUTPUT_DIR}
+ASF staging URL: ${RC_URL}
+Git remote: ${REMOTE}
+Release branch: ${RELEASE_BRANCH}
+Prepare only: ${PREPARE_ONLY}
+EOF
+ exit 0
+fi
+
+for command in git gpg python3; do
+ require_command "${command}"
+done
+if [[ "${PREPARE_ONLY}" == false ]]; then
+ require_command gh
+ require_command svn
+fi
+
+cd "${SOURCE_ROOT}"
+[[ -z "$(git status --porcelain)" ]] ||
+ fail "working tree must be clean before creating a release candidate"
+
+REMOTE_URL=$(git remote get-url "${REMOTE}" 2>/dev/null) ||
+ fail "Git remote does not exist: ${REMOTE}"
+case "${REMOTE_URL}" in
+ git@github.com:apache/paimon-cpp.git | \
+ https://github.com/apache/paimon-cpp | \
+ https://github.com/apache/paimon-cpp.git | \
+ ssh://git@github.com/apache/paimon-cpp.git)
+ ;;
+ *)
+ fail "${REMOTE} must point to apache/paimon-cpp, found ${REMOTE_URL}"
+ ;;
+esac
+
+"${SCRIPT_DIR}/bump_version.py" --check "${VERSION}"
+
+HEAD_COMMIT=$(git rev-parse HEAD)
+if [[ "${PREPARE_ONLY}" == false ]]; then
+ validate_release_branch
+fi
+
+if git rev-parse --verify "${RC_TAG}^{tag}" >/dev/null 2>&1; then
+ TAG_COMMIT=$(git rev-parse "${RC_TAG}^{commit}")
+ [[ "${TAG_COMMIT}" == "${HEAD_COMMIT}" ]] ||
+ fail "${RC_TAG} points to ${TAG_COMMIT}, expected HEAD ${HEAD_COMMIT}"
+ git verify-tag "${RC_TAG}"
+ echo "Reusing verified local tag ${RC_TAG}."
+else
+ git tag -s -u "${SIGNING_KEY}" -m "Apache Paimon C++ ${VERSION} RC${RC}" \
+ "${RC_TAG}"
+ git verify-tag "${RC_TAG}"
+fi
+
+ARTIFACT="${OUTPUT_DIR}/${ARTIFACT_NAME}"
+if [[ -e "${ARTIFACT}" || -e "${ARTIFACT}.asc" || -e "${ARTIFACT}.sha512" ]]; then
+ [[ -f "${ARTIFACT}" && -f "${ARTIFACT}.asc" && -f "${ARTIFACT}.sha512" ]] ||
+ fail "artifact directory contains an incomplete release candidate"
+ echo "Reusing existing artifacts in ${OUTPUT_DIR}."
+else
+ "${SCRIPT_DIR}/create_source_release.sh" \
+ --version "${VERSION}" \
+ --git-ref "${RC_TAG}" \
+ --output-dir "${OUTPUT_DIR}" \
+ --signing-key "${SIGNING_KEY}"
+fi
+
+"${SCRIPT_DIR}/verify_release_candidate.sh" \
+ --git-ref "${RC_TAG}" \
+ --keys-url "https://downloads.apache.org/paimon/KEYS" \
+ "${ARTIFACT}"
+
+validate_artifact_directory
+
+if [[ "${PREPARE_ONLY}" == true ]]; then
+ cat <<EOF
+
+Local release preparation completed successfully.
+
+The signed tag and source artifacts were created and verified locally.
+No tag was pushed and no artifacts were uploaded to ASF dist/dev.
+Do not start a release vote from this prepare-only run.
+EOF
+ exit 0
+fi
+
+if svn info "${RC_URL}" >/dev/null 2>&1; then
+ fail "release candidate already exists in ASF dist/dev: ${RC_URL}"
+fi
+git push "${REMOTE}" "${RC_TAG}"
+wait_for_release_candidate_workflow
+svn import "${OUTPUT_DIR}" "${RC_URL}" \
+ -m "Add Apache Paimon C++ ${VERSION} RC${RC}"
+
+cat <<EOF
+
+Release candidate staged successfully.
+
+To: dev@paimon.apache.org
+Subject: [VOTE][C++] Release Apache Paimon C++ ${VERSION} RC${RC}
+
+Hi everyone,
+
+Please review and vote on Apache Paimon C++ ${VERSION} RC${RC}.
+
+The release candidate is based on commit:
+${HEAD_COMMIT}
+
+Source artifacts:
+${RC_URL}/
+
+Git tag:
+https://github.com/apache/paimon-cpp/releases/tag/${RC_TAG}
+
+KEYS:
+https://downloads.apache.org/paimon/KEYS
+
+The vote will remain open for at least 72 hours.
+
+[ ] +1 Approve the release
+[ ] +0 No opinion
+[ ] -1 Do not approve, because...
+EOF
diff --git a/scripts/releasing/tests/test_release_tools.py b/scripts/releasing/tests/test_release_tools.py
new file mode 100644
index 0000000..e3dbdac
--- /dev/null
+++ b/scripts/releasing/tests/test_release_tools.py
@@ -0,0 +1,295 @@
+#!/usr/bin/env python3
+#
+# 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.
+
+import hashlib
+import io
+import json
+import subprocess
+import sys
+import tarfile
+import tempfile
+import unittest
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+
+RELEASING_DIR = Path(__file__).resolve().parents[1]
+ARCHIVE_VALIDATOR = RELEASING_DIR / "validate_source_archive.py"
+VERSION_TOOL = RELEASING_DIR / "bump_version.py"
+RELEASE_VERIFIER = RELEASING_DIR / "verify_release_candidate.sh"
+
+
+class ReleaseToolTest(unittest.TestCase):
+ def run_tool(
+ self, tool: Path, *args: str, expected_returncode: int = 0
+ ) -> subprocess.CompletedProcess:
+ result = subprocess.run(
+ [sys.executable, str(tool), *args],
+ universal_newlines=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ )
+ self.assertEqual(
+ result.returncode,
+ expected_returncode,
+ msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
+ )
+ return result
+
+ def create_archive(
+ self,
+ path: Path,
+ *,
+ extra_members: Optional[List[Tuple[tarfile.TarInfo, bytes]]] = None,
+ ) -> None:
+ with tarfile.open(path, mode="w:gz") as archive:
+ root = tarfile.TarInfo("paimon-cpp-1.2.3/")
+ root.type = tarfile.DIRTYPE
+ root.mode = 0o755
+ archive.addfile(root)
+
+ license_info = tarfile.TarInfo("paimon-cpp-1.2.3/LICENSE")
+ license_info.size = len(b"Apache License\n")
+ license_info.mode = 0o644
+ archive.addfile(license_info, io.BytesIO(b"Apache License\n"))
+
+ for member, content in extra_members or []:
+ member.size = len(content) if member.isfile() else 0
+ archive.addfile(member, io.BytesIO(content) if member.isfile() else None)
+
+ def create_verifier_archive(self, directory: Path) -> Path:
+ artifact = directory / "apache-paimon-cpp-1.2.3-src.tgz"
+ files = {
+ "LICENSE": b"Apache License\n",
+ "NOTICE": b"Apache Paimon\n",
+ "CMakeLists.txt": (
+ b"project(paimon\n"
+ b" VERSION 1.2.3\n"
+ b' DESCRIPTION "Paimon C++ Project")\n'
+ ),
+ "docs/source/conf.py": b'version = "1.2.3"\n',
+ "docs/source/_static/versions.json": (
+ b'[{"name": "1.2.3", "version": "1.2.3", '
+ b'"url": "https://paimon.apache.org/docs/cpp/"}]\n'
+ ),
+ ".github/.rat-excludes": b"",
+ "scripts/releasing/create_source_release.sh": b"#!/usr/bin/env bash\n",
+ }
+ with tarfile.open(artifact, mode="w:gz") as archive:
+ root = tarfile.TarInfo("paimon-cpp-1.2.3/")
+ root.type = tarfile.DIRTYPE
+ root.mode = 0o755
+ archive.addfile(root)
+ for name, content in files.items():
+ member = tarfile.TarInfo(f"paimon-cpp-1.2.3/{name}")
+ member.size = len(content)
+ member.mode = 0o755 if name.endswith(".sh") else 0o644
+ archive.addfile(member, io.BytesIO(content))
+
+ digest = hashlib.sha512(artifact.read_bytes()).hexdigest()
+ artifact.with_suffix(artifact.suffix + ".sha512").write_text(
+ f"{digest} {artifact.name}\n", encoding="utf-8"
+ )
+ return artifact
+
+ def run_verifier(
+ self, artifact: Path, *, expected_returncode: int = 0
+ ) -> subprocess.CompletedProcess:
+ result = subprocess.run(
+ [
+ "bash",
+ str(RELEASE_VERIFIER),
+ "--allow-unsigned",
+ "--skip-rat",
+ "--skip-build",
+ str(artifact),
+ ],
+ universal_newlines=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ )
+ self.assertEqual(
+ result.returncode,
+ expected_returncode,
+ msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
+ )
+ return result
+
+ def test_archive_validator_accepts_regular_archive(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ artifact = Path(temp) / "valid.tgz"
+ self.create_archive(artifact)
+ self.run_tool(
+ ARCHIVE_VALIDATOR,
+ "--expected-root",
+ "paimon-cpp-1.2.3",
+ str(artifact),
+ )
+
+ def test_archive_validator_rejects_path_traversal(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ artifact = Path(temp) / "traversal.tgz"
+ member = tarfile.TarInfo("../outside")
+ member.mode = 0o644
+ self.create_archive(artifact, extra_members=[(member, b"bad")])
+ self.run_tool(
+ ARCHIVE_VALIDATOR,
+ "--expected-root",
+ "paimon-cpp-1.2.3",
+ str(artifact),
+ expected_returncode=1,
+ )
+
+ def test_archive_validator_rejects_symlink(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ artifact = Path(temp) / "symlink.tgz"
+ member = tarfile.TarInfo("paimon-cpp-1.2.3/link")
+ member.type = tarfile.SYMTYPE
+ member.linkname = "../../outside"
+ member.mode = 0o777
+ self.create_archive(artifact, extra_members=[(member, b"")])
+ self.run_tool(
+ ARCHIVE_VALIDATOR,
+ "--expected-root",
+ "paimon-cpp-1.2.3",
+ str(artifact),
+ expected_returncode=1,
+ )
+
+ def test_archive_validator_rejects_compiled_magic(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ artifact = Path(temp) / "binary.tgz"
+ member = tarfile.TarInfo("paimon-cpp-1.2.3/generated")
+ member.mode = 0o755
+ self.create_archive(
+ artifact, extra_members=[(member, b"\x7fELFcompiled")]
+ )
+ self.run_tool(
+ ARCHIVE_VALIDATOR,
+ "--expected-root",
+ "paimon-cpp-1.2.3",
+ str(artifact),
+ expected_returncode=1,
+ )
+
+ def test_archive_validator_rejects_portable_collision(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ artifact = Path(temp) / "collision.tgz"
+ first = tarfile.TarInfo("paimon-cpp-1.2.3/README")
+ first.mode = 0o644
+ second = tarfile.TarInfo("paimon-cpp-1.2.3/readme")
+ second.mode = 0o644
+ self.create_archive(
+ artifact,
+ extra_members=[(first, b"one"), (second, b"two")],
+ )
+ self.run_tool(
+ ARCHIVE_VALIDATOR,
+ "--expected-root",
+ "paimon-cpp-1.2.3",
+ str(artifact),
+ expected_returncode=1,
+ )
+
+ def test_verifier_accepts_valid_checksum_and_archive(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ artifact = self.create_verifier_archive(Path(temp))
+ result = self.run_verifier(artifact)
+ self.assertIn("Release candidate verification completed", result.stdout)
+
+ def test_verifier_rejects_checksum_mismatch(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ artifact = self.create_verifier_archive(Path(temp))
+ checksum = artifact.with_suffix(artifact.suffix + ".sha512")
+ checksum.write_text(f"{'0' * 128} {artifact.name}\n", encoding="utf-8")
+ result = self.run_verifier(artifact, expected_returncode=1)
+ self.assertIn("SHA-512 checksum does not match", result.stderr)
+
+ def test_verifier_rejects_multiple_checksum_lines(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ artifact = self.create_verifier_archive(Path(temp))
+ checksum = artifact.with_suffix(artifact.suffix + ".sha512")
+ checksum.write_text(
+ checksum.read_text(encoding="utf-8")
+ + f"{'0' * 128} attacker-controlled-file\n",
+ encoding="utf-8",
+ )
+ result = self.run_verifier(artifact, expected_returncode=1)
+ self.assertIn(
+ "checksum file must contain exactly one non-empty line",
+ result.stderr,
+ )
+
+ def create_version_tree(self, root: Path) -> None:
+ (root / "docs/source/_static").mkdir(parents=True)
+ (root / "CMakeLists.txt").write_text(
+ "project(paimon\n VERSION 1.2.3\n"
+ ' DESCRIPTION "Paimon C++ Project")\n',
+ encoding="utf-8",
+ )
+ (root / "docs/source/conf.py").write_text(
+ 'version = "1.2.3"\n', encoding="utf-8"
+ )
+ (root / "docs/source/_static/versions.json").write_text(
+ json.dumps(
+ [
+ {
+ "name": "1.2.3",
+ "version": "1.2.3",
+ "url": "https://paimon.apache.org/docs/cpp/",
+ }
+ ],
+ indent=4,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ def test_version_tool_checks_and_updates_all_metadata(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ self.create_version_tree(root)
+ self.run_tool(VERSION_TOOL, "--root", str(root), "--check", "1.2.3")
+ self.run_tool(VERSION_TOOL, "--root", str(root), "1.2.3", "1.2.4")
+ self.run_tool(VERSION_TOOL, "--root", str(root), "--check", "1.2.4")
+ self.assertIn("VERSION 1.2.4", (root / "CMakeLists.txt").read_text())
+ self.assertIn(
+ 'version = "1.2.4"', (root / "docs/source/conf.py").read_text()
+ )
+
+ def test_version_tool_rejects_inconsistent_metadata(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ self.create_version_tree(root)
+ (root / "docs/source/conf.py").write_text(
+ 'version = "9.9.9"\n', encoding="utf-8"
+ )
+ self.run_tool(
+ VERSION_TOOL,
+ "--root",
+ str(root),
+ "--check",
+ "1.2.3",
+ expected_returncode=1,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/releasing/validate_source_archive.py b/scripts/releasing/validate_source_archive.py
new file mode 100755
index 0000000..a646453
--- /dev/null
+++ b/scripts/releasing/validate_source_archive.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+#
+# 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.
+
+"""Validate the portable and security-sensitive layout of a source archive."""
+
+import argparse
+import stat
+import sys
+import tarfile
+import unicodedata
+from pathlib import PurePosixPath
+from typing import Optional, Set
+
+
+COMPILED_MAGICS = {
+ b"\x00asm": "WebAssembly module",
+ b"\x7fELF": "ELF binary",
+ b"BC\xc0\xde": "LLVM bitcode",
+ b"\xca\xfe\xba\xbe": "Java class or Mach-O universal binary",
+ b"\xce\xfa\xed\xfe": "Mach-O binary",
+ b"\xcf\xfa\xed\xfe": "Mach-O binary",
+ b"\xfe\xed\xfa\xce": "Mach-O binary",
+ b"\xfe\xed\xfa\xcf": "Mach-O binary",
+}
+
+
+class ValidationError(RuntimeError):
+ """The archive does not satisfy release safety requirements."""
+
+
+def require(condition: bool, message: str) -> None:
+ if not condition:
+ raise ValidationError(message)
+
+
+def normalized_name(member: tarfile.TarInfo) -> str:
+ value = member.name
+ require(bool(value), "archive contains an empty path")
+ require("\\" not in value, f"archive path contains a backslash: {value!r}")
+ candidate = value[:-1] if member.isdir() and value.endswith("/") else value
+ path = PurePosixPath(candidate)
+ require(not path.is_absolute(), f"archive contains an absolute path: {value!r}")
+ require(".." not in path.parts, f"archive contains path traversal: {value!r}")
+ require(path.as_posix() == candidate, f"archive path is not canonical: {value!r}")
+ return path.as_posix()
+
+
+def portable_key(name: str) -> str:
+ return unicodedata.normalize("NFC", name).casefold()
+
+
+def compiled_description(
+ archive: tarfile.TarFile, member: tarfile.TarInfo
+) -> Optional[str]:
+ extracted = archive.extractfile(member)
+ if extracted is None:
+ raise ValidationError(f"cannot read archive member: {member.name}")
+ header = extracted.read(4096)
+ for magic, description in COMPILED_MAGICS.items():
+ if header.startswith(magic):
+ return description
+ if header.startswith(b"!<arch>\n"):
+ return "Unix archive"
+ if header.startswith(b"MZ") and len(header) >= 64:
+ pe_offset = int.from_bytes(header[60:64], "little")
+ if pe_offset + 4 <= len(header) and header[pe_offset : pe_offset + 4] == b"PE\0\0":
+ return "Windows PE binary"
+ return None
+
+
+def validate(artifact: str, expected_root: str) -> None:
+ expected_root = expected_root.rstrip("/")
+ require(bool(expected_root), "expected root must not be empty")
+ require("/" not in expected_root, "expected root must be one path component")
+
+ try:
+ archive = tarfile.open(artifact, mode="r:gz")
+ except (OSError, tarfile.TarError) as error:
+ raise ValidationError(f"cannot open source archive: {error}") from error
+
+ with archive:
+ members = archive.getmembers()
+ require(bool(members), "source archive is empty")
+ names: Set[str] = set()
+ portable_names: Set[str] = set()
+ found_root = False
+
+ for member in members:
+ require(
+ member.isfile() or member.isdir(),
+ f"archive contains a link or special member: {member.name!r}",
+ )
+ name = normalized_name(member)
+ parts = PurePosixPath(name).parts
+ require(
+ bool(parts) and parts[0] == expected_root,
+ f"archive entry is outside {expected_root}/: {member.name!r}",
+ )
+ if name == expected_root and member.isdir():
+ found_root = True
+
+ require(name not in names, f"archive contains duplicate path: {name!r}")
+ names.add(name)
+ key = portable_key(name)
+ require(
+ key not in portable_names,
+ f"archive contains a portable path collision: {name!r}",
+ )
+ portable_names.add(key)
+
+ require(
+ member.mode & (stat.S_IWGRP | stat.S_IWOTH) == 0,
+ f"archive contains a group- or world-writable member: {name!r}",
+ )
+ require(
+ member.mode & (stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX) == 0,
+ f"archive contains a member with special permission bits: {name!r}",
+ )
+
+ if member.isfile():
+ description = compiled_description(archive, member)
+ require(
+ description is None,
+ f"source archive contains a compiled file ({description}): {name}",
+ )
+
+ require(found_root, f"archive root directory is missing: {expected_root}/")
+ print(f"Validated {len(members)} archive members under {expected_root}/")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("artifact", help="source .tar.gz or .tgz archive")
+ parser.add_argument("--expected-root", required=True)
+ args = parser.parse_args()
+ try:
+ validate(args.artifact, args.expected_root)
+ except ValidationError as error:
+ print(f"Archive validation failed: {error}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/releasing/verify_release_candidate.sh b/scripts/releasing/verify_release_candidate.sh
index cf6660d..dee7d81 100755
--- a/scripts/releasing/verify_release_candidate.sh
+++ b/scripts/releasing/verify_release_candidate.sh
@@ -10,20 +10,31 @@
#
# 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.
+# 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 -euo pipefail
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+SOURCE_ROOT=$(cd "${SCRIPT_DIR}/../.." && pwd)
+
ARTIFACT=""
+REQUESTED_VERSION=""
+RC=""
+DIST_DEV_BASE_URL="https://dist.apache.org/repos/dist/dev/paimon"
+KEYS_URL=""
+KEYS_FILE=""
+GIT_REF=""
RAT_JAR=${RAT_JAR:-}
+RAT_VERSION="0.16.1"
ALLOW_UNSIGNED=false
SKIP_RAT=false
SKIP_BUILD=false
+SKIP_INSTALL=false
+JOBS=${PAIMON_BUILD_JOBS:-}
usage() {
cat <<'EOF'
@@ -31,22 +42,31 @@
Usage:
verify_release_candidate.sh [options] ARTIFACT
+ verify_release_candidate.sh --version VERSION --rc RC [options]
-Options:
- --rat-jar FILE Apache RAT executable jar (or set RAT_JAR)
- --allow-unsigned Allow a missing .asc file for local preparation only
- --skip-rat Skip Apache RAT for local preparation only
- --skip-build Skip the release build and test suite
- -h, --help Show this help
+Download options:
+ --version VERSION Release version to download
+ --rc RC Release candidate number to download
+ --dist-dev-base URL ASF dist/dev project URL
-By default the script requires:
- ARTIFACT.sha512
- ARTIFACT.asc
- an Apache RAT jar
+Trust and reproducibility:
+ --keys-url URL Download KEYS and verify in an isolated GPG home
+ --keys-file FILE Import this KEYS file into an isolated GPG home
+ --git-ref REF Regenerate the archive from REF and compare bytes
-It verifies the checksum and signature, inspects and extracts the archive,
-checks release metadata, runs Apache RAT, then builds and tests from the
-extracted source distribution.
+Verification options:
+ --rat-jar FILE Apache RAT executable jar (or set RAT_JAR)
+ --rat-version VERSION RAT version to download (default: 0.16.1)
+ --jobs JOBS Parallel build and test jobs
+ --allow-unsigned Allow a missing .asc file for local/CI preparation
+ --skip-rat Skip Apache RAT for local preparation only
+ --skip-build Skip the release build, tests, and install smoke test
+ --skip-install Skip only the install and consumer smoke test
+ -h, --help Show this help
+
+Download mode defaults to https://downloads.apache.org/paimon/KEYS. For a
+local artifact, pass --keys-url or --keys-file to avoid trusting the user's
+default GPG keyring.
EOF
}
@@ -66,13 +86,66 @@
fi
}
+download_file() {
+ local url=$1
+ local destination=$2
+ if command -v curl >/dev/null 2>&1; then
+ curl --fail --location --show-error --silent \
+ --output "${destination}" "${url}"
+ elif command -v wget >/dev/null 2>&1; then
+ wget --quiet --output-document="${destination}" "${url}"
+ else
+ fail "curl or wget is required to download release files"
+ fi
+}
+
while [[ $# -gt 0 ]]; do
case "$1" in
+ --version)
+ [[ $# -ge 2 ]] || fail "--version requires a value"
+ REQUESTED_VERSION=$2
+ shift 2
+ ;;
+ --rc)
+ [[ $# -ge 2 ]] || fail "--rc requires a value"
+ RC=$2
+ shift 2
+ ;;
+ --dist-dev-base)
+ [[ $# -ge 2 ]] || fail "--dist-dev-base requires a value"
+ DIST_DEV_BASE_URL=${2%/}
+ shift 2
+ ;;
+ --keys-url)
+ [[ $# -ge 2 ]] || fail "--keys-url requires a value"
+ KEYS_URL=$2
+ shift 2
+ ;;
+ --keys-file)
+ [[ $# -ge 2 ]] || fail "--keys-file requires a value"
+ KEYS_FILE=$2
+ shift 2
+ ;;
+ --git-ref)
+ [[ $# -ge 2 ]] || fail "--git-ref requires a value"
+ GIT_REF=$2
+ shift 2
+ ;;
--rat-jar)
[[ $# -ge 2 ]] || fail "--rat-jar requires a value"
RAT_JAR=$2
shift 2
;;
+ --rat-version)
+ [[ $# -ge 2 ]] || fail "--rat-version requires a value"
+ RAT_VERSION=$2
+ shift 2
+ ;;
+ --jobs)
+ [[ $# -ge 2 ]] || fail "--jobs requires a value"
+ JOBS=$2
+ shift 2
+ ;;
--allow-unsigned)
ALLOW_UNSIGNED=true
shift
@@ -85,6 +158,10 @@
SKIP_BUILD=true
shift
;;
+ --skip-install)
+ SKIP_INSTALL=true
+ shift
+ ;;
-h|--help)
usage
exit 0
@@ -100,9 +177,39 @@
esac
done
-[[ -n "${ARTIFACT}" ]] || fail "an artifact is required"
-[[ -f "${ARTIFACT}" ]] || fail "artifact does not exist: ${ARTIFACT}"
+[[ -z "${KEYS_URL}" || -z "${KEYS_FILE}" ]] ||
+ fail "--keys-url and --keys-file are mutually exclusive"
+[[ -z "${REQUESTED_VERSION}" ||
+ "${REQUESTED_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] ||
+ fail "--version must use MAJOR.MINOR.PATCH format"
+[[ -z "${RC}" || "${RC}" =~ ^[0-9]+$ ]] ||
+ fail "--rc must be a non-negative integer"
+[[ -z "${JOBS}" || "${JOBS}" =~ ^[1-9][0-9]*$ ]] ||
+ fail "--jobs must be a positive integer"
+TEMP_DIR=$(mktemp -d)
+trap 'rm -rf "${TEMP_DIR}"' EXIT
+
+if [[ -z "${ARTIFACT}" ]]; then
+ [[ -n "${REQUESTED_VERSION}" && -n "${RC}" ]] ||
+ fail "ARTIFACT or both --version and --rc are required"
+ ARTIFACT_NAME="apache-paimon-cpp-${REQUESTED_VERSION}-src.tgz"
+ RC_URL="${DIST_DEV_BASE_URL}/paimon-cpp-${REQUESTED_VERSION}-rc${RC}"
+ DOWNLOAD_DIR="${TEMP_DIR}/download"
+ mkdir -p "${DOWNLOAD_DIR}"
+ echo "Downloading Apache Paimon C++ ${REQUESTED_VERSION} RC${RC}..."
+ download_file "${RC_URL}/${ARTIFACT_NAME}" "${DOWNLOAD_DIR}/${ARTIFACT_NAME}"
+ download_file "${RC_URL}/${ARTIFACT_NAME}.sha512" \
+ "${DOWNLOAD_DIR}/${ARTIFACT_NAME}.sha512"
+ if [[ "${ALLOW_UNSIGNED}" == false ]]; then
+ download_file "${RC_URL}/${ARTIFACT_NAME}.asc" \
+ "${DOWNLOAD_DIR}/${ARTIFACT_NAME}.asc"
+ fi
+ ARTIFACT="${DOWNLOAD_DIR}/${ARTIFACT_NAME}"
+ KEYS_URL=${KEYS_URL:-"https://downloads.apache.org/paimon/KEYS"}
+fi
+
+[[ -f "${ARTIFACT}" ]] || fail "artifact does not exist: ${ARTIFACT}"
ARTIFACT_DIR=$(cd "$(dirname "${ARTIFACT}")" && pwd)
ARTIFACT_NAME=$(basename "${ARTIFACT}")
ARTIFACT="${ARTIFACT_DIR}/${ARTIFACT_NAME}"
@@ -112,18 +219,26 @@
else
fail "unexpected artifact name: ${ARTIFACT_NAME}"
fi
+[[ -z "${REQUESTED_VERSION}" || "${REQUESTED_VERSION}" == "${RELEASE_VERSION}" ]] ||
+ fail "artifact version ${RELEASE_VERSION} does not match ${REQUESTED_VERSION}"
CHECKSUM_FILE="${ARTIFACT}.sha512"
SIGNATURE_FILE="${ARTIFACT}.asc"
ARCHIVE_ROOT="paimon-cpp-${RELEASE_VERSION}"
[[ -f "${CHECKSUM_FILE}" ]] || fail "missing checksum: ${CHECKSUM_FILE}"
-EXPECTED_SHA512=$(awk 'NR == 1 {print $1}' "${CHECKSUM_FILE}")
-CHECKSUM_ARTIFACT=$(awk 'NR == 1 {print $2}' "${CHECKSUM_FILE}")
+CHECKSUM_LINE_COUNT=$(awk 'NF { count++ } END { print count + 0 }' "${CHECKSUM_FILE}")
+[[ "${CHECKSUM_LINE_COUNT}" == "1" ]] ||
+ fail "checksum file must contain exactly one non-empty line"
+CHECKSUM_FIELD_COUNT=$(awk 'NF { print NF; exit }' "${CHECKSUM_FILE}")
+[[ "${CHECKSUM_FIELD_COUNT}" == "2" ]] ||
+ fail "checksum line must contain a digest and a filename"
+EXPECTED_SHA512=$(awk 'NF { print $1; exit }' "${CHECKSUM_FILE}")
+CHECKSUM_ARTIFACT=$(awk 'NF { print $2; exit }' "${CHECKSUM_FILE}")
[[ "${EXPECTED_SHA512}" =~ ^[0-9a-fA-F]{128}$ ]] ||
fail "invalid SHA-512 file: ${CHECKSUM_FILE}"
[[ "${CHECKSUM_ARTIFACT}" == "${ARTIFACT_NAME}" ]] ||
- fail "checksum file names ${CHECKSUM_ARTIFACT:-<missing>}, expected ${ARTIFACT_NAME}"
+ fail "checksum names ${CHECKSUM_ARTIFACT:-<missing>}, expected ${ARTIFACT_NAME}"
ACTUAL_SHA512=$(calculate_sha512 "${ARTIFACT}")
EXPECTED_SHA512=$(printf '%s' "${EXPECTED_SHA512}" | tr '[:upper:]' '[:lower:]')
ACTUAL_SHA512=$(printf '%s' "${ACTUAL_SHA512}" | tr '[:upper:]' '[:lower:]')
@@ -133,74 +248,90 @@
if [[ -f "${SIGNATURE_FILE}" ]]; then
command -v gpg >/dev/null 2>&1 || fail "gpg is required to verify the signature"
- gpg --verify "${SIGNATURE_FILE}" "${ARTIFACT}"
- echo "OpenPGP signature: valid"
+ if [[ -n "${KEYS_URL}" || -n "${KEYS_FILE}" ]]; then
+ GNUPG_HOME="${TEMP_DIR}/gnupg"
+ mkdir -m 700 "${GNUPG_HOME}"
+ if [[ -n "${KEYS_URL}" ]]; then
+ KEYS_FILE="${TEMP_DIR}/KEYS"
+ download_file "${KEYS_URL}" "${KEYS_FILE}"
+ else
+ KEYS_FILE=$(cd "$(dirname "${KEYS_FILE}")" && pwd)/$(basename "${KEYS_FILE}")
+ fi
+ [[ -f "${KEYS_FILE}" ]] || fail "KEYS file does not exist: ${KEYS_FILE}"
+ gpg --batch --homedir "${GNUPG_HOME}" --import "${KEYS_FILE}" >/dev/null
+ gpg --batch --homedir "${GNUPG_HOME}" \
+ --verify "${SIGNATURE_FILE}" "${ARTIFACT}"
+ echo "OpenPGP signature: valid against ${KEYS_FILE}"
+ else
+ gpg --verify "${SIGNATURE_FILE}" "${ARTIFACT}"
+ echo "OpenPGP signature: valid against the default GPG keyring"
+ fi
elif [[ "${ALLOW_UNSIGNED}" == true ]]; then
- echo "OpenPGP signature: skipped for local preparation"
+ echo "OpenPGP signature: skipped for local/CI preparation"
else
fail "missing signature: ${SIGNATURE_FILE}"
fi
-TEMP_DIR=$(mktemp -d)
-trap 'rm -rf "${TEMP_DIR}"' EXIT
-CONTENTS_FILE="${TEMP_DIR}/archive-contents.txt"
-
-tar -tzf "${ARTIFACT}" >"${CONTENTS_FILE}"
-[[ -s "${CONTENTS_FILE}" ]] || fail "source archive is empty"
-
-while IFS= read -r entry; do
- [[ "${entry}" != /* ]] || fail "archive contains an absolute path: ${entry}"
- [[ "${entry}" != ".." && "${entry}" != ../* && "${entry}" != */.. &&
- "${entry}" != *"/../"* ]] ||
- fail "archive contains path traversal: ${entry}"
- [[ "${entry}" == "${ARCHIVE_ROOT}" || "${entry}" == "${ARCHIVE_ROOT}/"* ]] ||
- fail "archive entry is outside ${ARCHIVE_ROOT}: ${entry}"
-done <"${CONTENTS_FILE}"
+command -v python3 >/dev/null 2>&1 || fail "python3 is required"
+python3 "${SCRIPT_DIR}/validate_source_archive.py" \
+ --expected-root "${ARCHIVE_ROOT}" "${ARTIFACT}"
tar -xzf "${ARTIFACT}" -C "${TEMP_DIR}"
SOURCE_DIR="${TEMP_DIR}/${ARCHIVE_ROOT}"
[[ -d "${SOURCE_DIR}" ]] || fail "archive root is missing: ${ARCHIVE_ROOT}"
-for required_file in LICENSE NOTICE CMakeLists.txt docs/source/conf.py; do
+for required_file in \
+ LICENSE \
+ NOTICE \
+ CMakeLists.txt \
+ docs/source/conf.py \
+ docs/source/_static/versions.json \
+ .github/.rat-excludes \
+ scripts/releasing/create_source_release.sh; do
[[ -f "${SOURCE_DIR}/${required_file}" ]] ||
fail "required release file is missing: ${required_file}"
done
-CMAKE_VERSION=$(
- sed -n 's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*$/\1/p' \
- "${SOURCE_DIR}/CMakeLists.txt" |
- head -n 1
-)
-[[ "${CMAKE_VERSION}" == "${RELEASE_VERSION}" ]] ||
- fail "CMake version ${CMAKE_VERSION:-<missing>} does not match ${RELEASE_VERSION}"
-
-DOCS_VERSION=$(
- sed -n 's/^version = "\([^"]*\)"$/\1/p' "${SOURCE_DIR}/docs/source/conf.py" |
- head -n 1
-)
-[[ "${DOCS_VERSION}" == "${RELEASE_VERSION}" ]] ||
- fail "documentation version ${DOCS_VERSION:-<missing>} does not match ${RELEASE_VERSION}"
+python3 "${SCRIPT_DIR}/bump_version.py" \
+ --root "${SOURCE_DIR}" \
+ --check "${RELEASE_VERSION}"
UNEXPECTED_BINARIES=$(
find "${SOURCE_DIR}" -type f \
- \( -name '*.a' -o -name '*.class' -o -name '*.dll' -o -name '*.dylib' \
- -o -name '*.exe' -o -name '*.jar' -o -name '*.lib' -o -name '*.o' \
- -o -name '*.pdb' -o -name '*.pyc' -o -name '*.so' \) \
+ \( -name '*.a' -o -name '*.bc' -o -name '*.class' -o -name '*.dll' \
+ -o -name '*.dylib' -o -name '*.exe' -o -name '*.jar' -o -name '*.la' \
+ -o -name '*.lib' -o -name '*.lo' -o -name '*.o' -o -name '*.obj' \
+ -o -name '*.pdb' -o -name '*.pyc' -o -name '*.so' -o -name '*.wasm' \) \
-print
)
[[ -z "${UNEXPECTED_BINARIES}" ]] ||
fail "source archive contains unexpected compiled files:${UNEXPECTED_BINARIES}"
-
-UNSAFE_PERMISSIONS=$(
- find "${SOURCE_DIR}" -type f \( -perm -0020 -o -perm -0002 \) -print
-)
-[[ -z "${UNSAFE_PERMISSIONS}" ]] ||
- fail "source archive contains group- or world-writable files:${UNSAFE_PERMISSIONS}"
-
echo "Archive layout and release metadata: valid"
+if [[ -n "${GIT_REF}" ]]; then
+ git -C "${SOURCE_ROOT}" rev-parse --verify "${GIT_REF}^{commit}" >/dev/null 2>&1 ||
+ fail "Git ref does not resolve to a commit: ${GIT_REF}"
+ if git -C "${SOURCE_ROOT}" rev-parse --verify "${GIT_REF}^{tag}" \
+ >/dev/null 2>&1; then
+ git -C "${SOURCE_ROOT}" verify-tag "${GIT_REF}"
+ fi
+ REPRO_DIR="${TEMP_DIR}/reproduced"
+ "${SCRIPT_DIR}/create_source_release.sh" \
+ --version "${RELEASE_VERSION}" \
+ --git-ref "${GIT_REF}" \
+ --output-dir "${REPRO_DIR}"
+ cmp "${ARTIFACT}" "${REPRO_DIR}/${ARTIFACT_NAME}" >/dev/null ||
+ fail "artifact bytes differ from a fresh archive of ${GIT_REF}"
+ echo "Git ref reproducibility: valid (${GIT_REF})"
+fi
+
if [[ "${SKIP_RAT}" == false ]]; then
- [[ -n "${RAT_JAR}" ]] || fail "--rat-jar or RAT_JAR is required"
+ if [[ -z "${RAT_JAR}" ]]; then
+ RAT_JAR="${TEMP_DIR}/apache-rat-${RAT_VERSION}.jar"
+ download_file \
+ "https://repo.maven.apache.org/maven2/org/apache/rat/apache-rat/${RAT_VERSION}/apache-rat-${RAT_VERSION}.jar" \
+ "${RAT_JAR}"
+ fi
[[ -f "${RAT_JAR}" ]] || fail "Apache RAT jar does not exist: ${RAT_JAR}"
RAT_REPORT="${TEMP_DIR}/rat-report.txt"
@@ -218,10 +349,21 @@
fi
if [[ "${SKIP_BUILD}" == false ]]; then
- "${SOURCE_DIR}/ci/scripts/build_paimon.sh" "${SOURCE_DIR}" false false Release
+ INSTALL_SMOKE=true
+ if [[ "${SKIP_INSTALL}" == true ]]; then
+ INSTALL_SMOKE=false
+ fi
+ PAIMON_BUILD_JOBS="${JOBS}" \
+ "${SOURCE_DIR}/ci/scripts/build_paimon.sh" \
+ "${SOURCE_DIR}" false false Release "${INSTALL_SMOKE}"
echo "Release build and tests: valid"
+ if [[ "${INSTALL_SMOKE}" == true ]]; then
+ echo "Install and consumer smoke test: valid"
+ else
+ echo "Install and consumer smoke test: skipped"
+ fi
else
- echo "Release build and tests: skipped"
+ echo "Release build, tests, and install smoke test: skipped"
fi
echo "Release candidate verification completed successfully."