This is an automated email from the ASF dual-hosted git repository.
SteNicholas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new c961ff2 fix(release): make source archives reproducible (#177)
c961ff2 is described below
commit c961ff222129e55e085c353b652632b112fb1d87
Author: Zhang Jiawei <[email protected]>
AuthorDate: Tue Aug 4 15:13:15 2026 +0800
fix(release): make source archives reproducible (#177)
---
.github/workflows/release_candidate.yaml | 4 +
scripts/releasing/README.md | 53 +++--
scripts/releasing/create_source_release.sh | 40 +++-
scripts/releasing/release_rc.sh | 126 ++++++++++--
scripts/releasing/tests/test_release_tools.py | 282 ++++++++++++++++++++++++++
scripts/releasing/verify_release_candidate.sh | 45 ++--
6 files changed, 495 insertions(+), 55 deletions(-)
diff --git a/.github/workflows/release_candidate.yaml
b/.github/workflows/release_candidate.yaml
index 28a4b09..859b257 100644
--- a/.github/workflows/release_candidate.yaml
+++ b/.github/workflows/release_candidate.yaml
@@ -78,9 +78,13 @@ jobs:
--output-dir release/ci
- name: Audit source archive
+ env:
+ # The creator must ignore caller-provided gzip defaults.
+ GZIP: "-9"
run: |
scripts/releasing/verify_release_candidate.sh \
--allow-unsigned \
+ --git-ref HEAD \
--skip-build \
"release/ci/apache-paimon-cpp-${RELEASE_VERSION}-src.tgz"
diff --git a/scripts/releasing/README.md b/scripts/releasing/README.md
index ee558c1..a251bed 100644
--- a/scripts/releasing/README.md
+++ b/scripts/releasing/README.md
@@ -34,9 +34,9 @@ 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);
+- install `git`, GNU `gzip`, `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
@@ -44,6 +44,15 @@ Before starting a release:
- prepare and merge a release-preparation PR that updates the release notes and
all version metadata, and passes the normal and release-candidate workflows.
+The source archive uses GNU gzip with fixed options so that macOS and Linux
+produce the same bytes. On macOS, install Homebrew gzip and either put it first
+on `PATH` or select it explicitly:
+
+```bash
+brew install gzip
+export PAIMON_GZIP="$(brew --prefix gzip)/bin/gzip"
+```
+
For example, update all version locations and review the diff:
```bash
@@ -80,10 +89,13 @@ The release scripts use `vVERSION-rcRC` for
release-candidate tags and
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`:
+its current history. It then creates and verifies a signed RC tag, pushes the
+tag, and waits for the tag-triggered release-candidate workflow. That workflow
+creates the canonical source archive and checksum, builds and tests the same
+archive with GCC and Clang, and uploads it as a workflow artifact. The wrapper
+downloads those exact bytes, confirms that they are reproducible from the tag,
+signs the archive locally, performs the full source-release verification, and
+imports the three files into ASF `dist/dev`:
```bash
scripts/releasing/release_rc.sh \
@@ -96,12 +108,13 @@ scripts/releasing/release_rc.sh \
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.
+Use `--prepare-only` to create and verify preview artifacts without pushing the
+tag or uploading to ASF infrastructure. This local-only mode does not require
+`HEAD` to match the remote release branch. A preview is not authoritative: a
+published run downloads the workflow artifact and rejects an existing local
+archive if its bytes differ. Use `--dry-run` to print identifiers without
+making changes. A prepare-only run does not print a vote email and must not be
+used to start a vote.
The candidate directory contains:
@@ -164,13 +177,16 @@ The verifier checks:
- installation plus compilation and execution of an external CMake consumer.
Pass `--git-ref v0.3.0-rc1` when the Git repository is available to regenerate
-the archive from the signed tag and compare it byte-for-byte.
+the archive from the signed tag with GNU gzip and compare it byte-for-byte.
+This check requires GNU gzip on every platform; it intentionally rejects the
+macOS system gzip instead of treating different compressed bytes as equivalent.
`--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.
+the unsigned canonical archive for deterministic CI validation. The release
+manager downloads and signs that exact archive; the private signing key remains
+only on the release manager's machine.
## Publish an approved release
@@ -207,8 +223,9 @@ than ASF's general one-hour minimum.
- `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.
+- `create_source_release.sh`: deterministically create an archive with GNU
+ gzip, a SHA-512 checksum, and an 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,
diff --git a/scripts/releasing/create_source_release.sh
b/scripts/releasing/create_source_release.sh
index 125ab57..67e4d09 100755
--- a/scripts/releasing/create_source_release.sh
+++ b/scripts/releasing/create_source_release.sh
@@ -49,6 +49,9 @@ The script creates:
apache-paimon-cpp-VERSION-src.tgz.asc (when --signing-key is provided)
Existing artifacts are never overwritten.
+
+GNU gzip is required so macOS and Linux produce the same compressed bytes.
+Set PAIMON_GZIP to an explicit GNU gzip executable when it is not on PATH.
EOF
}
@@ -68,6 +71,36 @@ calculate_sha512() {
fi
}
+find_gnu_gzip() {
+ local candidate
+ local resolved
+ local version_line
+ local -a candidates
+
+ if [[ -n "${PAIMON_GZIP:-}" ]]; then
+ candidates=("${PAIMON_GZIP}")
+ else
+ candidates=(gzip ggzip)
+ fi
+
+ for candidate in "${candidates[@]}"; do
+ if [[ -x "${candidate}" ]]; then
+ resolved=${candidate}
+ elif resolved=$(command -v "${candidate}" 2>/dev/null); then
+ :
+ else
+ continue
+ fi
+ version_line=$("${resolved}" --version 2>/dev/null | sed -n '1p' ||
true)
+ if [[ "${version_line}" =~ ^gzip[[:space:]][0-9] ]]; then
+ printf '%s\n' "${resolved}"
+ return 0
+ fi
+ done
+
+ fail "GNU gzip is required for reproducible source archives; on macOS run
'brew install gzip' and set PAIMON_GZIP to the Homebrew gzip executable"
+}
+
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
@@ -128,6 +161,8 @@ DOCS_VERSION=$(
[[ "${DOCS_VERSION}" == "${RELEASE_VERSION}" ]] ||
fail "documentation version ${DOCS_VERSION:-<missing>} does not match
${RELEASE_VERSION}"
+GZIP_BIN=$(find_gnu_gzip)
+
ARTIFACT_NAME="apache-paimon-cpp-${RELEASE_VERSION}-src.tgz"
ARCHIVE_ROOT="paimon-cpp-${RELEASE_VERSION}"
@@ -147,7 +182,10 @@ git -C "${SOURCE_ROOT}" -c tar.umask=0022 archive \
--format=tar \
--prefix="${ARCHIVE_ROOT}/" \
"${GIT_REF}" |
- gzip -n >"${TEMP_DIR}/${ARTIFACT_NAME}"
+ (
+ unset GZIP
+ "${GZIP_BIN}" --no-name --stdout -6
+ ) >"${TEMP_DIR}/${ARTIFACT_NAME}"
SHA512=$(calculate_sha512 "${TEMP_DIR}/${ARTIFACT_NAME}")
printf '%s %s\n' "${SHA512}" "${ARTIFACT_NAME}" \
diff --git a/scripts/releasing/release_rc.sh b/scripts/releasing/release_rc.sh
index f943491..3454863 100755
--- a/scripts/releasing/release_rc.sh
+++ b/scripts/releasing/release_rc.sh
@@ -31,6 +31,8 @@
DIST_DEV_BASE_URL="https://dist.apache.org/repos/dist/dev/paimon"
PREPARE_ONLY=false
DRY_RUN=false
WORKFLOW_DISCOVERY_TIMEOUT_SECONDS=600
+WORKFLOW_RUN_ID=""
+TEMP_DIR=""
usage() {
cat <<'EOF'
@@ -53,8 +55,9 @@ Options:
--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.
+For a published RC, GitHub Actions creates and tests the canonical source
+archive. This script downloads those exact bytes, signs them locally, and
+uploads them to ASF dist/dev. Prepare-only mode creates a local preview.
EOF
}
@@ -117,6 +120,74 @@ wait_for_release_candidate_workflow() {
--exit-status \
--interval 30 ||
fail "Release Candidate workflow run ${run_id} failed"
+ WORKFLOW_RUN_ID=${run_id}
+}
+
+validate_workflow_artifact_directory() {
+ local directory=$1
+ local -a entries
+ local entry
+ local name
+
+ shopt -s dotglob nullglob
+ entries=("${directory}"/*)
+ shopt -u dotglob nullglob
+ [[ ${#entries[@]} -eq 2 ]] ||
+ fail "workflow artifact must contain exactly the archive and checksum"
+ for entry in "${entries[@]}"; do
+ [[ -f "${entry}" ]] ||
+ fail "workflow artifact contains a non-file entry: ${entry}"
+ name=$(basename "${entry}")
+ case "${name}" in
+ "${ARTIFACT_NAME}" | "${ARTIFACT_NAME}.sha512")
+ ;;
+ *)
+ fail "workflow artifact contains an unexpected file: ${name}"
+ ;;
+ esac
+ done
+}
+
+download_and_sign_workflow_artifact() {
+ local workflow_dir="${TEMP_DIR}/source-archive"
+ local source
+ local target
+ local suffix
+
+ [[ -n "${WORKFLOW_RUN_ID}" ]] || fail "release workflow run ID is missing"
+ mkdir -p "${workflow_dir}"
+ gh run download "${WORKFLOW_RUN_ID}" \
+ --repo apache/paimon-cpp \
+ --name source-archive \
+ --dir "${workflow_dir}"
+ validate_workflow_artifact_directory "${workflow_dir}"
+
+ mkdir -p "${OUTPUT_DIR}"
+ for suffix in "" ".sha512"; do
+ source="${workflow_dir}/${ARTIFACT_NAME}${suffix}"
+ target="${OUTPUT_DIR}/${ARTIFACT_NAME}${suffix}"
+ if [[ -e "${target}" ]]; then
+ [[ -f "${target}" ]] || fail "artifact path is not a file:
${target}"
+ cmp "${source}" "${target}" >/dev/null ||
+ fail "existing ${target} differs from workflow run
${WORKFLOW_RUN_ID}"
+ else
+ cp -p "${source}" "${target}"
+ fi
+ done
+
+ ARTIFACT="${OUTPUT_DIR}/${ARTIFACT_NAME}"
+ if [[ -e "${ARTIFACT}.asc" ]]; then
+ [[ -f "${ARTIFACT}.asc" ]] ||
+ fail "artifact signature path is not a file: ${ARTIFACT}.asc"
+ echo "Reusing existing source artifact signature."
+ else
+ echo "Signing workflow source artifact with ${SIGNING_KEY}."
+ gpg --armor \
+ --local-user "${SIGNING_KEY}" \
+ --detach-sign \
+ --output "${ARTIFACT}.asc" \
+ "${ARTIFACT}"
+ fi
}
validate_artifact_directory() {
@@ -224,6 +295,9 @@ EOF
exit 0
fi
+TEMP_DIR=$(mktemp -d)
+trap 'rm -rf "${TEMP_DIR}"' EXIT
+
for command in git gpg python3; do
require_command "${command}"
done
@@ -269,33 +343,33 @@ else
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}" \
+if [[ "${PREPARE_ONLY}" == true ]]; then
+ 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 preview 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}" \
- --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}"
+ --keys-url "https://downloads.apache.org/paimon/KEYS" \
+ "${ARTIFACT}"
+ validate_artifact_directory
-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.
+The signed tag and preview 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.
+The published workflow artifact is authoritative; do not start a release vote
+from this prepare-only run.
EOF
exit 0
fi
@@ -305,6 +379,14 @@ if svn info "${RC_URL}" >/dev/null 2>&1; then
fi
git push "${REMOTE}" "${RC_TAG}"
wait_for_release_candidate_workflow
+download_and_sign_workflow_artifact
+
+"${SCRIPT_DIR}/verify_release_candidate.sh" \
+ --git-ref "${RC_TAG}" \
+ --keys-url "https://downloads.apache.org/paimon/KEYS" \
+ "${ARTIFACT}"
+validate_artifact_directory
+
svn import "${OUTPUT_DIR}" "${RC_URL}" \
-m "Add Apache Paimon C++ ${VERSION} RC${RC}"
diff --git a/scripts/releasing/tests/test_release_tools.py
b/scripts/releasing/tests/test_release_tools.py
index d1b4eea..827ddfe 100644
--- a/scripts/releasing/tests/test_release_tools.py
+++ b/scripts/releasing/tests/test_release_tools.py
@@ -20,6 +20,8 @@ import hashlib
import io
import json
import os
+import re
+import shutil
import subprocess
import sys
import tarfile
@@ -33,9 +35,26 @@ 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"
+SOURCE_RELEASE_CREATOR = RELEASING_DIR / "create_source_release.sh"
+SOURCE_ROOT = RELEASING_DIR.parents[1]
class ReleaseToolTest(unittest.TestCase):
+ def head_release_version(self) -> str:
+ result = subprocess.run(
+ ["git", "-C", str(SOURCE_ROOT), "show", "HEAD:CMakeLists.txt"],
+ universal_newlines=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ )
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ match = re.search(
+ r"^\s*VERSION\s+(\d+\.\d+\.\d+)\s*$", result.stdout, re.MULTILINE
+ )
+ self.assertIsNotNone(match)
+ return match.group(1)
+
def run_tool(
self, tool: Path, *args: str, expected_returncode: int = 0
) -> subprocess.CompletedProcess:
@@ -314,6 +333,269 @@ source_root=$(cd "$(dirname "$0")/../.." && pwd)
result.stderr,
)
+ @unittest.skipUnless(
+ shutil.which("gpg") and shutil.which("gzip"), "gpg and gzip are
required"
+ )
+ def test_verifier_uses_keys_file_for_unsigned_artifact_tag(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ directory = Path(temp)
+ source_root = directory / "source"
+ releasing_dir = source_root / "scripts/releasing"
+ releasing_dir.mkdir(parents=True)
+ for script in (
+ "bump_version.py",
+ "create_source_release.sh",
+ "validate_source_archive.py",
+ "verify_release_candidate.sh",
+ ):
+ shutil.copy2(RELEASING_DIR / script, releasing_dir / script)
+
+ files = {
+ "CMakeLists.txt": "project(paimon\n VERSION 1.2.3\n)\n",
+ "LICENSE": "Apache License\n",
+ "NOTICE": "Apache Paimon\n",
+ "docs/source/conf.py": 'version = "1.2.3"\n',
+ "docs/source/_static/versions.json": (
+ '[{"name": "1.2.3", "version": "1.2.3", '
+ '"url": "https://paimon.apache.org/docs/cpp/"}]\n'
+ ),
+ ".github/.rat-excludes": "",
+ }
+ for name, content in files.items():
+ path = source_root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+
+ empty_git_config = directory / "empty-gitconfig"
+ empty_git_config.touch()
+ git_env = os.environ.copy()
+ for name in list(git_env):
+ if name in ("GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS") or
re.fullmatch(
+ r"GIT_CONFIG_(KEY|VALUE)_\d+", name
+ ):
+ del git_env[name]
+ git_env["GIT_CONFIG_GLOBAL"] = str(empty_git_config)
+ git_env["GIT_CONFIG_SYSTEM"] = str(empty_git_config)
+
+ subprocess.run(
+ ["git", "init", "-q", str(source_root)], env=git_env,
check=True
+ )
+ subprocess.run(
+ ["git", "-C", str(source_root), "config", "user.name",
"Release Test"],
+ env=git_env,
+ check=True,
+ )
+ subprocess.run(
+ [
+ "git",
+ "-C",
+ str(source_root),
+ "config",
+ "user.email",
+ "[email protected]",
+ ],
+ env=git_env,
+ check=True,
+ )
+ subprocess.run(
+ ["git", "-C", str(source_root), "add", "."],
+ env=git_env,
+ check=True,
+ )
+ subprocess.run(
+ ["git", "-C", str(source_root), "commit", "-q", "-m", "test"],
+ env=git_env,
+ check=True,
+ )
+
+ signing_home = directory / "signing-home"
+ signing_home.mkdir(mode=0o700)
+ signing_env = git_env.copy()
+ signing_env["GNUPGHOME"] = str(signing_home)
+ subprocess.run(
+ [
+ "gpg",
+ "--batch",
+ "--passphrase",
+ "",
+ "--quick-generate-key",
+ "Release Test <[email protected]>",
+ "ed25519",
+ "sign",
+ "0",
+ ],
+ env=signing_env,
+ check=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ key_listing = subprocess.run(
+ ["gpg", "--batch", "--with-colons", "--list-secret-keys"],
+ env=signing_env,
+ universal_newlines=True,
+ stdout=subprocess.PIPE,
+ check=True,
+ )
+ fingerprint = next(
+ line.split(":")[9]
+ for line in key_listing.stdout.splitlines()
+ if line.startswith("fpr:") # codespell:ignore fpr
+ )
+ subprocess.run(
+ [
+ "git",
+ "-C",
+ str(source_root),
+ "tag",
+ "-s",
+ "-u",
+ fingerprint,
+ "-m",
+ "test tag",
+ "v1.2.3-rc1",
+ ],
+ env=signing_env,
+ check=True,
+ )
+
+ keys_file = directory / "KEYS"
+ with keys_file.open("w", encoding="utf-8") as output:
+ subprocess.run(
+ ["gpg", "--batch", "--armor", "--export", fingerprint],
+ env=signing_env,
+ universal_newlines=True,
+ stdout=output,
+ check=True,
+ )
+
+ real_gzip = shutil.which("gzip")
+ self.assertIsNotNone(real_gzip)
+ fake_gzip = directory / "gzip"
+ fake_gzip.write_text(
+ "#!/usr/bin/env bash\n"
+ "set -euo pipefail\n"
+ "if [[ ${1:-} == --version ]]; then\n"
+ " echo 'gzip 1.99'\n"
+ " exit 0\n"
+ "fi\n"
+ f'exec "{real_gzip}" -n -c -6\n',
+ encoding="utf-8",
+ )
+ fake_gzip.chmod(0o755)
+ release_env = git_env.copy()
+ release_env["PAIMON_GZIP"] = str(fake_gzip)
+
+ artifact_dir = directory / "release"
+ subprocess.run(
+ [
+ "bash",
+ str(releasing_dir / "create_source_release.sh"),
+ "--version",
+ "1.2.3",
+ "--git-ref",
+ "v1.2.3-rc1",
+ "--output-dir",
+ str(artifact_dir),
+ ],
+ env=release_env,
+ check=True,
+ stdout=subprocess.DEVNULL,
+ )
+ artifact = artifact_dir / "apache-paimon-cpp-1.2.3-src.tgz"
+ result = subprocess.run(
+ [
+ "bash",
+ str(releasing_dir / "verify_release_candidate.sh"),
+ "--allow-unsigned",
+ "--keys-file",
+ str(keys_file),
+ "--git-ref",
+ "v1.2.3-rc1",
+ "--skip-rat",
+ "--skip-build",
+ str(artifact),
+ ],
+ universal_newlines=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=release_env,
+ check=False,
+ )
+ self.assertEqual(
+ result.returncode, 0, msg=result.stdout + result.stderr
+ )
+ self.assertIn("Git ref reproducibility: valid", result.stdout)
+
+ def test_source_creator_rejects_non_gnu_gzip(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ directory = Path(temp)
+ fake_gzip = directory / "gzip"
+ fake_gzip.write_text(
+ "#!/usr/bin/env bash\n"
+ "echo 'Apple gzip 999.0'\n",
+ encoding="utf-8",
+ )
+ fake_gzip.chmod(0o755)
+ env = os.environ.copy()
+ env["PAIMON_GZIP"] = str(fake_gzip)
+ result = subprocess.run(
+ [
+ "bash",
+ str(SOURCE_RELEASE_CREATOR),
+ "--version",
+ self.head_release_version(),
+ "--git-ref",
+ "HEAD",
+ "--output-dir",
+ str(directory / "release"),
+ ],
+ universal_newlines=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env,
+ check=False,
+ )
+ self.assertEqual(result.returncode, 1, msg=result.stdout +
result.stderr)
+ self.assertIn("GNU gzip is required", result.stderr)
+
+ def test_source_creator_clears_gzip_environment_options(self) -> None:
+ with tempfile.TemporaryDirectory() as temp:
+ directory = Path(temp)
+ fake_gzip = directory / "gzip"
+ fake_gzip.write_text(
+ "#!/usr/bin/env bash\n"
+ "set -euo pipefail\n"
+ "if [[ ${1:-} == --version ]]; then\n"
+ " echo 'gzip 1.99'\n"
+ " exit 0\n"
+ "fi\n"
+ "[[ -z ${GZIP+x} ]] || { echo 'GZIP was not cleared' >&2; exit
1; }\n"
+ "dd of=/dev/null 2>/dev/null\n",
+ encoding="utf-8",
+ )
+ fake_gzip.chmod(0o755)
+ env = os.environ.copy()
+ env["GZIP"] = "-9"
+ env["PAIMON_GZIP"] = str(fake_gzip)
+ result = subprocess.run(
+ [
+ "bash",
+ str(SOURCE_RELEASE_CREATOR),
+ "--version",
+ self.head_release_version(),
+ "--git-ref",
+ "HEAD",
+ "--output-dir",
+ str(directory / "release"),
+ ],
+ universal_newlines=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env,
+ check=False,
+ )
+ self.assertEqual(result.returncode, 0, msg=result.stdout +
result.stderr)
+
def create_version_tree(self, root: Path) -> None:
(root / "docs/source/_static").mkdir(parents=True)
(root / "CMakeLists.txt").write_text(
diff --git a/scripts/releasing/verify_release_candidate.sh
b/scripts/releasing/verify_release_candidate.sh
index 99e7df0..0f3e76d 100755
--- a/scripts/releasing/verify_release_candidate.sh
+++ b/scripts/releasing/verify_release_candidate.sh
@@ -28,6 +28,8 @@
DIST_DEV_BASE_URL="https://dist.apache.org/repos/dist/dev/paimon"
KEYS_URL=""
KEYS_FILE=""
GIT_REF=""
+VERIFY_GNUPG_HOME=""
+VERIFY_KEYS_FILE=""
RAT_JAR=${RAT_JAR:-}
RAT_VERSION="0.16.1"
ALLOW_UNSIGNED=false
@@ -52,7 +54,7 @@ Download options:
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
+ --git-ref REF Regenerate with GNU gzip and compare archive bytes
Verification options:
--rat-jar FILE Apache RAT executable jar (or set RAT_JAR)
@@ -99,6 +101,24 @@ download_file() {
fi
}
+prepare_verification_keyring() {
+ [[ -z "${VERIFY_GNUPG_HOME}" ]] || return 0
+
+ command -v gpg >/dev/null 2>&1 || fail "gpg is required for signature
verification"
+ VERIFY_GNUPG_HOME="${TEMP_DIR}/gnupg"
+ mkdir -m 700 "${VERIFY_GNUPG_HOME}"
+ if [[ -n "${KEYS_URL}" ]]; then
+ VERIFY_KEYS_FILE="${TEMP_DIR}/KEYS"
+ download_file "${KEYS_URL}" "${VERIFY_KEYS_FILE}"
+ else
+ VERIFY_KEYS_FILE=$(cd "$(dirname "${KEYS_FILE}")" && pwd)/$(basename
"${KEYS_FILE}")
+ fi
+ [[ -f "${VERIFY_KEYS_FILE}" ]] ||
+ fail "KEYS file does not exist: ${VERIFY_KEYS_FILE}"
+ gpg --batch --homedir "${VERIFY_GNUPG_HOME}" \
+ --import "${VERIFY_KEYS_FILE}" >/dev/null
+}
+
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
@@ -249,19 +269,10 @@ echo "SHA-512 checksum: valid"
if [[ -f "${SIGNATURE_FILE}" ]]; then
command -v gpg >/dev/null 2>&1 || fail "gpg is required to verify the
signature"
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}" \
+ prepare_verification_keyring
+ gpg --batch --homedir "${VERIFY_GNUPG_HOME}" \
--verify "${SIGNATURE_FILE}" "${ARTIFACT}"
- echo "OpenPGP signature: valid against ${KEYS_FILE}"
+ echo "OpenPGP signature: valid against ${VERIFY_KEYS_FILE}"
else
gpg --verify "${SIGNATURE_FILE}" "${ARTIFACT}"
echo "OpenPGP signature: valid against the default GPG keyring"
@@ -313,7 +324,13 @@ if [[ -n "${GIT_REF}" ]]; then
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}"
+ if [[ -n "${KEYS_URL}" || -n "${KEYS_FILE}" ]]; then
+ prepare_verification_keyring
+ GNUPGHOME=${VERIFY_GNUPG_HOME} \
+ git -C "${SOURCE_ROOT}" verify-tag "${GIT_REF}"
+ else
+ git -C "${SOURCE_ROOT}" verify-tag "${GIT_REF}"
+ fi
fi
REPRO_DIR="${TEMP_DIR}/reproduced"
"${SCRIPT_DIR}/create_source_release.sh" \