Publishing zenoh-java

August 11, 2026 · View on GitHub

This document describes how zenoh-java is built, verified, and published to Maven Central, and how to rehearse a release without publishing one.

It describes the pipeline as it exists in this repository. Where something is not yet implemented or not yet exercised, it is listed under Known gaps rather than described as if it worked.

If you just need to run a release, go to Running a release. For the JVM publishing concepts — coordinates, staging, signing — see zenoh-flat-jni's PUBLISHING.md, which covers them once for both repositories.

Contents

What this repository publishes

org.eclipse.zenoh:zenoh-java:<version>          the JVM artifact
org.eclipse.zenoh:zenoh-java-android:<version>  the Android artifact

Both are pure JVM/Kotlin. This repository contains no Rust and builds no native libraries: they arrive inside the zenoh-flat-jni artifacts, already cross-compiled and verified by that repository's own release, and a consumer of zenoh-java gets them transitively.

zenoh-flat-jni is itself a Kotlin Multiplatform library, so this SDK declares one dependency on its root coordinate and Gradle resolves the variant matching each target:

zenoh-java artifactresolveswhich carries
zenoh-javazenoh-flat-jni-jvmsix desktop targets
zenoh-java-androidzenoh-flat-jni-androidfour Android ABIs, as jni/<abi>/

Nothing selects between them by hand, so a publication cannot name the wrong one. And because one Gradle invocation produces both publications correctly, they are uploaded into a single staging repository and released together.

That automatic resolution applies to the dependency, not to this SDK itself. zenoh-java keeps the two plain coordinates above: the release publishes the jvm and androidRelease publications only, never the Kotlin Multiplatform root module, so there is no module metadata for a consumer to resolve against and an Android consumer must name zenoh-java-android explicitly. That is deliberate — it keeps org.eclipse.zenoh:zenoh-java meaning the JVM artifact, as it always has. It is also why the publish workflow names its two publication tasks instead of using publishAllPublicationsTo…: the unpublished root publication carries the same zenoh-java artifactId as the JVM one, and publishing both would upload two different things to one coordinate.

That is the whole reason the publishing here is simple — there is no build matrix, no cross-compilation, and no native artifact to inspect.

Relationship to zenoh-flat-jni

zenoh-flat-jni:<version> released to Maven Central
                  |
                  v
        zenoh-java built against it
                  |
                  v
        zenoh-java:<version> released

The order is not a convention, it is a constraint. zenoh-java cannot be released until the zenoh-flat-jni version it depends on is really on Maven Central, because a release must not depend on a snapshot:

  • consumers do not have the Central snapshot repository configured, so the dependency would simply fail to resolve for them;
  • snapshots are mutable and are eventually removed, so even where it resolved it would not stay reproducible.

ci/scripts/bump-and-tag.bash refuses to let that reach a published POM. It checks the value gradle.properties ends up with, not the workflow input: between releases that file names a snapshot, so omitting the input is the way a release would reach one.

Rehearsals are not constrained this way — see Rehearsing before zenoh-flat-jni is released.

The snapshot publication

Between releases, every merge to main uploads a mutable pre-release build to the Central snapshot repository. Its purpose is to keep the upload machinery exercised — signing keys, credentials, what Central accepts — and to give people a way to try the current main.

It publishes five coordinates, not two:

org.eclipse.zenoh:zenoh-java:<version>-SNAPSHOT
org.eclipse.zenoh:zenoh-java-android:<version>-SNAPSHOT
org.eclipse.zenoh:zenoh-flat-jni:1.9.0-java-SNAPSHOT          (+ -jvm, -android)

The last three are our own copy of zenoh-flat-jni, built from the commit Cargo.lock pins. Publishing what we depend on is what makes the snapshot both self-sufficient and coherent:

  • self-sufficient — if zenoh-flat-jni's CI were switched off entirely, this publication still works. It uses that repository's source at a commit we choose, never an artifact its CI produced.
  • coherent — the dependency our POM names is the code we compiled against. Pointing instead at zenoh-flat-jni's own 1.9.0-SNAPSHOT would name the tip of its main while we compiled against our pin; JNI being a binary contract, that mismatch surfaces as UnsatisfiedLinkError at runtime rather than as a build failure.

The -java qualifier keeps our copy from overwriting the one zenoh-flat-jni publishes itself, or zenoh-kotlin's — the three can legitimately pin different commits at the same moment. The names are fixed rather than derived from a commit, so each is overwritten in place and storage does not grow with the number of builds. (Central still stores snapshots as timestamped builds and cleans them after 90 days, so it is the consumer-facing name that is constant, not the bytes behind it.)

Rebuilding that copy means cross-compiling ten targets, on the order of half an hour, and the pin moves roughly once a day — so it is rebuilt only when it has to be. Every POM zenoh-flat-jni publishes carries the commit it was built from:

$ curl -s .../1.9.0-java-SNAPSHOT/maven-metadata.xml           # ~2.9 kB
$ curl -s .../zenoh-flat-jni-1.9.0-java-<timestamp>-<n>.pom    # ~1.8 kB
<zenoh.flatJniCommit>e75529ce…</zenoh.flatJniCommit>

ci/scripts/flat-jni-copy.bash reads that stamp from all three coordinates and compares it with the pin; anything missing or different means rebuild. Run it locally to see the decision, or --self-test to check its parsers.

What it does not guarantee

The two uploads are separate Gradle invocations and a snapshot repository has no staging-and-flip, so nothing makes the pair atomic. main's CI runs are serialized rather than cancelled — cancelling mid-publication is what splits them — but a failure during the second upload still leaves a split state until the next successful run. That is accepted for a mutable pre-release artifact; strict coherence would need the SDK to name an immutable, timestamped snapshot, which conflicts with the fixed names above.

Every publication is followed by ci/consumer-smoke-test, a separate Gradle build with no connection to this one, which resolves the published zenoh-java:<version>-SNAPSHOT from the snapshot repository with --refresh-dependencies and runs a key-expression round trip through JNI. That is the check that the whole chain — POM, transitive zenoh-flat-jni, native library — works for someone who is not us.

Running a release

Everything is driven from Actions → Release → Run workflow on the default branch. The workflow creates the release branch, bumps the version, tags it, builds and publishes.

Before the first run

  • Secrets are already in place. CENTRAL_SONATYPE_TOKEN_* and ORG_GPG_* are organization-level secrets on eclipse-zenoh, inherited automatically.

  • The zenoh-flat-jni version must already be on Maven Central for a live run. Check before starting:

    curl -sfI https://repo1.maven.org/maven2/org/eclipse/zenoh/zenoh-flat-jni/<version>/zenoh-flat-jni-<version>.pom
    

Rehearsal (dry run)

FieldValue
live-rununchecked
versiona fresh provisional number, not one already used
zenoh-flat-jni-versiona version that exists — today a snapshot, see below. Empty falls back to gradle.properties, which names our own 1.9.0-java-SNAPSHOT copy: fine for a rehearsal, refused for a live run
maven_publishchecked — or uncheck for the very first run

live-run and maven_publish behave exactly as in zenoh-flat-jni: unchecking live-run publishes <version>-SNAPSHOT to the mutable snapshot repository and never runs closeAndReleaseSonatypeStagingRepository, while maven_publish decides whether any upload happens at all. A rehearsal with maven_publish checked performs a real, signed upload — into the snapshot repository — and is the only configuration that exercises the credentials.

bump-and-tag.bash tags whatever version it is handed, rehearsals included, so never give a rehearsal the number you intend to release.

The real release

FieldValue
live-runchecked
versionthe release number
zenoh-flat-jni-versionthe zenoh-flat-jni release to build against — must already be on Central
maven_publishchecked

Supplying zenoh-flat-jni-version rewrites zenohFlatJniVersion in gradle.properties and commits it, so the published POM records exactly which binding release the SDK was built against.

After a release

Confirm the coordinates resolve, then verify the dependency is right — the POM must reference a real zenoh-flat-jni release:

curl -s https://repo1.maven.org/maven2/org/eclipse/zenoh/zenoh-java/<version>/zenoh-java-<version>.pom \
  | grep -A2 zenoh-flat-jni

Rehearsing before zenoh-flat-jni is released

This is the common case during the transition, and it works — only the live release is blocked.

Rehearsalresolves zenoh-flat-jni fromproves
local build and testsits source — the pinned commit, or your own checkout (README)the code compiles and the tests pass
CI, maven_publish uncheckedthe snapshot repositorythe artifact assembles
CI, snapshot publicationzenoh-flat-jni:<version>-SNAPSHOTsigning, credentials, a real upload
live releasezenoh-flat-jni:<version> on Centralblocked until that exists

A snapshot may depend on a snapshot, because nothing published is permanent — which is what The snapshot publication above rests on. gradle.properties names 1.9.0-java-SNAPSHOT, our own copy, and every merge to main republishes it from the pinned commit. So a rehearsal needs nothing set: the default already resolves.

Name another one on the command line to build against a different zenoh-flat-jni — the snapshot it publishes itself, or one from a rehearsal there:

./gradlew build -PzenohFlatJniVersion=1.9.0-rc8-SNAPSHOT

Rehearsing the release workflow with a snapshot

Find what is actually published — the list moves as zenoh-flat-jni rehearses:

curl -s https://central.sonatype.com/repository/maven-snapshots/org/eclipse/zenoh/zenoh-flat-jni/maven-metadata.xml
<latest>1.9.0-rc8-SNAPSHOT</latest>

Then run Release from the Actions tab with:

inputvalue
live-rununchecked — a live run refuses a -SNAPSHOT binding
zenoh-flat-jni-versionthe version above, e.g. 1.9.0-rc8-SNAPSHOT
maven_publishchecked to rehearse the upload too, unchecked to stop at assembly
version, branchleave empty unless you are rehearsing a specific one

zenoh-flat-jni-version decides what the rehearsal builds against. Left empty it falls back to zenohFlatJniVersion in gradle.properties1.9.0-java-SNAPSHOT, our own copy, which main republishes on every merge. A rehearsal against that is a real rehearsal — leaving the field empty is now a sound default rather than the guaranteed compile failure it used to be.

What that fallback cannot do is reach a live release: ci/scripts/bump-and-tag.bash refuses a -SNAPSHOT binding, and it checks the value gradle.properties ends up with rather than the input, precisely because an omitted input now inherits one.

The Central snapshot repository is declared conditionally in build.gradle.kts, and this is the part worth understanding:

if (zenohFlatJniVersion.endsWith("-SNAPSHOT")) {
    maven {
        url = uri("https://central.sonatype.com/repository/maven-snapshots/")
        content { includeGroup("org.eclipse.zenoh") }
    }
}

includeGroup, not includeModule: the dependency is declared on the root coordinate, but what Gradle actually downloads is zenoh-flat-jni-jvm or zenoh-flat-jni-android. Naming a single module would hide those from the snapshot repository and the build would fail to resolve.

It enters the resolution path only when a snapshot version was explicitly asked for, and even then serves only that one group. A release version never ends in -SNAPSHOT, so a release build cannot resolve a mutable artifact — not by oversight, and not by someone leaving a flag set. The guarantee is structural rather than procedural.

While developing, prefer not to involve a repository at all — see Where the native library comes from in the README.

How the pipeline works

release.yml runs four jobs:

  1. tageclipse-zenoh/ci/create-release-branch cuts the release branch, then ci/scripts/bump-and-tag.bash writes version.txt, optionally rewrites zenohFlatJniVersion in gradle.properties, commits and tags. It refuses a -SNAPSHOT binding version.
  2. publish_package — compiles Kotlin and publishes both artifacts in one Gradle invocation, so they share one staging repository and are released together. No native toolchain is installed and no Rust is built; both would be pointless here.
  3. publish-dokka — regenerates the API documentation and, on a live run only, deploys it to the gh-pages site README.md links to. The javadoc JAR attached to the Maven publications does not serve that site; this job does.
  4. publish-github — creates the GitHub release, on a live run only.

Publishing goes through io.github.gradle-nexus.publish-plugin to the Central Portal, signed with the organization GPG key, exactly as in zenoh-flat-jni.

Every third-party action these workflows use is pinned to a commit SHA, with the version in a trailing comment — a tag is mutable, and a moved tag would run code nobody reviewed on a job that holds the signing key and the Central token. The eclipse-zenoh/ci actions are ours and stay on @main deliberately: the tagging and GitHub-release steps track whatever that branch holds at run time. Bumping a pin is an ordinary pull request; read the diff of the action first.

Building against zenoh-flat-jni source

A build can be pointed at zenoh-flat-jni's source through a Gradle composite build, which is what CI and local development do — see Where the native library comes from in the README, and CI.md for the commit pin behind it.

A release must not. With a composite build the published artifact would be built from source on the builder's disk while the POM still claimed the released version it was supposed to be built against. Nothing opts in by default, and build.gradle.kts fails any publish* task while an included build is present, so a leftover -PuseLocalJni, -PlocalJniDir or path = "…" cannot reach a publication silently.

Which zenoh-flat-jni release this SDK is built and published against is zenohFlatJniVersion in gradle.properties, and that is unrelated to the above.

Required secrets

SecretUse
CENTRAL_SONATYPE_TOKEN_USERNAME / _PASSWORDCentral Portal user token
ORG_GPG_KEY_ID / _SUBKEY_ID / _PRIVATE_KEY / _PASSPHRASEsigning
BOT_TOKEN_WORKFLOWrelease branch, tag push, GitHub release

All are organization-level on eclipse-zenoh; nothing is configured per repository.

Known gaps

  • The rewritten release path has never run. The workflows were repaired for a repository that no longer contains Rust; no rehearsal has yet exercised them.
  • No consumer test before a release. Every snapshot publication is followed by ci/consumer-smoke-test, which resolves the published artifact from the snapshot repository and runs it — but a release goes to a staging repository and is not resolvable at that point, so nothing consumes a release candidate the way zenoh-flat-jni's own dry-run repository lets it consume one.
  • The Android artifact has no runtime test, and its ndkVersion and NDK setup step are retained although no native code is built here — unverified whether the Android Gradle Plugin still needs them.
  • zenoh-flat-jni itself has not been released, so the ordering constraint above has never been satisfied for a real release.

Release checklist

  • The zenoh-flat-jni version to build against is on Maven Central.
  • version.txt and the intended tag agree.
  • A rehearsal completed under a fresh version, with publication enabled at least once so signing and credentials were exercised.
  • useLocalJni is off — the release resolves from Central.
  • The published POM references a released zenoh-flat-jni, not a snapshot.
  • For an Android release: the Android POM references zenoh-flat-jni-android, not the desktop coordinate.
  • The released coordinates resolve from Maven Central.