Skip to content

Make the snapshot publication self-sufficient: publish the zenoh-flat-jni it was built against #524

Description

@milyin

1. The problem

The nightly snapshot publication has never succeeded. It fails while compiling — for example
run 31464535324:

> Could not resolve all files for configuration ':zenoh-java:jvmCompileClasspath'.
   > Could not find org.eclipse.zenoh:zenoh-flat-jni:1.9.0.

A short glossary first, since the rest depends on these terms:

  • Snapshot publication. Between releases, this repository uploads a mutable, pre-release build
    of itself — version 1.9.0-SNAPSHOT — to the
    Maven Central snapshot repository.
    Unlike a release, the same version can be overwritten again and again. Its purpose is to keep the
    upload machinery exercised (signing keys, credentials, what Central accepts) between releases,
    and to give people a way to try the current main.
  • zenoh-flat-jni. The Rust/JNI layer this SDK is built on. It used to live inside this
    repository as zenoh-jni/; it now lives in
    its own repository and is consumed as an
    ordinary Maven dependency, org.eclipse.zenoh:zenoh-flat-jni:<version>.

The failure follows directly from that move. Before it, the publication job compiled Rust that was
already in this repository, so it depended on nothing external. Now it must resolve
zenoh-flat-jni from Maven Central — and nothing has ever been published there:

$ curl -s https://repo1.maven.org/maven2/org/eclipse/zenoh/zenoh-flat-jni/   # 404
$ curl -s https://central.sonatype.com/repository/maven-snapshots/org/eclipse/zenoh/zenoh-flat-jni/maven-metadata.xml
<version>1.9.0-rc4-SNAPSHOT</version>
<version>1.9.0-rc8-SNAPSHOT</version>

Only two snapshots exist, both left over from hand-run release rehearsals, under version numbers
nothing tracks. gradle.properties names 1.9.0, which does not exist in any form.

2. Requirements

  1. The publication test repeats on its own. Same rule this repository has always used, and
    which the pre-flat tree still shows: publish from main only — every merge there, plus the
    weekday nightly. Branches and pull requests publish nothing.
  2. It is self-sufficient. If zenoh-flat-jni's CI were switched off entirely, this
    repository's CI must still pass. It may use zenoh-flat-jni's source; it must not wait on
    artifacts produced by that repository's CI.
  3. What it publishes is usable. A published zenoh-java snapshot must resolve for someone who
    depends on it. Its zenoh-flat-jni dependency therefore has to exist, and has to be the code
    this SDK was actually compiled against.
  4. Artifact names are stable. No name derived from a commit hash. Names are reused and
    overwritten, so storage does not grow with the number of builds.

3. Why the obvious answers do not work

Point at zenoh-flat-jni's own snapshot. Fails requirement 2: if that repository's CI has not
run, the coordinate is missing or stale. It also fails requirement 3 in a subtle way — that
coordinate always holds the tip of its main, while this SDK compiles against the specific
commit recorded in our Cargo.lock. When the two differ, we publish an artifact compiled against
one version of the JNI layer and pointing users at another. Since JNI is a binary contract, the
mismatch surfaces as UnsatisfiedLinkError at runtime rather than a build failure.

Build zenoh-flat-jni from source with a Gradle composite build.
A composite build is Gradle's
mechanism for including another, separate build into this one and letting its output stand in for
an external dependency. settings.gradle.kts already does this for local development. It satisfies
requirement 2 but not requirement 3: nothing is published, so the snapshot's dependency still does
not exist. It has two further problems.

It never exercises packaging. With a composite build the consumer does not receive a finished
.jar/.aar at all — it receives the other build's intermediate outputs directly. So it proves the
Kotlin API matches, and proves nothing about the artifact a user would download.

It breaks the Android publication. Generating the Android
POM — the XML file
published alongside an artifact that states its coordinates and lists its dependencies, and which is
how a consumer learns it needs zenoh-flat-jni at all — fails in zenoh-kotlin under a composite
build, because an Android library consumed as a project exposes internal build outputs rather than
the clean variants a published .aar declares:

> Could not apply withXml() to generated POM
   > we cannot choose between the following variants of project :zenoh-flat-jni:
       variant android-aidl / android-classes-jar / android-lint / …

4. The plan

The rule that follows from the requirements: the publication test must publish everything it
depends on.
If this job uploads a zenoh-java snapshot, it also uploads the zenoh-flat-jni
snapshot that snapshot names.

4.1 Publish our own copy of zenoh-flat-jni

Under a version qualified by the publisher, not by a commit:

org.eclipse.zenoh:zenoh-flat-jni:1.9.0-java-SNAPSHOT
org.eclipse.zenoh:zenoh-flat-jni-jvm:1.9.0-java-SNAPSHOT
org.eclipse.zenoh:zenoh-flat-jni-android:1.9.0-java-SNAPSHOT

(Three coordinates because zenoh-flat-jni is a Kotlin Multiplatform library: a root module plus
one artifact per platform, selected automatically.)

The name is fixed, so it is overwritten on each publication and satisfies requirement 4.
The -java qualifier keeps it distinct from the copy zenoh-flat-jni publishes itself and from
zenoh-kotlin's, so the three repositories never overwrite each other — which matters because they
can legitimately pin different commits at the same moment.

gradle.properties then names this version, so every build of main — CI, local, and the
publication job — resolves the same artifact, and this repository is the one that put it there.

4.2 Build it from the commit Cargo.lock pins

This repository records which commit of zenoh-flat-jni it is built against, as a git dependency
in Cargo.lock:

source = "git+https://github.com/eclipse-zenoh/zenoh-flat-jni.git?branch=main#e75529ce…"

That commit — moved by the usual lockfile-sync process — is the single source of truth, and it is
what gets built and published.

zenoh-flat-jni's publication workflow is already a
reusable workflow, so it can
be called from here, pinned to that same commit:

uses: eclipse-zenoh/zenoh-flat-jni/.github/workflows/publish.yml@<commit from Cargo.lock>

This is the important part for requirement 2: we consume that repository's source at a commit we
choose
, never a run of its CI or an artifact it produced. It also avoids duplicating its
cross-compilation matrix — six desktop targets and four Android ABIs — in two downstream
repositories.

Note this reverses an invariant CI.md currently states — "Publishing does not use any of
this … zenohFlatJniVersion says which release this SDK is published against, Cargo.lock says
which commit it is tested against, and moving one does not move the other."
For a snapshot the
two become the same commit by construction. Only a release keeps them separate, because it
resolves a published zenoh-flat-jni release from Maven Central and the pin plays no part.

4.3 Rebuild only when the pinned commit changes

Publishing zenoh-flat-jni means cross-compiling Rust for ten targets: on the order of half an
hour. Doing that on every merge would be wasteful, since the pin changes roughly once a day.

So: stamp the commit into the published artifact, and before rebuilding, read that stamp from the
currently published 1.9.0-java-SNAPSHOT.

The stamp goes in the POM, as a property, so the check costs two small requests instead of a
39 MB download — the version-level metadata resolves -SNAPSHOT to its timestamped build, then the
POM is fetched:

$ 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>

zenoh-flat-jni's own build sets it from git rev-parse HEAD, so it is written for every
publication including its canonical 1.9.0-SNAPSHOT — which also gives any consumer a way to ask
which commit a mutable version came from. A manifest attribute can carry the same value for runtime
forensics, but no decision needs to open a jar.

  • stamp equals the pin → reuse what is published, skip straight to publishing the SDK
  • stamp differs, or nothing is published yet → rebuild and republish, then continue

This is why the stable name matters: a commit-derived name would make every build a cache miss by
construction, and would accumulate roughly 68 MB per commit in the snapshot repository forever.

4.4 Publish one run at a time

Two runs on main — a merge and the nightly, or two merges in quick succession — publish the same
fixed coordinates concurrently. The uploads are separate Gradle invocations, so nothing is atomic:
the zenoh-flat-jni copy can end up holding one run's commit while the SDK beside it was compiled
against another. Nothing fails; the result is simply wrong. The commit-stamp check races the same
way, with both runs deciding to rebuild because neither has published yet.

So the publication job takes a
concurrency group:

concurrency:
  group: publish-snapshot
  cancel-in-progress: true

A superseded run is cancelled rather than left to finish. Its results are stale by definition —
main has already moved — and the run that cancelled it republishes the same coordinates, so
waiting for the old one buys nothing.

Note that no setting makes the pair atomic: the zenoh-flat-jni copy and the SDK are uploaded by
separate Gradle invocations, and a snapshot repository offers no staging-and-flip. A cancellation
can therefore always split them. What keeps that window small is job ordering — do all the
building first and both uploads last, back to back
— which reduces the cancel-sensitive gap from
the length of the job to a few seconds. If the superseding run then fails, a split state persists
until the next successful run; acceptable for a mutable pre-release artifact.

Note this race exists today for zenoh-java:1.9.0-SNAPSHOT on its own; it matters more once
coherence depends on two uploads staying paired.

4.5 Leave releases alone

A real release must still depend on a real, immutable zenoh-flat-jni release from Maven Central.
Nothing above applies to it: the release workflow keeps naming a released version, and
ci/scripts/bump-and-tag.bash keeps refusing to release against a snapshot.

4.6 Keep the composite build for local development, and keep it tested

-PlocalJniDir=<path> stays exactly as it is — editing zenoh-flat-jni and rebuilding this SDK
without publishing anything is what a composite build is genuinely good for. Only the publication
stops relying on it.

But a path nothing exercises rots, so the existing test job stays as the smoke test for it:

./gradlew jvmTest -PuseLocalJni=true

It has to actually build. A cheaper "does the dependency resolve" check is not enough — the Android
ambiguity above passes graph resolution and fails only later, at POM generation:

$ ./gradlew :zenoh-kotlin:dependencies --configuration releaseCompileClasspath -PuseLocalJni=true
+--- org.eclipse.zenoh:zenoh-flat-jni:1.9.0-SNAPSHOT -> project :zenoh-flat-jni     ok
$ ./gradlew :zenoh-kotlin:generatePomFileForAndroidReleasePublication -PuseLocalJni=true
> we cannot choose between the following variants of project :zenoh-flat-jni        fails

One platform is enough for it — it is checking the wiring in settings.gradle.kts and the
substitution, not the SDK. The two developer entry points differ only in where the directory comes
from (-PuseLocalJni=true fetches the pinned commit into .zenoh-flat-jni; -PlocalJniDir=<path>
takes one you already have), so the same job can cover the second by re-running against the checkout
the first produced.

5. Result

when what happens
pull request, or push to a branch build and test; nothing is published
merge to main, and the weekday nightly if the pinned commit changed, build and publish our zenoh-flat-jni copy; then publish the SDK snapshot
release workflow unchanged; resolves a released zenoh-flat-jni from Maven Central

Published from this repository:

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

Five fixed coordinates, each overwritten in place. The SDK snapshot's dependency is always
something the same job uploaded, so it is coherent by construction — no cross-repository check, and
correct whether or not zenoh-flat-jni's CI has ever run.

6. Trade-offs we are accepting

  • Cost on the days the pin moves: one full cross-build, around half an hour, in this repository
    and again in zenoh-kotlin. This is the price of a snapshot that actually works on every
    platform; a cheaper build produces a jar usable only on the runner's own OS.
  • Storage: three extra fixed coordinates, roughly 68 MB, overwritten rather than accumulated.
  • Both SDKs on one classpath would see two versions of zenoh-flat-jni and Gradle would pick
    one. They are alternative bindings to the same library, so this is a documentation note rather
    than a design constraint.

7. Open questions to settle before implementation

Three points in §4 do not yet hold as written.

Calling zenoh-flat-jni's workflow needs changes on its side. In a called reusable workflow the
github context belongs to the caller, and its publish.yml checks out without naming a
repository:

- uses: actions/checkout@v4
  with:
    ref: ${{ inputs.branch }}

Called from here that checks out this repository and then tries to build a Cargo project that
is not in it. So it needs repository/ref inputs on its checkout steps, and a version-qualifier
input — it derives the version from version.txt and cannot currently be asked for
-java-SNAPSHOT. Still no dependency on its CI running, but a dependency on changing its
workflow.

The two uploads cannot be made adjacent in the structure above. §4.4 says to build first and
upload last so a cancellation cannot split the pair, but the zenoh-flat-jni upload happens at the
end of the called workflow while the SDK upload happens here afterwards — separated by the SDK
build, javadoc and signing. Closing it properly means the called workflow only builds and stages
(it already passes natives between jobs as artifacts) with both Maven uploads done from here.

Bootstrapping. gradle.properties names 1.9.0-java-SNAPSHOT, which does not exist until the
first publication succeeds — so until then any build that does not use the composite build cannot
resolve it, including a contributor's plain ./gradlew build. Needs a one-time manual publication,
or a documented fallback.

8. Work items

  • gradle.properties: zenohFlatJniVersion=1.9.0-java-SNAPSHOT
  • ci/scripts/bump-and-tag.bash: refuse a live release when the effective value is a
    snapshot, not only when the workflow input is one (an omitted input now inherits a snapshot)
  • Stamp the commit as a POM property (zenoh.flatJniCommit) — a change in zenoh-flat-jni's
    build, so every publication of it carries the value; optionally a manifest attribute too
  • A consumer smoke test after publishing: in a clean project, resolve
    org.eclipse.zenoh:zenoh-java:1.9.0-SNAPSHOT from the snapshot repository with
    --refresh-dependencies, and run something that loads the native library — proving the SDK
    and its zenoh-flat-jni dependency resolve for someone who is not us. Model it on
    zenoh-flat-jni's ci/consumer-smoke-test
  • Script: read the pinned commit from Cargo.lock; read the stamp from the published
    1.9.0-java-SNAPSHOT; decide rebuild or reuse
  • .github/workflows/: call zenoh-flat-jni's publication workflow at the pinned commit,
    publishing under the -java qualifier, gated on that decision
  • .github/workflows/: a publish-snapshot concurrency group (cancel-in-progress: true), so
    two runs on main cannot interleave their uploads, with the job ordered to build first and
    upload last
  • Keep jvmTest -PuseLocalJni=true as the composite-build smoke test, on one platform, so the
    local development path does not rot
  • PUBLISHING.md: the naming scheme, what is published from where, and why a release is
    different
  • CI.md: rewrite "Publishing does not use any of this" — the pin now drives the snapshot
    publication, so pin and published version are the same commit for a snapshot and independent
    only for a release; also update "What CI runs" if the test job stops using the composite
    build
  • Mirror the whole thing to zenoh-kotlin once this is green, including both documents

9. Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    internalChanges not included in the changelog

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions