diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b12d3b..eb15d48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -102,114 +107,97 @@ jobs: working-directory: java run: ./gradlew test - # Builds every native the fat JAR ships. release.yml calls this workflow, so - # these artifacts are built from the tested commit before anything is tagged, - # and the publish job downloads them from the same run. - build-natives: - name: Build native (${{ matrix.target }}) + # 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: - # 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 + - { 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: dtolnay/rust-toolchain@stable + - uses: actions/setup-java@v4 + if: ${{ !matrix.container }} with: - targets: ${{ matrix.target == 'universal2' && 'aarch64-apple-darwin x86_64-apple-darwin' || matrix.target }} + java-version: ${{ matrix.java }} + distribution: ${{ matrix.distribution }} - - uses: actions/setup-python@v5 - if: matrix.zig-target + - uses: actions/download-artifact@v4 with: - python-version: '3.x' - - - name: Install zig and cargo-zigbuild - if: matrix.zig-target - run: pip install ziglang cargo-zigbuild + name: dist-jar + path: dist-jar - - 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' + - name: Run consumer test 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 + 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 - - name: Upload native - uses: actions/upload-artifact@v4 + readme-sync: + name: README quick start matches the consumer test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 with: - name: native-${{ matrix.target }} - path: out/${{ matrix.lib-name }} - if-no-files-found: error + 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/release.yml b/.github/workflows/release.yml index c8e0510..3082750 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,6 +82,13 @@ jobs: - name: Update Cargo.lock run: cargo update --precise "${{ steps.ver.outputs.semver }}" minigraf + - name: Stamp the version in the README install snippets + run: | + for f in README.md java/README.md; do + sed -i -e 's/\(minigraf-jvm:\)[^"]*"/\1${{ steps.ver.outputs.semver }}"/' \ + -e 's|[^<]*|${{ steps.ver.outputs.semver }}|' "$f" + done + # Push the pin commit to a throwaway branch only. The tag and the base # branch are updated by the tag job, after the test job has passed on this commit. - name: Commit and push release branch @@ -89,7 +96,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock + git add Cargo.toml Cargo.lock README.md java/README.md if ! git diff --cached --quiet; then git commit -m "chore: release ${{ steps.ver.outputs.tag }}" else @@ -108,6 +115,9 @@ jobs: uses: ./.github/workflows/ci.yml with: ref: ${{ needs.prepare.outputs.sha }} + # The jar is built and consumer-tested here at the release version; + # publish rebuilds it from the same commit and checks it matches. + version: ${{ needs.prepare.outputs.semver }} tag: name: Push tag and update ${{ needs.prepare.outputs.base }} @@ -187,9 +197,32 @@ jobs: - name: Build Rust library for bindgen run: cargo build --release - - name: Generate Kotlin bindings + - uses: actions/download-artifact@v4 + with: + name: dist-jar + path: tested + + # nmcp publishes from the Gradle publication, so the jars are rebuilt + # here from the tag (the tested commit) and must be byte-identical to + # the ones the consumer test ran. package.yml checks on every PR that + # the build is reproducible. + - name: Rebuild and compare with the tested jars working-directory: java - run: ./gradlew generateKotlinBindings + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.semver }} + run: | + ./gradlew --no-daemon generateKotlinBindings publishReleasePublicationToDistRepository + DIR=io/github/project-minigraf/minigraf-jvm/$RELEASE_VERSION + # Same files, then same bytes. Signing is off in this step; the + # publish step adds only the .asc signatures. + diff <(ls "../tested/$DIR") <(ls "build/m2/$DIR") \ + || { echo "::error::the rebuild publishes a different set of files"; exit 1; } + for f in ../tested/$DIR/*; do + case "$f" in *.md5|*.sha1|*.sha256|*.sha512) continue ;; esac + cmp "$f" "build/m2/$DIR/$(basename "$f")" \ + || { echo "::error::$(basename "$f") differs from the tested artifact"; exit 1; } + done + echo "Rebuilt artifacts are identical to the tested ones" - name: Publish to Maven Central working-directory: java @@ -201,3 +234,15 @@ jobs: RELEASE_VERSION: ${{ needs.prepare.outputs.semver }} run: ./gradlew publishAllPublicationsToCentralPortal + # Installs the published version from Maven Central and runs the consumer + # test; on failure the run fails and a release-broken issue is opened. + verify-published: + name: Verify the published jar + needs: [prepare, assemble-and-publish] + permissions: + contents: read + issues: write + uses: ./.github/workflows/verify-published.yml + with: + version: ${{ needs.prepare.outputs.semver }} + ref: ${{ needs.prepare.outputs.tag }} 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 9dfdd84..ff4cbae 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:2.0.2") + implementation("io.github.project-minigraf:minigraf-jvm:2.0.8") } ``` @@ -19,7 +19,7 @@ dependencies { io.github.project-minigraf minigraf-jvm - 2.0.2 + 2.0.8 ``` @@ -30,7 +30,7 @@ import io.github.project_minigraf.minigraf.MiniGrafDb val db = MiniGrafDb.openInMemory() val result = db.execute("""(transact [[:alice :name "Alice"]])""") -println(result) // {"transacted":1} +println(result) // {"transacted":} ``` From Java, use `io.github.project_minigraf.minigraf.Minigraf.openInMemory()` / `Minigraf.open(path)`. 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..a2d765b --- /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. +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.project_minigraf.minigraf.MiniGrafDb; +import io.github.project_minigraf.minigraf.MiniGrafException; +import io.github.project_minigraf.minigraf.Minigraf; +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 = Minigraf.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 = Minigraf.open(path)) { + db.execute("(transact [[:bob :name \"Bob\"]])"); + db.checkpoint(); + } + try (MiniGrafDb db = Minigraf.open(path)) { + assertTrue(db.execute(QUERY).contains("[[\"Bob\"]]"), "query after reopen returns [[\"Bob\"]]"); + } + } + + @Test + void invalidDatalogThrowsMiniGrafException() throws Exception { + try (MiniGrafDb db = Minigraf.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..702bb38 --- /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 io.github.project_minigraf.minigraf.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 5d9f650..592d0e4 100644 --- a/java/README.md +++ b/java/README.md @@ -24,7 +24,7 @@ On any other platform, the first call fails with an `UnsupportedOperationExcepti ```kotlin dependencies { - implementation("io.github.project-minigraf:minigraf-jvm:2.0.2") + implementation("io.github.project-minigraf:minigraf-jvm:2.0.8") } ``` @@ -34,7 +34,7 @@ dependencies { io.github.project-minigraf minigraf-jvm - 2.0.2 + 2.0.8 ``` diff --git a/java/build.gradle.kts b/java/build.gradle.kts index f434d04..344e81e 100644 --- a/java/build.gradle.kts +++ b/java/build.gradle.kts @@ -148,6 +148,13 @@ 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 +} + tasks.jar { manifest { attributes( @@ -159,6 +166,13 @@ tasks.jar { } 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"])