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..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")) @@ -131,7 +136,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"]) 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")) + } +}