This is an automated email from the ASF dual-hosted git repository. Cole-Greer pushed a commit to branch reproducible-build in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit cf209197c4e6d6dc4bde94c3d07ff2f584edc830 Author: Cole Greer <[email protected]> AuthorDate: Tue Jul 21 15:55:22 2026 -0700 Add reproduce-distribution.sh to validate release artifacts against a clean rebuild --- bin/reproduce-distribution.sh | 532 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 532 insertions(+) diff --git a/bin/reproduce-distribution.sh b/bin/reproduce-distribution.sh new file mode 100755 index 0000000000..ddd24f1458 --- /dev/null +++ b/bin/reproduce-distribution.sh @@ -0,0 +1,532 @@ +#!/bin/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. +# + +# ============================================================================= +# Reproducible Build Validation Script +# ============================================================================= +# Independently rebuilds TinkerPop release distributions from a git tag inside +# a pinned Docker toolchain and compares the rebuilt artifacts entry-by-entry +# against the published artifacts on dist.apache.org. +# +# This establishes trust WITHOUT relying on the release manager's checksums or +# signatures — the threat model is that the release was built on untrusted or +# compromised hardware. +# +# Usage: reproduce-distribution.sh <GIT_TAG> [DIST_URL_BASE] +# ============================================================================= + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Platform-specific SHA-512 (macOS compatibility) +# --------------------------------------------------------------------------- +if [[ "$OSTYPE" == "darwin"* ]]; then + sha512sum() { shasum -a 512 "$@"; } +fi + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +DOCKER_IMAGE_PREFIX="tinkerpop-reproducible-build" +DOCKERFILE_PATH="${REPO_ROOT}/docker/reproducible-build/Dockerfile" + +# --------------------------------------------------------------------------- +# Functions +# --------------------------------------------------------------------------- + +usage() { + cat <<EOF +Usage: $(basename "$0") <GIT_TAG> [DIST_URL_BASE] + + GIT_TAG Release tag to reproduce (e.g. 3.7.6) + DIST_URL_BASE Base URL for published artifacts + (default: https://dist.apache.org/repos/dist/release/tinkerpop/<GIT_TAG>) + +This script rebuilds TinkerPop distribution artifacts from a git tag inside a +pinned Docker container and compares them entry-by-entry against the published +artifacts. It requires Docker and git to be installed. + +EOF + exit 1 +} + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +info() { + echo "* $*" +} + +warn() { + echo " [WARN] $*" +} + +check_prerequisites() { + command -v docker >/dev/null 2>&1 || die "docker is required but not found on PATH" + command -v git >/dev/null 2>&1 || die "git is required but not found on PATH" + command -v curl >/dev/null 2>&1 || die "curl is required but not found on PATH" + command -v unzip >/dev/null 2>&1 || die "unzip is required but not found on PATH" + + docker info >/dev/null 2>&1 || die "docker daemon is not running" +} + +# Determine if a JAR filename belongs to TinkerPop (org.apache.tinkerpop) +is_tinkerpop_jar() { + local filename="$1" + # TinkerPop JARs: gremlin-*, tinkergraph-*, spark-gremlin*, hadoop-gremlin*, + # neo4j-gremlin*, tinkerpop-* + if [[ "$filename" =~ ^(gremlin-|tinkergraph-|spark-gremlin|hadoop-gremlin|neo4j-gremlin|tinkerpop-) ]]; then + return 0 + fi + return 1 +} + +# Compare inner entries of two JARs (recursive comparison) +compare_jar_entries() { + local jar_rebuilt="$1" + local jar_published="$2" + local jar_name="$3" + local tmp_rebuilt tmp_published + local has_diff=0 + + tmp_rebuilt=$(mktemp -d) + tmp_published=$(mktemp -d) + + unzip -q -o "$jar_rebuilt" -d "$tmp_rebuilt" 2>/dev/null || true + unzip -q -o "$jar_published" -d "$tmp_published" 2>/dev/null || true + + # Get sorted entry lists + local entries_rebuilt entries_published + entries_rebuilt=$(cd "$tmp_rebuilt" && find . -type f | sort) + entries_published=$(cd "$tmp_published" && find . -type f | sort) + + # Find entries only in one side + local only_rebuilt only_published + only_rebuilt=$(comm -23 <(echo "$entries_rebuilt") <(echo "$entries_published")) + only_published=$(comm -13 <(echo "$entries_rebuilt") <(echo "$entries_published")) + + if [[ -n "$only_rebuilt" ]]; then + echo " Entries only in rebuilt ${jar_name}:" + echo "$only_rebuilt" | sed 's/^/ /' + has_diff=1 + fi + + if [[ -n "$only_published" ]]; then + echo " Entries only in published ${jar_name}:" + echo "$only_published" | sed 's/^/ /' + has_diff=1 + fi + + # Compare common entries by content hash + local common_entries + common_entries=$(comm -12 <(echo "$entries_rebuilt") <(echo "$entries_published")) + while IFS= read -r entry; do + [[ -z "$entry" ]] && continue + local hash_r hash_p + hash_r=$(sha512sum "${tmp_rebuilt}/${entry}" | awk '{print $1}') + hash_p=$(sha512sum "${tmp_published}/${entry}" | awk '{print $1}') + if [[ "$hash_r" != "$hash_p" ]]; then + echo " DIFFERS: ${jar_name}!${entry}" + has_diff=1 + fi + done <<< "$common_entries" + + rm -rf "$tmp_rebuilt" "$tmp_published" + return $has_diff +} + +# Compare two ZIP artifacts entry-by-entry +# Returns 0 if PASS, 1 if FAIL (TinkerPop entries differ), 2 if only external deps differ +compare_zip_entries() { + local zip_rebuilt="$1" + local zip_published="$2" + local artifact_name="$3" + local has_tp_diff=0 + local has_ext_diff=0 + + # Extract entry listings: path, size, CRC from unzip -v + # unzip -v format: Length Method Size Cmpr Date Time CRC-32 Name + local tmp_rebuilt_list tmp_published_list + tmp_rebuilt_list=$(mktemp) + tmp_published_list=$(mktemp) + + # Get sorted list of entries with size and CRC-32 + # unzip -v format: Length Method Size Cmpr Date Time CRC-32 Name + # Note: $NF is used for path which assumes no spaces in entry names (safe for Maven artifacts) + unzip -v "$zip_rebuilt" 2>/dev/null | awk '/^[- ]*$/{p=!p;next} p && NF>=8 {print $NF, $1, $7}' | sort > "$tmp_rebuilt_list" + unzip -v "$zip_published" 2>/dev/null | awk '/^[- ]*$/{p=!p;next} p && NF>=8 {print $NF, $1, $7}' | sort > "$tmp_published_list" + + # Compare entry paths + local paths_rebuilt paths_published + paths_rebuilt=$(awk '{print $1}' "$tmp_rebuilt_list") + paths_published=$(awk '{print $1}' "$tmp_published_list") + + local only_rebuilt only_published + only_rebuilt=$(comm -23 <(echo "$paths_rebuilt") <(echo "$paths_published")) + only_published=$(comm -13 <(echo "$paths_rebuilt") <(echo "$paths_published")) + + if [[ -n "$only_rebuilt" ]]; then + echo " Entries only in rebuilt ${artifact_name}:" + echo "$only_rebuilt" | head -20 | sed 's/^/ /' + local count + count=$(echo "$only_rebuilt" | wc -l | tr -d ' ') + [[ $count -gt 20 ]] && echo " ... and $((count - 20)) more" + has_tp_diff=1 + fi + + if [[ -n "$only_published" ]]; then + echo " Entries only in published ${artifact_name}:" + echo "$only_published" | head -20 | sed 's/^/ /' + local count + count=$(echo "$only_published" | wc -l | tr -d ' ') + [[ $count -gt 20 ]] && echo " ... and $((count - 20)) more" + has_tp_diff=1 + fi + + # Compare entries present in both (size + CRC) + local common_paths differing_entries="" + common_paths=$(comm -12 <(echo "$paths_rebuilt") <(echo "$paths_published")) + + while IFS= read -r path; do + [[ -z "$path" ]] && continue + local info_r info_p + info_r=$(grep -F "$path " "$tmp_rebuilt_list" | head -1) + info_p=$(grep -F "$path " "$tmp_published_list" | head -1) + if [[ "$info_r" != "$info_p" ]]; then + differing_entries="${differing_entries}${path}\n" + fi + done <<< "$common_paths" + + rm -f "$tmp_rebuilt_list" "$tmp_published_list" + + if [[ -z "$differing_entries" ]] && [[ $has_tp_diff -eq 0 ]]; then + return 0 + fi + + # Classify differing entries as TinkerPop or external + if [[ -n "$differing_entries" ]]; then + local tp_diffs="" ext_diffs="" + while IFS= read -r entry_path; do + [[ -z "$entry_path" ]] && continue + local basename_entry + basename_entry=$(basename "$entry_path") + if [[ "$basename_entry" == *.jar ]] && ! is_tinkerpop_jar "$basename_entry"; then + ext_diffs="${ext_diffs}${entry_path}\n" + else + tp_diffs="${tp_diffs}${entry_path}\n" + fi + done < <(echo -e "$differing_entries") + + if [[ -n "$ext_diffs" ]]; then + echo " External dependencies (from Maven Central — not built from source, skipped):" + echo -e "$ext_diffs" | head -10 | sed 's/^/ /' + local ext_count + ext_count=$(echo -e "$ext_diffs" | grep -c . || true) + [[ $ext_count -gt 10 ]] && echo " ... and $((ext_count - 10)) more" + has_ext_diff=1 + fi + + if [[ -n "$tp_diffs" ]]; then + echo " TinkerPop entries with differing size/CRC:" + echo -e "$tp_diffs" | sed 's/^/ /' + has_tp_diff=1 + + # Recurse into differing TinkerPop JARs + local tmp_r_extract tmp_p_extract + tmp_r_extract=$(mktemp -d) + tmp_p_extract=$(mktemp -d) + unzip -q -o "$zip_rebuilt" -d "$tmp_r_extract" 2>/dev/null || true + unzip -q -o "$zip_published" -d "$tmp_p_extract" 2>/dev/null || true + + while IFS= read -r entry_path; do + [[ -z "$entry_path" ]] && continue + local basename_entry + basename_entry=$(basename "$entry_path") + if [[ "$basename_entry" == *.jar ]] && is_tinkerpop_jar "$basename_entry"; then + local jar_r="${tmp_r_extract}/${entry_path}" + local jar_p="${tmp_p_extract}/${entry_path}" + if [[ -f "$jar_r" ]] && [[ -f "$jar_p" ]]; then + echo " Recursing into TinkerPop JAR: ${basename_entry}" + compare_jar_entries "$jar_r" "$jar_p" "$basename_entry" || true + fi + fi + done < <(echo -e "$tp_diffs") + + rm -rf "$tmp_r_extract" "$tmp_p_extract" + fi + fi + + if [[ $has_tp_diff -ne 0 ]]; then + return 1 + elif [[ $has_ext_diff -ne 0 ]]; then + return 2 + fi + return 0 +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +# Parse arguments +GIT_TAG="${1:-}" +if [[ -z "$GIT_TAG" ]]; then + usage +fi + +# Validate GIT_TAG format to prevent injection via docker build -t or git clone --branch +if ! [[ "$GIT_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$ ]]; then + die "Invalid GIT_TAG '${GIT_TAG}': must match pattern <major>.<minor>.<patch> with optional -rcN suffix (e.g. 3.7.6, 3.7.7-rc1)" +fi + +DIST_URL_BASE="${2:-https://dist.apache.org/repos/dist/release/tinkerpop/${GIT_TAG}}" + +echo "=============================================================================" +echo " Reproducible Build Validation for Apache TinkerPop ${GIT_TAG}" +echo "=============================================================================" +echo "" +echo " Git tag: ${GIT_TAG}" +echo " Dist URL: ${DIST_URL_BASE}" +echo "" + +# Preconditions +info "checking prerequisites ..." +check_prerequisites +echo " OK" + +# Create working directory +WORK_DIR=$(mktemp -d) +info "working directory: ${WORK_DIR}" + +cleanup() { + local exit_code=$? + if [[ $exit_code -ne 0 ]]; then + echo "" + echo "Build artifacts preserved for inspection at: ${WORK_DIR}" + else + rm -rf "$WORK_DIR" + fi + # Remove the Docker image built for this validation run (non-fatal if it fails) + if [[ -n "${DOCKER_TAG:-}" ]]; then + docker rmi "$DOCKER_TAG" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Step 1: Build the Docker image +# --------------------------------------------------------------------------- +DOCKER_TAG="${DOCKER_IMAGE_PREFIX}:${GIT_TAG}" +info "building Docker image (${DOCKER_TAG}) ..." + +if [[ ! -f "$DOCKERFILE_PATH" ]]; then + die "Dockerfile not found at ${DOCKERFILE_PATH}. Run Phase 5 first." +fi + +docker build -t "$DOCKER_TAG" -f "$DOCKERFILE_PATH" "$(dirname "$DOCKERFILE_PATH")" \ + > "${WORK_DIR}/docker-build.log" 2>&1 || { + echo " FAILED (see ${WORK_DIR}/docker-build.log)" + cat "${WORK_DIR}/docker-build.log" + exit 1 +} +echo " OK" + +# --------------------------------------------------------------------------- +# Step 2: Check out the tag into a temp directory +# --------------------------------------------------------------------------- +SOURCE_DIR="${WORK_DIR}/source" +info "checking out tag ${GIT_TAG} ..." + +git clone --depth 1 --branch "$GIT_TAG" "file://${REPO_ROOT}" "$SOURCE_DIR" \ + > "${WORK_DIR}/git-clone.log" 2>&1 || { + echo " FAILED — tag '${GIT_TAG}' not found in repository" + cat "${WORK_DIR}/git-clone.log" + exit 1 +} +echo " OK" + +# --------------------------------------------------------------------------- +# Step 3: Build inside Docker +# --------------------------------------------------------------------------- +BUILD_LOG="${WORK_DIR}/mvn-build.log" +info "building from source inside Docker (this may take a while) ..." + +docker run --rm \ + -v "${SOURCE_DIR}:/build:rw" \ + -v "${WORK_DIR}/m2-repo:/root/.m2/repository:rw" \ + -w /build \ + "$DOCKER_TAG" \ + mvn clean install -Papache-release -DskipTests -DskipImageBuild \ + > "$BUILD_LOG" 2>&1 || { + echo " FAILED (see ${BUILD_LOG})" + echo "" + echo " Last 30 lines of build log:" + tail -30 "$BUILD_LOG" | sed 's/^/ /' + exit 1 +} +echo " OK" + +# --------------------------------------------------------------------------- +# Step 4: Locate rebuilt artifacts and map to published names +# --------------------------------------------------------------------------- +info "locating rebuilt artifacts ..." + +# The rebuilt artifacts use Maven assembly finalName; published ones may be renamed. +# Assembly finalName for console/server: apache-tinkerpop-${artifactId}-${version} +# Mapping to published names (per release.asciidoc): +# apache-tinkerpop-gremlin-console-<ver>-distribution.zip -> apache-tinkerpop-gremlin-console-<ver>-bin.zip +# apache-tinkerpop-gremlin-server-<ver>-distribution.zip -> apache-tinkerpop-gremlin-server-<ver>-bin.zip +# tinkerpop-<ver>-source-release.zip -> apache-tinkerpop-<ver>-src.zip + +CONSOLE_REBUILT="${SOURCE_DIR}/gremlin-console/target/apache-tinkerpop-gremlin-console-${GIT_TAG}-distribution.zip" +SERVER_REBUILT="${SOURCE_DIR}/gremlin-server/target/apache-tinkerpop-gremlin-server-${GIT_TAG}-distribution.zip" +SOURCE_REBUILT="${SOURCE_DIR}/target/tinkerpop-${GIT_TAG}-source-release.zip" + +# Published names on dist.apache.org +CONSOLE_PUBLISHED_NAME="apache-tinkerpop-gremlin-console-${GIT_TAG}-bin.zip" +SERVER_PUBLISHED_NAME="apache-tinkerpop-gremlin-server-${GIT_TAG}-bin.zip" +SOURCE_PUBLISHED_NAME="apache-tinkerpop-${GIT_TAG}-src.zip" + +# Verify rebuilt artifacts exist +for artifact in "$CONSOLE_REBUILT" "$SERVER_REBUILT" "$SOURCE_REBUILT"; do + if [[ ! -f "$artifact" ]]; then + die "Expected rebuilt artifact not found: ${artifact}" + fi +done +echo " OK — all three artifacts found" + +# --------------------------------------------------------------------------- +# Step 5: Download published artifacts +# --------------------------------------------------------------------------- +PUBLISHED_DIR="${WORK_DIR}/published" +mkdir -p "$PUBLISHED_DIR" + +info "downloading published artifacts from ${DIST_URL_BASE} ..." + +for name in "$CONSOLE_PUBLISHED_NAME" "$SERVER_PUBLISHED_NAME" "$SOURCE_PUBLISHED_NAME"; do + echo -n " * ${name} ... " + curl -Lsf "${DIST_URL_BASE}/${name}" -o "${PUBLISHED_DIR}/${name}" || { + echo "FAILED" + die "Failed to download ${DIST_URL_BASE}/${name}" + } + echo "OK" +done + +# --------------------------------------------------------------------------- +# Step 6: Compare rebuilt vs published +# --------------------------------------------------------------------------- +echo "" +echo "=============================================================================" +echo " Comparison Results" +echo "=============================================================================" +echo "" + +# Declare artifact pairs: rebuilt_path:published_path:display_name +declare -a ARTIFACT_PAIRS=( + "${CONSOLE_REBUILT}:${PUBLISHED_DIR}/${CONSOLE_PUBLISHED_NAME}:gremlin-console" + "${SERVER_REBUILT}:${PUBLISHED_DIR}/${SERVER_PUBLISHED_NAME}:gremlin-server" + "${SOURCE_REBUILT}:${PUBLISHED_DIR}/${SOURCE_PUBLISHED_NAME}:source" +) + +declare -a RESULTS=() +OVERALL_EXIT=0 + +for pair in "${ARTIFACT_PAIRS[@]}"; do + IFS=':' read -r rebuilt_path published_path display_name <<< "$pair" + + echo "--- ${display_name} ---" + echo "" + + # Whole-file SHA-512 comparison + hash_rebuilt=$(sha512sum "$rebuilt_path" | awk '{print $1}') + hash_published=$(sha512sum "$published_path" | awk '{print $1}') + + if [[ "$hash_rebuilt" == "$hash_published" ]]; then + echo " SHA-512: MATCH (bit-for-bit identical)" + echo "" + RESULTS+=("${display_name}: PASS (bit-for-bit)") + else + echo " SHA-512: MISMATCH" + echo " rebuilt: ${hash_rebuilt}" + echo " published: ${hash_published}" + echo "" + echo " Performing entry-by-entry comparison ..." + echo "" + + set +e + compare_zip_entries "$rebuilt_path" "$published_path" "$display_name" + cmp_result=$? + set -e + + case $cmp_result in + 0) + echo " Entry comparison: all entries match" + RESULTS+=("${display_name}: PASS (entries match)") + ;; + 2) + echo " Entry comparison: TinkerPop entries match; only external deps differ" + RESULTS+=("${display_name}: PASS (entries match, only external deps differ)") + ;; + 1) + echo " Entry comparison: TinkerPop entries DIFFER" + RESULTS+=("${display_name}: FAIL") + OVERALL_EXIT=1 + ;; + esac + + # Optionally use diffoscope for richer diff + if command -v diffoscope >/dev/null 2>&1; then + echo "" + echo " Running diffoscope for detailed diff ..." + diffoscope_out="${WORK_DIR}/diffoscope-${display_name}.txt" + diffoscope "$rebuilt_path" "$published_path" > "$diffoscope_out" 2>&1 || true + echo " Diffoscope output saved to: ${diffoscope_out}" + fi + fi + + echo "" +done + +# --------------------------------------------------------------------------- +# Step 7: Final summary +# --------------------------------------------------------------------------- +echo "=============================================================================" +echo " SUMMARY" +echo "=============================================================================" +echo "" +for result in "${RESULTS[@]}"; do + echo " ${result}" +done +echo "" + +if [[ $OVERALL_EXIT -eq 0 ]]; then + echo " OVERALL: PASS — all artifacts are reproducible" +else + echo " OVERALL: FAIL — one or more artifacts could not be reproduced" + echo "" + echo " Build artifacts preserved at: ${WORK_DIR}" +fi + +echo "" +exit $OVERALL_EXIT
