From 71040828ad3e9673085ed01c6592c8bd6417a029 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Wed, 21 Oct 2026 00:43:52 +0530 Subject: [PATCH 1/2] fix(readme): document the uniffi.minigraf_ffi package 2.x ships; consumer-test v2.x Every 2.x jar has its classes in uniffi.minigraf_ffi; the README quick start imported io.github.project_minigraf.minigraf.MiniGrafDb, which only exists from v3.0.0 (the package rename is a breaking change, so it stays out of 2.x). The install snippets said 1.2.0 and 1.1.1. 2.0.x patch releases run main's workflows on this branch, so this branch gets what they need: the reproducible-archive settings and the build/m2 dist repository in build.gradle.kts, and consumer-test/ written against the 2.x API (MiniGrafDb.Companion from Java). The workflow files are copies of main's, so PRs to v2.x run the same package and consumer tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PtCdzbJMSS1V8qEPaJ4jXz --- .github/workflows/ci.yml | 156 +++++++++++- .github/workflows/package.yml | 222 ++++++++++++++++++ .github/workflows/verify-published.yml | 109 +++++++++ README.md | 8 +- consumer-test/.gitignore | 3 + consumer-test/build.gradle.kts | 30 +++ consumer-test/readme_sync.py | 56 +++++ consumer-test/run.sh | 59 +++++ consumer-test/settings.gradle.kts | 20 ++ consumer-test/src/test/java/ContractTest.java | 44 ++++ .../src/test/kotlin/QuickstartTest.kt | 16 ++ java/README.md | 4 +- java/build.gradle.kts | 14 ++ 13 files changed, 734 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/package.yml create mode 100644 .github/workflows/verify-published.yml create mode 100644 consumer-test/.gitignore create mode 100644 consumer-test/build.gradle.kts create mode 100644 consumer-test/readme_sync.py create mode 100755 consumer-test/run.sh create mode 100644 consumer-test/settings.gradle.kts create mode 100644 consumer-test/src/test/java/ContractTest.java create mode 100644 consumer-test/src/test/kotlin/QuickstartTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91d55a6..eb15d48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, 'v*.x'] pull_request: workflow_call: inputs: @@ -11,6 +11,11 @@ on: required: false type: string default: '' + version: + description: 'Version stamped into the jar (release.yml passes the release version)' + required: false + type: string + default: '0.0.0-ci' jobs: rust-test: @@ -47,3 +52,152 @@ jobs: - name: Run tests working-directory: java run: ./gradlew test + + # Runs the Java tests on musl libc, so NativeLoader's musl detection and the + # linux-musl native are exercised at runtime rather than only built. + java-test-alpine: + name: Java tests (Alpine / musl) + runs-on: ubuntu-latest + container: eclipse-temurin:17-jdk-alpine + env: + # musl defaults to crt-static, under which rustc drops the cdylib output. + RUSTFLAGS: -C target-feature=-crt-static + steps: + - name: Install build dependencies + run: apk add --no-cache bash curl gcc git musl-dev + + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + # release.yml runs this workflow from main against the pinned commit, which + # for a v2.x core patch is on the v2.x branch. v2.x predates musl support + # (no musl detection in copyLocalNative or NativeLoader), so there is + # nothing to test on Alpine there. + - name: Check musl support in tested commit + id: musl + run: | + if grep -q 'ld-musl' java/build.gradle.kts; then + echo "supported=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Tested commit has no musl support; skipping Alpine tests." + echo "supported=false" >> "$GITHUB_OUTPUT" + fi + + - name: Install Rust + if: steps.musl.outputs.supported == 'true' + run: | + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Build Rust library (release) + if: steps.musl.outputs.supported == 'true' + run: cargo build --release + + - name: Generate Kotlin bindings + copy local native + if: steps.musl.outputs.supported == 'true' + working-directory: java + run: | + ./gradlew generateKotlinBindings copyLocalNative + # Fails if musl detection sent the native to the glibc directory. + test -f src/main/resources/natives/linux-musl/x86_64/libminigraf_ffi.so + + - name: Run tests + if: steps.musl.outputs.supported == 'true' + working-directory: java + run: ./gradlew test + + # Builds every native and the fat JAR, as the release publishes them. + # release.yml calls this workflow, so the jar is built and consumer-tested at + # the release version from the tested commit before anything is tagged. + package: + name: Package + uses: ./.github/workflows/package.yml + with: + ref: ${{ inputs.ref }} + version: ${{ inputs.version || '0.0.0-ci' }} + + # A Gradle project that depends on the built jar by Maven coordinates runs + # the README quick start (Kotlin) and the consumer contract (Java) on every + # platform the jar ships a native for. + consumer: + name: Consumer test (${{ matrix.name }}) + needs: package + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + strategy: + fail-fast: false + matrix: + include: + - { name: linux-x86_64, os: ubuntu-latest, java: '17', distribution: temurin } + - { name: linux-aarch64, os: ubuntu-24.04-arm, java: '17', distribution: temurin } + - { name: macos, os: macos-14, java: '17', distribution: temurin } + - { name: windows-x86_64, os: windows-latest, java: '17', distribution: temurin } + # Temurin has no Windows arm64 build of 17. + - { name: windows-aarch64, os: windows-11-arm, java: '17', distribution: microsoft } + # The image brings its JDK; musl, so the linux-musl native is loaded. + - { name: alpine, os: ubuntu-latest, container: 'eclipse-temurin:17-jdk-alpine' } + steps: + - name: Install bash and curl + if: matrix.container + run: apk add --no-cache bash curl + + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - uses: actions/setup-java@v4 + if: ${{ !matrix.container }} + with: + java-version: ${{ matrix.java }} + distribution: ${{ matrix.distribution }} + + - uses: actions/download-artifact@v4 + with: + name: dist-jar + path: dist-jar + + - name: Run consumer test + shell: bash + run: bash consumer-test/run.sh local dist-jar ${{ inputs.version || '0.0.0-ci' }} + + # 2.0.x patch releases build the v2.x branch with main's workflows + # (release.yml selects the base branch). On a PR to main, package and + # consumer-test v2.x with this PR's workflows, so a workflow change that + # breaks the v2.x release fails here rather than at release time. + package-v2x: + name: Package v2.x + if: github.event_name == 'pull_request' && github.base_ref == 'main' + uses: ./.github/workflows/package.yml + with: + ref: v2.x + version: 0.0.0-ci + artifact-prefix: v2x- + + consumer-v2x: + name: Consumer test v2.x (linux-x86_64) + needs: package-v2x + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: v2.x + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: temurin + - uses: actions/download-artifact@v4 + with: + name: v2x-dist-jar + path: dist-jar + - name: Run consumer test + run: bash consumer-test/run.sh local dist-jar 0.0.0-ci + + readme-sync: + name: README quick start matches the consumer test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - run: python3 -I consumer-test/readme_sync.py README.md consumer-test/src/test/kotlin/QuickstartTest.kt diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..3d67292 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,222 @@ +name: Package + +# Builds the fat JAR exactly as the release publishes it, stamped with +# `version`, and uploads: +# native- each platform's native library, copied into the jar; +# the release's publish job rebuilds the jar from these +# dist-jar the Maven repository layout (build/m2): jar, sources and +# javadoc jars, pom, module +# Unsigned: the release rebuilds from the same commit, checks the jars are +# byte-identical to the tested ones, then signs and publishes. +# +# release.yml also runs this, through ci.yml, for v2.x patch releases with +# `ref` on the v2.x branch, so it must build that branch's sources too. + +on: + workflow_call: + inputs: + ref: + required: false + type: string + default: '' + version: + required: true + type: string + artifact-prefix: + description: 'Prefix for the uploaded artifact names, so one run can package two refs' + required: false + type: string + default: '' + +jobs: + natives: + name: Build native (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # Linux targets use cargo-zigbuild to pin an old glibc floor (2.17) + # instead of the runner's, so RHEL 8, Debian 11 etc. can load them. + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + zig-target: x86_64-unknown-linux-gnu.2.17 + native-dir: linux/x86_64 + lib-name: libminigraf_ffi.so + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + zig-target: aarch64-unknown-linux-gnu.2.17 + native-dir: linux/aarch64 + lib-name: libminigraf_ffi.so + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + zig-target: x86_64-unknown-linux-musl + native-dir: linux-musl/x86_64 + lib-name: libminigraf_ffi.so + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + zig-target: aarch64-unknown-linux-musl + native-dir: linux-musl/aarch64 + lib-name: libminigraf_ffi.so + - os: macos-14 + target: universal2 + native-dir: macos/universal + lib-name: libminigraf_ffi.dylib + - os: windows-latest + target: x86_64-pc-windows-msvc + native-dir: windows/x86_64 + lib-name: minigraf_ffi.dll + - os: windows-latest + target: aarch64-pc-windows-msvc + native-dir: windows/aarch64 + lib-name: minigraf_ffi.dll + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target == 'universal2' && 'aarch64-apple-darwin x86_64-apple-darwin' || matrix.target }} + + - uses: actions/setup-python@v5 + if: matrix.zig-target + with: + python-version: '3.x' + + - name: Install zig and cargo-zigbuild + if: matrix.zig-target + run: pip install ziglang cargo-zigbuild + + - name: Build (universal2) + if: matrix.target == 'universal2' + run: | + cargo build --release --target aarch64-apple-darwin + cargo build --release --target x86_64-apple-darwin + mkdir -p out + lipo -create \ + target/aarch64-apple-darwin/release/libminigraf_ffi.dylib \ + target/x86_64-apple-darwin/release/libminigraf_ffi.dylib \ + -output out/libminigraf_ffi.dylib + + - name: Build (Linux, zigbuild) + if: matrix.zig-target + # musl defaults to crt-static, under which rustc drops the cdylib output. + env: + RUSTFLAGS: ${{ contains(matrix.target, 'musl') && '-C target-feature=-crt-static' || '' }} + run: | + cargo zigbuild --release --lib --target ${{ matrix.zig-target }} + mkdir -p out + cp target/${{ matrix.target }}/release/${{ matrix.lib-name }} out/ + + - name: Build (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + cargo build --release --lib --target ${{ matrix.target }} + mkdir -p out + cp target/${{ matrix.target }}/release/${{ matrix.lib-name }} out/ + + - name: Show dynamic dependencies + if: runner.os == 'Linux' + run: readelf -d out/${{ matrix.lib-name }} | grep NEEDED + + - name: Check glibc floor + if: endsWith(matrix.target, '-linux-gnu') + run: | + MAX=$(objdump -T out/${{ matrix.lib-name }} | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -uV | tail -1) + echo "Highest required glibc: $MAX" + if [ "$(printf '%s\n2.17\n' "$MAX" | sort -V | tail -1)" != "2.17" ]; then + echo "::error::${{ matrix.lib-name }} requires glibc $MAX, above the 2.17 floor" + exit 1 + fi + + - name: Upload native + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-prefix }}native-${{ matrix.target }} + path: out/${{ matrix.lib-name }} + if-no-files-found: error + + jar: + name: Assemble fat JAR + needs: natives + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - uses: dtolnay/rust-toolchain@stable + + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Download all natives + uses: actions/download-artifact@v4 + with: + pattern: ${{ inputs.artifact-prefix }}native-* + path: natives-staging + merge-multiple: false + + - name: Copy natives into resources + run: | + RESOURCES=java/src/main/resources/natives + # :; must match NativeLoader.resolve(). + for pair in \ + x86_64-unknown-linux-gnu:linux/x86_64 \ + aarch64-unknown-linux-gnu:linux/aarch64 \ + x86_64-unknown-linux-musl:linux-musl/x86_64 \ + aarch64-unknown-linux-musl:linux-musl/aarch64 \ + universal2:macos/universal \ + x86_64-pc-windows-msvc:windows/x86_64 \ + aarch64-pc-windows-msvc:windows/aarch64; do + target="${pair%%:*}"; dir="${pair#*:}" + mkdir -p "$RESOURCES/$dir" + cp "natives-staging/${{ inputs.artifact-prefix }}native-$target/"* "$RESOURCES/$dir/" + done + find "$RESOURCES" -type f | sort + + - name: Build Rust library for bindgen + run: cargo build --release + + - name: Build and publish to build/m2 + working-directory: java + env: + RELEASE_VERSION: ${{ inputs.version }} + run: ./gradlew --no-daemon generateKotlinBindings publishReleasePublicationToDistRepository + + - name: Check the jar contents + env: + VERSION: ${{ inputs.version }} + run: | + DIR=java/build/m2/io/github/project-minigraf/minigraf-jvm/$VERSION + JAR=$DIR/minigraf-jvm-$VERSION.jar + unzip -l "$JAR" | awk '{print $4}' | grep -q '/MiniGrafDb.class$' \ + || { echo "::error::the jar has no MiniGrafDb class"; exit 1; } + NATIVES=$(unzip -l "$JAR" | awk '{print $4}' | grep '^natives/.*\.\(so\|dylib\|dll\)$' | sort) + echo "$NATIVES" + [ "$(echo "$NATIVES" | wc -l)" -eq 7 ] || { echo "::error::expected 7 natives in the jar"; exit 1; } + grep -q 'jna' "$DIR/minigraf-jvm-$VERSION.pom" \ + || { echo "::error::pom does not declare JNA"; exit 1; } + + # The release rebuilds the jars from the same commit and refuses to + # publish unless they are byte-identical to these. Prove here, on every + # PR, that a clean rebuild is identical. + - name: Check the build is reproducible + working-directory: java + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + DIR=io/github/project-minigraf/minigraf-jvm/$RELEASE_VERSION + cp -r "build/m2/$DIR" "$RUNNER_TEMP/first" + ./gradlew --no-daemon clean generateKotlinBindings publishReleasePublicationToDistRepository + diff -r "$RUNNER_TEMP/first" "build/m2/$DIR" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-prefix }}dist-jar + path: java/build/m2 + if-no-files-found: error diff --git a/.github/workflows/verify-published.yml b/.github/workflows/verify-published.yml new file mode 100644 index 0000000..5c5e0a1 --- /dev/null +++ b/.github/workflows/verify-published.yml @@ -0,0 +1,109 @@ +name: Verify published + +# Installs minigraf-jvm from Maven Central, as a user's app +# would, and runs the consumer test on it. A failure means a broken release is +# public: the run fails and an issue labelled release-broken is opened (or +# commented on, if one is open for this version). + +on: + workflow_call: + inputs: + version: + required: true + type: string + ref: + description: 'Ref whose consumer-test/ to run (release.yml passes the release tag)' + required: false + type: string + default: '' + workflow_dispatch: + inputs: + version: + description: 'Published version (e.g. 2.0.9)' + required: true + type: string + ref: + description: 'Ref whose consumer-test/ to run (default: the branch dispatched from)' + required: false + type: string + default: '' + +permissions: + contents: read + issues: write + +jobs: + wait: + name: Wait for Maven Central + runs-on: ubuntu-latest + steps: + - name: Wait for the pom (up to 90 minutes) + env: + VERSION: ${{ inputs.version }} + run: | + URL="https://repo1.maven.org/maven2/io/github/project-minigraf/minigraf-jvm/$VERSION/minigraf-jvm-$VERSION.pom" + for i in $(seq 1 180); do + curl -sfI -o /dev/null "$URL" && { echo "available after $i checks"; exit 0; } + sleep 30 + done + echo "::error::$URL did not appear within 90 minutes" + exit 1 + + verify: + name: Consumer test from Maven Central (${{ matrix.name }}) + needs: wait + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + strategy: + fail-fast: false + matrix: + include: + - { name: linux-x86_64, os: ubuntu-latest, java: '17', distribution: temurin } + - { name: linux-aarch64, os: ubuntu-24.04-arm, java: '17', distribution: temurin } + - { name: macos, os: macos-14, java: '17', distribution: temurin } + - { name: windows-x86_64, os: windows-latest, java: '17', distribution: temurin } + - { name: windows-aarch64, os: windows-11-arm, java: '17', distribution: microsoft } + - { name: alpine, os: ubuntu-latest, container: 'eclipse-temurin:17-jdk-alpine' } + steps: + - name: Install bash and curl + if: matrix.container + run: apk add --no-cache bash curl + + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - uses: actions/setup-java@v4 + if: ${{ !matrix.container }} + with: + java-version: ${{ matrix.java }} + distribution: ${{ matrix.distribution }} + + - name: Run consumer test + shell: bash + run: bash consumer-test/run.sh registry ${{ inputs.version }} + + report: + name: Report broken release + needs: [wait, verify] + if: failure() + runs-on: ubuntu-latest + steps: + - name: Open or update the release-broken issue + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + VERSION: ${{ inputs.version }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh label create release-broken --repo "$REPO" --color B60205 \ + --description "A published release does not install or run" --force + TITLE="v$VERSION published but unusable: consumer test failed (Maven Central)" + BODY="The consumer test failed against minigraf-jvm $VERSION installed from Maven Central. Run: $RUN_URL" + NUMBER=$(gh issue list --repo "$REPO" --label release-broken --state open \ + --search "\"v$VERSION published\" in:title" --json number -q '.[0].number') + if [ -n "$NUMBER" ]; then + gh issue comment "$NUMBER" --repo "$REPO" --body "$BODY" + else + gh issue create --repo "$REPO" --title "$TITLE" --label bug --label release-broken --body "$BODY" + fi diff --git a/README.md b/README.md index 7927ce9..5e09b56 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ single-file, embedded bi-temporal graph database with Datalog queries. ```kotlin dependencies { - implementation("io.github.project-minigraf:minigraf-jvm:1.2.0") + implementation("io.github.project-minigraf:minigraf-jvm:2.0.8") } ``` @@ -19,18 +19,18 @@ dependencies { io.github.project-minigraf minigraf-jvm - 1.2.0 + 2.0.8 ``` ## Quick start ```kotlin -import io.github.project_minigraf.minigraf.MiniGrafDb +import uniffi.minigraf_ffi.MiniGrafDb val db = MiniGrafDb.openInMemory() val result = db.execute("""(transact [[:alice :name "Alice"]])""") -println(result) // {"transacted":1} +println(result) // {"transacted":} ``` ## Building from source diff --git a/consumer-test/.gitignore b/consumer-test/.gitignore new file mode 100644 index 0000000..5cc008e --- /dev/null +++ b/consumer-test/.gitignore @@ -0,0 +1,3 @@ +build/ +.gradle/ +.kotlin/ diff --git a/consumer-test/build.gradle.kts b/consumer-test/build.gradle.kts new file mode 100644 index 0000000..122b2dd --- /dev/null +++ b/consumer-test/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + kotlin("jvm") version "2.0.21" +} + +val minigrafVersion: String = providers.gradleProperty("minigrafVersion").get() + +dependencies { + implementation("io.github.project-minigraf:minigraf-jvm:$minigrafVersion") + testImplementation(kotlin("test")) + testImplementation("org.junit.jupiter:junit-jupiter:5.11.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +// Whatever JDK runs Gradle (17 or later) compiles and runs the tests. +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} +tasks.withType().configureEach { + options.release.set(17) +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "failed", "skipped") + showStandardStreams = true + } +} diff --git a/consumer-test/readme_sync.py b/consumer-test/readme_sync.py new file mode 100644 index 0000000..28491d0 --- /dev/null +++ b/consumer-test/readme_sync.py @@ -0,0 +1,56 @@ +"""Check that the README quick start is what the consumer test runs. + +Usage: python3 -I readme_sync.py README.md consumer-test/ + +Takes the first fenced code block under "## Quick start" in the README. Every +non-blank line of it, with indentation trimmed, must appear in the consumer +file in the same order. The consumer may add lines around them (a package, +more imports, a test wrapper, assertions), but it cannot drop or reorder a +README line, so a README naming a symbol the package does not have fails CI. +Shared by every binding repo; the canonical copy is in +minigraf-binding-template. +""" + +import sys + + +def quick_start_block(readme): + lines = readme.splitlines() + try: + start = next(i for i, l in enumerate(lines) if l.strip().lower() == "## quick start") + except StopIteration: + sys.exit("README has no '## Quick start' section") + block, inside = [], False + for line in lines[start + 1:]: + if line.startswith("## ") and not inside: + break + if line.lstrip().startswith("```"): + if inside: + return block + inside = True + continue + if inside: + block.append(line) + sys.exit("'## Quick start' has no complete fenced code block") + + +def main(): + readme_path, consumer_path = sys.argv[1], sys.argv[2] + with open(readme_path, encoding="utf-8") as f: + wanted = [l.strip() for l in quick_start_block(f.read()) if l.strip()] + with open(consumer_path, encoding="utf-8") as f: + have = [l.strip() for l in f.read().splitlines()] + pos = 0 + for line in wanted: + try: + pos = have.index(line, pos) + 1 + except ValueError: + sys.exit( + f"{consumer_path} does not run the README quick start: " + f"no line {line!r} after line {pos}" + ) + print(f"README quick start ({len(wanted)} lines) matches {consumer_path}") + + +if __name__ == "__main__": + main() diff --git a/consumer-test/run.sh b/consumer-test/run.sh new file mode 100755 index 0000000..ca911b4 --- /dev/null +++ b/consumer-test/run.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Consumer test: a Gradle project that depends on minigraf-jvm only through its +# Maven coordinates runs the README quick start (Kotlin) and the consumer +# contract (Java) on this machine's JVM and native library. +# +# run.sh local from built artifacts (the dist-jar layout) +# run.sh registry from Maven Central, once it has +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(dirname "$HERE")" +GROUP_PATH=io/github/project-minigraf/minigraf-jvm + +# wait_for : run the command every 30 s until it succeeds. +wait_for() { + local limit="$1"; shift + local start=$SECONDS + until "$@"; do + if (( SECONDS - start >= limit )); then + echo "::error::gave up after ${limit}s waiting for: $*" + return 1 + fi + sleep 30 + done +} + +ARGS=() +case "${1:-}" in + local) + REPO="$(cd "$2" && pwd)" + VERSION="$3" + # Gradle wants a URI; on Windows (Git Bash) turn /d/a/... into D:/a/... + if command -v cygpath >/dev/null; then REPO="$(cygpath -m "$REPO")"; fi + ARGS+=("-PminigrafRepo=$REPO") + ;; + registry) + VERSION="$2" + # Maven Central sync is the slowest of the registries (spec §6). + wait_for 5400 curl -sfI -o /dev/null \ + "https://repo1.maven.org/maven2/$GROUP_PATH/$VERSION/minigraf-jvm-$VERSION.pom" + ;; + *) + echo "usage: $0 local | registry " >&2 + exit 2 + ;; +esac +ARGS+=("-PminigrafVersion=$VERSION") + +gradle() { "$ROOT/java/gradlew" -p "$HERE" --no-daemon "${ARGS[@]}" "$@"; } + +# The resolved dependency is the version under test, and its pom brings JNA. +DEPS="$(gradle -q dependencies --configuration runtimeClasspath)" +echo "$DEPS" +grep -q "io.github.project-minigraf:minigraf-jvm:$VERSION\b" <<<"$DEPS" \ + || { echo "::error::minigraf-jvm:$VERSION is not on the runtime classpath"; exit 1; } +grep -q "net.java.dev.jna:jna:" <<<"$DEPS" \ + || { echo "::error::minigraf-jvm does not bring JNA"; exit 1; } + +gradle test diff --git a/consumer-test/settings.gradle.kts b/consumer-test/settings.gradle.kts new file mode 100644 index 0000000..8b8d335 --- /dev/null +++ b/consumer-test/settings.gradle.kts @@ -0,0 +1,20 @@ +// Consumer test project: depends on minigraf-jvm only through its Maven +// coordinates, as a user's project does. See run.sh. +val minigrafRepo: String? = providers.gradleProperty("minigrafRepo").orNull + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + if (minigrafRepo != null) { + // Local mode: minigraf-jvm comes only from the built artifacts, + // never from Maven Central, even if that version is already there. + exclusiveContent { + forRepository { maven { url = uri(minigrafRepo) } } + filter { includeGroup("io.github.project-minigraf") } + } + } + mavenCentral() + } +} + +rootProject.name = "minigraf-jvm-consumer" diff --git a/consumer-test/src/test/java/ContractTest.java b/consumer-test/src/test/java/ContractTest.java new file mode 100644 index 0000000..fee4bba --- /dev/null +++ b/consumer-test/src/test/java/ContractTest.java @@ -0,0 +1,44 @@ +// The consumer contract every binding checks (spec §4), from Java: in memory, +// file-backed across a reopen, and the error type for invalid Datalog. +// 2.x: the generated classes are in uniffi.minigraf_ffi, with no Java facade. +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import uniffi.minigraf_ffi.MiniGrafDb; +import uniffi.minigraf_ffi.MiniGrafException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +class ContractTest { + private static final String QUERY = "(query [:find ?n :where [?e :name ?n]])"; + + @Test + void inMemory() throws Exception { + try (MiniGrafDb db = MiniGrafDb.Companion.openInMemory()) { + db.execute("(transact [[:alice :name \"Alice\"]])"); + String result = db.execute(QUERY); + assertTrue(result.contains("[[\"Alice\"]]"), "query in memory returns [[\"Alice\"]]"); + } + } + + @Test + void fileBackedSurvivesReopen() throws Exception { + Path dir = Files.createTempDirectory("minigraf-contract"); + String path = dir.resolve("test.graph").toString(); + try (MiniGrafDb db = MiniGrafDb.Companion.open(path)) { + db.execute("(transact [[:bob :name \"Bob\"]])"); + db.checkpoint(); + } + try (MiniGrafDb db = MiniGrafDb.Companion.open(path)) { + assertTrue(db.execute(QUERY).contains("[[\"Bob\"]]"), "query after reopen returns [[\"Bob\"]]"); + } + } + + @Test + void invalidDatalogThrowsMiniGrafException() throws Exception { + try (MiniGrafDb db = MiniGrafDb.Companion.openInMemory()) { + assertThrows(MiniGrafException.class, () -> db.execute("(query [:find")); + } + } +} diff --git a/consumer-test/src/test/kotlin/QuickstartTest.kt b/consumer-test/src/test/kotlin/QuickstartTest.kt new file mode 100644 index 0000000..089c9f3 --- /dev/null +++ b/consumer-test/src/test/kotlin/QuickstartTest.kt @@ -0,0 +1,16 @@ +// The README quick start, line for line (readme_sync.py checks this), wrapped +// in a test. +import uniffi.minigraf_ffi.MiniGrafDb + +import kotlin.test.Test +import kotlin.test.assertTrue + +class QuickstartTest { + @Test + fun readmeQuickStart() { + val db = MiniGrafDb.openInMemory() + val result = db.execute("""(transact [[:alice :name "Alice"]])""") + println(result) // {"transacted":} + assertTrue(result.startsWith("{\"transacted\":")) + } +} diff --git a/java/README.md b/java/README.md index 2a97b99..cd77a77 100644 --- a/java/README.md +++ b/java/README.md @@ -13,7 +13,7 @@ Minigraf for Java and Kotlin on the desktop JVM. Fat JAR with embedded native li ```kotlin dependencies { - implementation("io.github.project-minigraf:minigraf-jvm:1.1.1") + implementation("io.github.project-minigraf:minigraf-jvm:2.0.8") } ``` @@ -23,7 +23,7 @@ dependencies { io.github.project-minigraf minigraf-jvm - 1.1.1 + 2.0.8 ``` diff --git a/java/build.gradle.kts b/java/build.gradle.kts index cff2a5e..e2f5cb2 100644 --- a/java/build.gradle.kts +++ b/java/build.gradle.kts @@ -131,7 +131,21 @@ java { withJavadocJar() } +// Reproducible archives: the release rebuilds the jars from the tested commit +// and refuses to publish unless they are byte-identical to the tested ones. +tasks.withType().configureEach { + isPreserveFileTimestamps = false + isReproducibleFileOrder = true +} + publishing { + // build/m2: the layout CI consumer-tests and the release compares against. + repositories { + maven { + name = "dist" + url = uri(layout.buildDirectory.dir("m2")) + } + } publications { create("release") { from(components["java"]) From 6c13a028e09721db42141208c36374e57bd71c04 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Wed, 21 Oct 2026 00:58:51 +0530 Subject: [PATCH 2/2] fix: load the musl and Windows arm64 natives on 2.x (#13) The 2.x NativeLoader picked the glibc native on musl and the x86_64 DLL on Windows arm64, so the jar, which ships both natives, failed to load on Alpine and Windows arm64 (UnsatisfiedLinkError). Backports main's loader (java#5), which resolves by libc and architecture and caches the extracted native, with its tests (less the Java facade test; 2.x has no facade), and the matching copyLocalNative mapping. Found by the new consumer test's Alpine and windows-11-arm legs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PtCdzbJMSS1V8qEPaJ4jXz --- java/build.gradle.kts | 17 ++- .../project_minigraf/minigraf/NativeLoader.kt | 121 +++++++++++++----- .../minigraf/NativeLoaderTest.kt | 51 ++++++++ 3 files changed, 153 insertions(+), 36 deletions(-) create mode 100644 java/src/test/kotlin/io/github/project_minigraf/minigraf/NativeLoaderTest.kt diff --git a/java/build.gradle.kts b/java/build.gradle.kts index e2f5cb2..35649fa 100644 --- a/java/build.gradle.kts +++ b/java/build.gradle.kts @@ -110,14 +110,19 @@ tasks.withType().configureEac val copyLocalNative by tasks.registering(Copy::class) { group = "codegen" description = "Copy local platform native into resources (dev only)" + // Mirrors NativeLoader.resolve() so the loader finds the local native. val os = System.getProperty("os.name").lowercase() - val arch = System.getProperty("os.arch").lowercase() + val arch = when (System.getProperty("os.arch").lowercase()) { + "amd64", "x86_64", "x86-64" -> "x86_64" + "aarch64", "arm64" -> "aarch64" + else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}") + } + val musl = File("/lib").listFiles { f -> f.name.startsWith("ld-musl-") }?.isNotEmpty() == true val (osKey, nativeName) = when { - "linux" in os && ("aarch64" in arch || "arm64" in arch) -> - "linux/aarch64" to "libminigraf_ffi.so" - "linux" in os -> "linux/x86_64" to "libminigraf_ffi.so" - "mac" in os -> "macos/universal" to "libminigraf_ffi.dylib" - "windows" in os -> "windows/x86_64" to "minigraf_ffi.dll" + os.startsWith("linux") && musl -> "linux-musl/$arch" to "libminigraf_ffi.so" + os.startsWith("linux") -> "linux/$arch" to "libminigraf_ffi.so" + os.startsWith("mac") -> "macos/universal" to "libminigraf_ffi.dylib" + os.startsWith("windows") -> "windows/$arch" to "minigraf_ffi.dll" else -> throw GradleException("Unsupported platform: $os $arch") } from(File("$repoRoot/target/release/$nativeName")) diff --git a/java/src/main/kotlin/io/github/project_minigraf/minigraf/NativeLoader.kt b/java/src/main/kotlin/io/github/project_minigraf/minigraf/NativeLoader.kt index 37ecd22..575e89d 100644 --- a/java/src/main/kotlin/io/github/project_minigraf/minigraf/NativeLoader.kt +++ b/java/src/main/kotlin/io/github/project_minigraf/minigraf/NativeLoader.kt @@ -2,56 +2,117 @@ package io.github.project_minigraf.minigraf import java.io.File import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest /** - * Extracts the platform-appropriate native library from JAR resources and loads it. - * Call [load] before using any Minigraf class. It is idempotent. + * Extracts the platform-appropriate native library from JAR resources and points + * the UniFFI bindings at it. + * + * The generated bindings call [load] automatically before the native library is + * first resolved, so callers never need to invoke it. It is idempotent. */ object NativeLoader { + private const val ISSUES_URL = "https://github.com/project-minigraf/minigraf-java/issues" + @Volatile private var loaded = false + /** Resource directory and file name of the native library for one platform. */ + internal data class NativeLib(val dir: String, val fileName: String) + + /** + * Maps JVM platform properties to the bundled native, or `null` when no native + * is bundled for that platform. Pure, so it can be unit-tested for any platform. + */ + internal fun resolve(osName: String, osArch: String, isMusl: Boolean): NativeLib? { + val os = osName.lowercase() + val arch = when (osArch.lowercase()) { + "amd64", "x86_64", "x86-64" -> "x86_64" + "aarch64", "arm64" -> "aarch64" + else -> return null + } + return when { + os.startsWith("linux") && isMusl -> NativeLib("linux-musl/$arch", "libminigraf_ffi.so") + os.startsWith("linux") -> NativeLib("linux/$arch", "libminigraf_ffi.so") + os.startsWith("mac") -> NativeLib("macos/universal", "libminigraf_ffi.dylib") + os.startsWith("windows") -> NativeLib("windows/$arch", "minigraf_ffi.dll") + else -> null + } + } + + /** musl libc (e.g. Alpine) installs its dynamic loader as /lib/ld-musl-.so.1. */ + private fun isMusl(): Boolean = + File("/lib").listFiles { f -> f.name.startsWith("ld-musl-") }?.isNotEmpty() == true + @Synchronized fun load() { if (loaded) return - val os = System.getProperty("os.name").lowercase() - val arch = System.getProperty("os.arch").lowercase() - - val (osKey, libName) = when { - "linux" in os && ("aarch64" in arch || "arm64" in arch) -> - "linux/aarch64" to "libminigraf_ffi.so" - "linux" in os -> - "linux/x86_64" to "libminigraf_ffi.so" - "mac" in os -> - "macos/universal" to "libminigraf_ffi.dylib" - "windows" in os -> - "windows/x86_64" to "minigraf_ffi.dll" - else -> throw UnsupportedOperationException( - "Unsupported platform: $os / $arch. " + - "Please file an issue at https://github.com/project-minigraf/minigraf" + val osName = System.getProperty("os.name") + val osArch = System.getProperty("os.arch") + val isLinux = osName.lowercase().startsWith("linux") + val lib = resolve(osName, osArch, isLinux && isMusl()) + ?: throw UnsupportedOperationException( + "Unsupported platform: $osName / $osArch. " + + "Bundled natives cover Linux (glibc and musl) and Windows on x86_64/aarch64, " + + "and macOS universal2. Please file an issue at $ISSUES_URL" ) - } - val resourcePath = "/natives/$osKey/$libName" - val stream = NativeLoader::class.java.getResourceAsStream(resourcePath) + val resourcePath = "/natives/${lib.dir}/${lib.fileName}" + val bytes = NativeLoader::class.java.getResourceAsStream(resourcePath) + ?.use { it.readBytes() } ?: throw UnsatisfiedLinkError( "Native library not found in JAR: $resourcePath. " + - "Ensure you are using the correct platform JAR." + "This build of minigraf-jvm does not include a native for this platform; " + + "please file an issue at $ISSUES_URL" ) - val tmpDir = Files.createTempDirectory("minigraf_native") - tmpDir.toFile().deleteOnExit() - val dest = tmpDir.resolve(libName).toFile() - dest.deleteOnExit() - - stream.use { src -> - dest.outputStream().use { dst -> src.copyTo(dst) } - } + val isWindows = osName.lowercase().startsWith("windows") + val dest = if (isWindows) extractCached(bytes, lib.fileName) else extractTemp(bytes, lib.fileName) // JNA's Native.register() uses findLibraryName() which reads this property. // Providing an absolute path causes JNA to dlopen/LoadLibrary directly from - // the temp file, bypassing the system library search path. + // the extracted file, bypassing the system library search path. System.setProperty("uniffi.component.minigraf_ffi.libraryOverride", dest.absolutePath) loaded = true } + + /** Extracts into a fresh temp directory that is removed when the JVM exits. */ + private fun extractTemp(bytes: ByteArray, fileName: String): File { + val tmpDir = Files.createTempDirectory("minigraf_native").toFile() + tmpDir.deleteOnExit() + val dest = File(tmpDir, fileName) + dest.deleteOnExit() + dest.writeBytes(bytes) + return dest + } + + /** + * Windows cannot delete a DLL while it is loaded, so deleteOnExit would leave a + * directory behind on every run. Instead, extract once into a directory keyed + * by the library's hash and reuse it across JVMs. The existing file is only + * reused if its hash still matches. + */ + private fun extractCached(bytes: ByteArray, fileName: String): File { + val hash = sha256(bytes) + val dir = File(System.getProperty("java.io.tmpdir"), "minigraf-native-${hash.take(16)}") + val dest = File(dir, fileName) + if (dest.isFile && sha256(dest.readBytes()) == hash) return dest + + var part: File? = null + return try { + dir.mkdirs() + part = File.createTempFile("$fileName.", ".part", dir) + part.writeBytes(bytes) + // A concurrent JVM may have loaded dest, which locks it; the move then fails. + Files.move(part.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING) + dest + } catch (e: Exception) { + part?.delete() + if (dest.isFile && sha256(dest.readBytes()) == hash) dest else extractTemp(bytes, fileName) + } + } + + private fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } } diff --git a/java/src/test/kotlin/io/github/project_minigraf/minigraf/NativeLoaderTest.kt b/java/src/test/kotlin/io/github/project_minigraf/minigraf/NativeLoaderTest.kt new file mode 100644 index 0000000..04041f9 --- /dev/null +++ b/java/src/test/kotlin/io/github/project_minigraf/minigraf/NativeLoaderTest.kt @@ -0,0 +1,51 @@ +package io.github.project_minigraf.minigraf + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NativeLoaderTest { + + private fun dir(os: String, arch: String, musl: Boolean = false) = + NativeLoader.resolve(os, arch, musl)?.dir + + @Test + fun testLinuxGlibc() { + assertEquals("linux/x86_64", dir("Linux", "amd64")) + assertEquals("linux/x86_64", dir("Linux", "x86_64")) + assertEquals("linux/aarch64", dir("Linux", "aarch64")) + } + + @Test + fun testLinuxMusl() { + assertEquals("linux-musl/x86_64", dir("Linux", "amd64", musl = true)) + assertEquals("linux-musl/aarch64", dir("Linux", "aarch64", musl = true)) + } + + @Test + fun testMacosUsesUniversalBinary() { + assertEquals("macos/universal", dir("Mac OS X", "x86_64")) + assertEquals("macos/universal", dir("Mac OS X", "aarch64")) + } + + @Test + fun testWindows() { + assertEquals("windows/x86_64", dir("Windows 11", "amd64")) + assertEquals("windows/aarch64", dir("Windows 11", "aarch64")) + assertEquals("minigraf_ffi.dll", NativeLoader.resolve("Windows 11", "amd64", false)?.fileName) + } + + @Test + fun testUnsupportedArchitecturesAreRejected() { + for (arch in listOf("riscv64", "ppc64le", "s390x", "x86", "i386", "arm")) { + assertNull(dir("Linux", arch), "linux/$arch") + } + assertNull(dir("Windows 10", "x86")) + } + + @Test + fun testUnsupportedOsIsRejected() { + assertNull(dir("FreeBSD", "amd64")) + assertNull(dir("SunOS", "amd64")) + } +}