From 859dc772766f5654e0d04f22dd76dbe709800b3e Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Mon, 26 Oct 2026 09:44:20 +0900 Subject: [PATCH 1/2] arcadedb-jvm: bundle Eclipse Temurin 25 and tag the Linux wheels manylinux_2_28 The jlink step took its JDK from the amazoncorretto:25 image (Amazon Linux 2023), whose binaries all need GLIBC_2.34. Temurin's Linux JDK needs glibc 2.17 at most on x86_64 and aarch64, and JPype's own Linux wheels are manylinux_2_28, so the wheels are tagged manylinux_2_28 like the native ones. verify_wheel_platform_tag.py expects the higher of the floor (2.28) and the JRE's requirement. test-jvm.yml installs the built wheel in a quay.io/pypa/manylinux_2_28 container on both Linux legs and runs SELECT 1. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/test-jvm.yml | 37 +++++++++++++++ scripts/Dockerfile.build | 31 +++++++------ scripts/verify_wheel_platform_tag.py | 32 ++++++++----- tests/test_wheel_platform_tag.py | 68 ++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 24 deletions(-) diff --git a/.github/workflows/test-jvm.yml b/.github/workflows/test-jvm.yml index 23c8c58..3249f09 100644 --- a/.github/workflows/test-jvm.yml +++ b/.github/workflows/test-jvm.yml @@ -360,6 +360,43 @@ jobs: mkdir -p test-install cp dist/*.whl test-install/ + # The tests below run on the runner (glibc 2.39). This step runs the wheel where its tag says it must + # work: a manylinux_2_28 container (glibc 2.28, AlmaLinux 8, the oldest system the Linux wheels are tagged for). + # pip refuses a wheel tagged above 2.28 there, so a JRE that raises the floor again fails here as well as in + # scripts/verify_wheel_platform_tag.py. The wheel's own JRE does the work: no Java in the container. + - name: Install and run the wheel in manylinux_2_28 (Linux only) + if: startsWith(matrix.platform, 'linux/') + shell: bash + env: + PLATFORM: ${{ matrix.platform }} + PYVER: ${{ matrix.python-version }} + run: | + case "$PLATFORM" in + linux/amd64) ARCH=x86_64 ;; + linux/arm64) ARCH=aarch64 ;; + *) echo "unexpected platform $PLATFORM" >&2; exit 1 ;; + esac + PYTAG="cp${PYVER//./}-cp${PYVER//./}" + ls -l test-install/*.whl + docker run --rm -i -e PYTAG="$PYTAG" -v "$PWD/test-install:/wheels:ro" \ + "quay.io/pypa/manylinux_2_28_${ARCH}:latest" bash -es <<'CONTAINER' + ldd --version | head -n 1 + test -x "/opt/python/$PYTAG/bin/python" || { echo "no $PYTAG in this image" >&2; ls /opt/python >&2; exit 1; } + "/opt/python/$PYTAG/bin/python" -m venv /tmp/smoke + # --only-binary: a source build of JPype must not hide a missing wheel + /tmp/smoke/bin/python -m pip install --quiet --only-binary=:all: /wheels/*.whl + cd /tmp + /tmp/smoke/bin/python - <<'PYSMOKE' + import tempfile + import arcadedb + with tempfile.TemporaryDirectory() as tmp: + with arcadedb.create_database(tmp + "/smoke") as db: + rows = db.query("sql", "SELECT 1 AS one").to_list() + assert rows and rows[0]["one"] == 1, rows + print("manylinux_2_28 smoke OK:", arcadedb.__version__, rows) + PYSMOKE + CONTAINER + - name: Set up Python for host testing uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: diff --git a/scripts/Dockerfile.build b/scripts/Dockerfile.build index dd11e84..f82e8e1 100644 --- a/scripts/Dockerfile.build +++ b/scripts/Dockerfile.build @@ -30,10 +30,17 @@ FROM arcadedata/arcadedb:${ARCADEDB_TAG} AS java-builder # Stage 2: Build minimal JRE with jlink # Docker builds are Linux-only. macOS/Windows JREs must be created on matching native hosts. -FROM amazoncorretto:25 AS jre-builder +# +# The JDK is Eclipse Temurin, not amazoncorretto:25. The Corretto IMAGE is Amazon Linux 2023 (glibc 2.34) and its JDK is +# built against it: every binary needs GLIBC_2.34, which is what set the wheels' manylinux_2_34 floor. Temurin's Linux +# JDK builds need glibc 2.17 at most on x86_64 and aarch64 (scripts/verify_wheel_platform_tag.py --jre prints +# manylinux_2_17 for both), and the eclipse-temurin image carries that build unchanged. jlink copies the libraries of the +# JDK it runs, so the bundled JRE has the same floor. Both are GPLv2 with the Classpath Exception. +FROM eclipse-temurin:25-jdk AS jre-builder # Install required tooling (findutils for jar exclusion, binutils for jlink) -RUN yum -y install findutils binutils && yum clean all +RUN apt-get update && apt-get install -y --no-install-recommends findutils binutils \ + && rm -rf /var/lib/apt/lists/* ARG TARGET_PLATFORM ARG USE_LOCAL_JARS @@ -227,15 +234,13 @@ RUN echo "📌 Package name: ${PACKAGE_NAME}" && \ # Build the wheel # Extract PEP 440 version from the engine jar or use BUILD_VERSION if provided. # -# Wheel platform tag must match the highest glibc symbol version required by the -# bundled JRE. amazoncorretto:25 (Amazon Linux 2023, glibc 2.34) ships a JRE -# whose binaries link against GLIBC_2.34 at most; older glibc symbol versions -# cannot be back-ported. Issue #4037: tagging as manylinux_2_35 prevents -# installation on systems with glibc 2.34 (RHEL 9, Rocky 9, AlmaLinux 9, Amazon -# Linux 2023, ...) even though the bundled JRE runs there fine. Keep the tag -# aligned with the JRE's actual requirement; bump only when the JRE base image -# moves to a newer glibc. The verify_wheel_platform_tag.py step below enforces -# this invariant at build time. +# Wheel platform tag: manylinux_2_28, the same floor as the native wheels (glibc 2.28: RHEL, Rocky and +# AlmaLinux 8, Debian 10, Ubuntu 20.04 and newer). It is also the lowest the Python side can have: JPype's own Linux +# wheels are manylinux_2_28. The tag is the higher of that floor and the highest GLIBC_ symbol the bundled JRE +# references (2.17 with Temurin). Tagging higher than that locks out systems the wheel runs on (issue #4037: a +# manylinux_2_35 tag on a JRE that needed 2.34); tagging lower lets it install where the JRE cannot run. The +# verify_wheel_platform_tag.py step below enforces both at build time, and test-jvm.yml installs the built wheel in +# a manylinux_2_28 container. ARG BUILD_VERSION="" RUN if [ -n "${BUILD_VERSION}" ]; then \ export ARCADEDB_VERSION="${BUILD_VERSION}"; \ @@ -252,9 +257,9 @@ RUN if [ -n "${BUILD_VERSION}" ]; then \ sed -i 's|^description = .*|description = "'"${PACKAGE_DESCRIPTION}"'"|' pyproject.toml && \ if echo "${TARGET_PLATFORM}" | grep -q '^linux-'; then \ if [ "${TARGET_PLATFORM}" = "linux-x64" ]; then \ - WHEEL_PLAT="manylinux_2_34_x86_64"; \ + WHEEL_PLAT="manylinux_2_28_x86_64"; \ elif [ "${TARGET_PLATFORM}" = "linux-arm64" ]; then \ - WHEEL_PLAT="manylinux_2_34_aarch64"; \ + WHEEL_PLAT="manylinux_2_28_aarch64"; \ else \ WHEEL_PLAT=""; \ fi; \ diff --git a/scripts/verify_wheel_platform_tag.py b/scripts/verify_wheel_platform_tag.py index 420b72d..d5e0c1e 100644 --- a/scripts/verify_wheel_platform_tag.py +++ b/scripts/verify_wheel_platform_tag.py @@ -3,17 +3,22 @@ Regression guard for issue #4037: the wheel was tagged ``manylinux_2_35`` while the bundled JRE only required ``GLIBC_2.34``, blocking installation on systems -running glibc 2.34 (RHEL 9, Rocky 9, AlmaLinux 9, Amazon Linux 2023, ...). The -wheel platform tag must equal the highest ``GLIBC_x.y`` symbol version actually -referenced by the JRE binaries (``java``, ``libjvm.so``, ...). Tagging higher -locks out users unnecessarily; tagging lower lets the wheel install on systems -where the JRE cannot run. +running glibc 2.34 (RHEL 9, Rocky 9, AlmaLinux 9, Amazon Linux 2023, ...). + +The expected tag is the higher of ``MANYLINUX_FLOOR`` (the oldest glibc the +package supports, manylinux_2_28, which is also the floor of JPype's own Linux +wheels) and the highest ``GLIBC_x.y`` symbol version referenced by the JRE +binaries (``java``, ``libjvm.so``, ...). Tagging higher locks users out +unnecessarily; tagging lower lets the wheel install on systems where the JRE +cannot run. With a JRE built for glibc 2.17 (Eclipse Temurin) the expected tag is +the floor; a JRE built on a newer system raises it. Usage: python verify_wheel_platform_tag.py Verify a wheel's filename platform tag against the JRE in jre_dir. python verify_wheel_platform_tag.py --jre - Print the recommended manylinux tag for the JRE (no wheel check). + Print the manylinux tag for the JRE (no wheel check): the floor, or + the JRE's own requirement when that is higher. Exits with status 0 on success, non-zero on mismatch. @@ -29,6 +34,9 @@ from pathlib import Path GLIBC_SYM_RE = re.compile(rb"GLIBC_(\d+)\.(\d+)(?:\.(\d+))?") +# The oldest glibc the package supports. The native wheels are built in manylinux_2_28, and JPype (the +# Python side) publishes manylinux_2_28 wheels only, so a lower tag could not install a working package. +MANYLINUX_FLOOR = (2, 28) WHEEL_TAG_RE = re.compile(r"-(manylinux_(\d+)_(\d+)_(x86_64|aarch64))\.whl$") @@ -96,7 +104,7 @@ def main() -> int: args = parser.parse_args() if args.jre_only is not None: - major, minor = max_glibc_in_dir(args.jre_only) + major, minor = max(max_glibc_in_dir(args.jre_only), MANYLINUX_FLOOR) print(f"manylinux_{major}_{minor}") return 0 @@ -106,21 +114,23 @@ def main() -> int: wheel_glibc, _arch = parse_wheel_tag(args.wheel) jre_glibc = max_glibc_in_dir(args.jre) + expected = max(jre_glibc, MANYLINUX_FLOOR) print(f"Wheel platform tag glibc: {wheel_glibc[0]}.{wheel_glibc[1]}") print(f"JRE max GLIBC_ symbol: {jre_glibc[0]}.{jre_glibc[1]}") + print(f"Supported floor: {MANYLINUX_FLOOR[0]}.{MANYLINUX_FLOOR[1]}") - if wheel_glibc != jre_glibc: + if wheel_glibc != expected: print( f"ERROR: wheel manylinux tag (manylinux_{wheel_glibc[0]}_{wheel_glibc[1]}) " - f"does not match the JRE's actual glibc requirement " - f"(manylinux_{jre_glibc[0]}_{jre_glibc[1]}). " + f"does not match the expected manylinux_{expected[0]}_{expected[1]}: " + f"the higher of the supported floor and the JRE's actual glibc requirement. " "Update WHEEL_PLAT in scripts/Dockerfile.build (issue #4037).", file=sys.stderr, ) return 1 - print("OK: wheel platform tag matches the JRE's glibc requirement.") + print("OK: wheel platform tag matches the floor or the JRE's glibc requirement.") return 0 diff --git a/tests/test_wheel_platform_tag.py b/tests/test_wheel_platform_tag.py index ab874b0..9f693ef 100644 --- a/tests/test_wheel_platform_tag.py +++ b/tests/test_wheel_platform_tag.py @@ -81,6 +81,74 @@ def test_main_succeeds_when_tag_matches( assert "OK" in capsys.readouterr().out +def test_main_succeeds_at_the_floor_when_jre_needs_less( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A JRE built for glibc 2.17 (Eclipse Temurin) is tagged with the floor, manylinux_2_28.""" + verifier = _load_verifier() + jre_dir = tmp_path / "jre" + jre_dir.mkdir() + _make_elf_with_glibc_symbols(jre_dir / "libjvm.so", ["2.2.5", "2.17"]) + + wheel = tmp_path / "arcadedb_jvm-26.4.2-py3-none-manylinux_2_28_aarch64.whl" + wheel.write_bytes(b"") + + rc = _run_main(verifier, [str(wheel), str(jre_dir)]) + assert rc == 0 + assert "OK" in capsys.readouterr().out + + +def test_main_fails_when_tag_below_the_floor( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The JRE would run on glibc 2.17, but JPype publishes manylinux_2_28 wheels only.""" + verifier = _load_verifier() + jre_dir = tmp_path / "jre" + jre_dir.mkdir() + _make_elf_with_glibc_symbols(jre_dir / "libjvm.so", ["2.17"]) + + wheel = tmp_path / "arcadedb_jvm-26.4.2-py3-none-manylinux_2_17_x86_64.whl" + wheel.write_bytes(b"") + + rc = _run_main(verifier, [str(wheel), str(jre_dir)]) + assert rc != 0 + assert "manylinux_2_28" in capsys.readouterr().err + + +def test_main_fails_when_tag_above_the_floor_and_jre( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Going back to manylinux_2_34 with a 2.17 JRE would lock out glibc 2.28 to 2.33 for no reason.""" + verifier = _load_verifier() + jre_dir = tmp_path / "jre" + jre_dir.mkdir() + _make_elf_with_glibc_symbols(jre_dir / "libjvm.so", ["2.17"]) + + wheel = tmp_path / "arcadedb_jvm-26.4.2-py3-none-manylinux_2_34_x86_64.whl" + wheel.write_bytes(b"") + + rc = _run_main(verifier, [str(wheel), str(jre_dir)]) + assert rc != 0 + assert "manylinux_2_28" in capsys.readouterr().err + + +def test_jre_only_mode_prints_the_floor_or_the_jre_requirement( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + verifier = _load_verifier() + old = tmp_path / "old" + old.mkdir() + _make_elf_with_glibc_symbols(old / "libjvm.so", ["2.17"]) + assert _run_main(verifier, ["--jre", str(old)]) == 0 + assert capsys.readouterr().out.strip() == "manylinux_2_28" + + new = tmp_path / "new" + new.mkdir() + _make_elf_with_glibc_symbols(new / "libjvm.so", ["2.17", "2.34"]) + assert _run_main(verifier, ["--jre", str(new)]) == 0 + assert capsys.readouterr().out.strip() == "manylinux_2_34" + + def test_main_fails_when_tag_higher_than_jre( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: From 0a76d7e2aafbcd1cfea7b4a58900fb69a1c5fbc2 Mon Sep 17 00:00:00 2001 From: Taewoon Kim Date: Mon, 26 Oct 2026 10:38:00 +0900 Subject: [PATCH 2/2] arcadedb-jvm: link the JRE from the Temurin run-time image, no full-JDK fallback The eclipse-temurin JDK has no jmods directory (JEP 493), so the jmods-only branch of the jre-builder stage fell back to copying the whole JDK: a 251 MB JRE and a 130 MB wheel, which scripts/verify_wheel_size.py rejects (over 100 MB). jlink links from the run-time image when --module-path is absent (40 MB JRE in the same image). The silent fallback is removed so a jlink failure stops the build. Co-Authored-By: Claude Sonnet 5.5 --- scripts/Dockerfile.build | 66 +++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/scripts/Dockerfile.build b/scripts/Dockerfile.build index f82e8e1..742e977 100644 --- a/scripts/Dockerfile.build +++ b/scripts/Dockerfile.build @@ -106,6 +106,11 @@ RUN echo "🔨 Building arcadedb-python-bridge.jar..." && \ # Automatically detect modules with jdeps, plus ensure jdk.zipfs (for JPype) and jdk.unsupported are included # We exclude jboss/wildfly JARs because they have broken module descriptors that fail analysis. # We also do NOT provide a classpath, forcing jdeps to ignore all missing dependencies (intra-jar or external). +# +# The eclipse-temurin JDK has no jmods directory (JEP 493: it is built with --enable-linkable-runtime), so jlink reads +# the modules from the JDK's own run-time image when --module-path is absent. There is no fallback that copies the +# whole JDK: the first Temurin build did that silently and produced a 130 MB wheel (251 MB JRE), over the 100 MB limit +# of scripts/verify_wheel_size.py. If jlink cannot build the JRE, the build stops. RUN echo "🔍 Analyzing JARs with jdeps..." && \ JMODS_DIR="${JAVA_HOME}/jmods" && \ if [ ! -d "$JMODS_DIR" ]; then JMODS_DIR="${JAVA_HOME}/lib/jmods"; fi && \ @@ -130,41 +135,38 @@ RUN echo "🔍 Analyzing JARs with jdeps..." && \ echo "📦 Final modules list: ${REQUIRED_MODULES}" && \ echo "📦 Required modules:" && \ echo "$REQUIRED_MODULES" | tr ',' '\n' | sed 's/^/ - /' && \ + echo "" && \ + echo "🔨 Running jlink..." && \ if [ -d "$JMODS_DIR" ]; then \ - echo "" ; \ - echo "🔨 Running jlink..." ; \ - if ! jlink \ - --module-path "$JMODS_DIR" \ - --add-modules "${REQUIRED_MODULES}" \ - --ignore-signing-information \ - --strip-debug \ - --no-man-pages \ - --no-header-files \ - --compress zip-9 \ - --output /build/jre ; then \ - echo "❌ jlink failed" ; \ - exit 1 ; \ - fi ; \ - echo "" ; \ - echo "✅ JRE build complete!" ; \ - echo "📊 JRE size after jlink: $(du -sm /build/jre | cut -f1) MB" ; \ - BAD_MODULES=$(/build/jre/bin/java --list-modules | sed 's/@.*//' | grep -xFf /build/jre_excluded_modules.list || true) ; \ - if [ -n "$BAD_MODULES" ]; then \ - echo "❌ the JRE contains excluded modules (another module requires them): $BAD_MODULES" ; \ - exit 1 ; \ - fi ; \ - echo "✅ No excluded module in the JRE" ; \ - find /build/jre -name '*.so' -type f -exec strip --strip-unneeded {} + ; \ - echo "📊 JRE size after stripping native libraries: $(du -sm /build/jre | cut -f1) MB" ; \ + JLINK_SOURCE="--module-path $JMODS_DIR" ; \ + echo " modules from ${JMODS_DIR}" ; \ else \ - echo "⚠️ jmods directory not found under ${JAVA_HOME}. Falling back to copying full JDK runtime." ; \ - mkdir -p /build/jre ; \ - cp -a ${JAVA_HOME}/bin /build/jre/bin ; \ - cp -a ${JAVA_HOME}/lib /build/jre/lib ; \ - cp -a ${JAVA_HOME}/conf /build/jre/conf || true ; \ - cp -a ${JAVA_HOME}/release /build/jre/release || true ; \ - echo "✅ Fallback JRE created from full JDK runtime." ; \ + JLINK_SOURCE="" ; \ + echo " no jmods directory: linking from the run-time image of ${JAVA_HOME} (JEP 493)" ; \ + fi && \ + if ! jlink \ + ${JLINK_SOURCE} \ + --add-modules "${REQUIRED_MODULES}" \ + --ignore-signing-information \ + --strip-debug \ + --no-man-pages \ + --no-header-files \ + --compress zip-9 \ + --output /build/jre ; then \ + echo "❌ jlink failed" ; \ + exit 1 ; \ + fi && \ + echo "" && \ + echo "✅ JRE build complete!" && \ + echo "📊 JRE size after jlink: $(du -sm /build/jre | cut -f1) MB" && \ + BAD_MODULES=$(/build/jre/bin/java --list-modules | sed 's/@.*//' | grep -xFf /build/jre_excluded_modules.list || true) && \ + if [ -n "$BAD_MODULES" ]; then \ + echo "❌ the JRE contains excluded modules (another module requires them): $BAD_MODULES" ; \ + exit 1 ; \ fi && \ + echo "✅ No excluded module in the JRE" && \ + find /build/jre -name '*.so' -type f -exec strip --strip-unneeded {} + && \ + echo "📊 JRE size after stripping native libraries: $(du -sm /build/jre | cut -f1) MB" && \ echo "" && \ JRE_SIZE=$(du -sh /build/jre | cut -f1) && \ echo "📊 JRE size: $JRE_SIZE" && \