From f0ec0e004f7f8fb8e63d3d694715cd07059cb57b Mon Sep 17 00:00:00 2001 From: Michael Ilyin Date: Tue, 18 Aug 2026 13:42:16 +0200 Subject: [PATCH 1/2] CI: make the GitHub release runnable on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The steps that create the GitHub release were inline in `release.yml`, a workflow that cannot be started without also running its tag and publish jobs. Once Maven Central has accepted a version it cannot be republished, so a release that reaches Central without a GitHub release has no way back. zenoh-kotlin hit that case with 1.10.0 and fixed it in #708; this is the same change here. The steps move to `.github/workflows/release-github.yml`, which `release.yml` calls as its last job and which you can also dispatch from the Actions tab. Three checks guard a manual run: `version` and `branch` are constrained to plain ref characters before `eclipse-zenoh/ci/publish-crates-github` builds a shell command from them; `version.txt` at the tag must name the version being released; and `check-maven` requires the version to resolve from Maven Central, which is what rejects a rehearsal tag. `publish-dokka.yml` declared a manual trigger with no inputs, so a dispatch received an empty `inputs`: the deploy step was skipped and the job built the documentation site and discarded it, reporting success. It now declares `live-run` and a required `branch`. Two gates come with this. `release.yml` gains `github_release`, and both the release and the documentation deploy are gated on `maven_publish` as well, so a live run with uploads disabled announces nothing. The `tag` job now refuses `live-run` with `maven_publish` off outright — that combination force-pushed the real tag while publishing nothing. --- .github/workflows/publish-dokka.yml | 28 +++ .github/workflows/release-github.yml | 212 +++++++++++++++++++++++ .github/workflows/release.yml | 73 ++++++-- PUBLISHING.md | 244 +++++++++++++++++++++++++-- 4 files changed, 537 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/release-github.yml diff --git a/.github/workflows/publish-dokka.yml b/.github/workflows/publish-dokka.yml index 650cab17..466fb882 100644 --- a/.github/workflows/publish-dokka.yml +++ b/.github/workflows/publish-dokka.yml @@ -11,7 +11,30 @@ on: description: Target branch type: string required: false + # Dispatchable on its own so the documentation can be redeployed for a version + # that is already released - the same recovery path release-github.yml exists + # for. These inputs are not optional decoration: without them a dispatch got + # an empty `inputs`, so `live-run` was false and the deploy step below was + # skipped, which made a manual run build the site and silently throw it away. workflow_dispatch: + inputs: + live-run: + description: Live-run — actually deploy to gh-pages + type: boolean + required: false + # Required, unlike the workflow_call side that release.yml always fills. + # Empty here would fall back to the ref the dispatch was started from - + # normally `main` - and a live run would then overwrite the published + # documentation with the unreleased API. Recovery wants the released tag. + # + # Give it fully qualified as `refs/tags/`: actions/checkout + # resolves a bare name to a remote branch before a tag, so a bare + # `1.10.0` would build a branch of that name if one existed, deploying + # movable content where the released tag was meant. + branch: + description: Ref to build the documentation from - use refs/tags/ for a released version + type: string + required: true env: CARGO_TERM_COLOR: always @@ -22,6 +45,11 @@ jobs: build_doc_and_deploy: name: Build and Deploy Documentation runs-on: ubuntu-latest + # Declared here rather than only on the caller, so a direct dispatch can + # push to gh-pages too. A called workflow gets the intersection of this and + # what the caller granted, and release.yml grants exactly this. + permissions: + contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/release-github.yml b/.github/workflows/release-github.yml new file mode 100644 index 00000000..29df0777 --- /dev/null +++ b/.github/workflows/release-github.yml @@ -0,0 +1,212 @@ +# +# Copyright (c) 2026 ZettaScale Technology +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +# which is available at https://www.apache.org/licenses/LICENSE-2.0. +# +# SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +# +# Contributors: +# ZettaScale Zenoh Team, +# +name: Release (GitHub) + +# Creates the GitHub release for an already-tagged version. +# +# This is both a step of `release.yml` and a workflow you can dispatch on its +# own. Standalone is the point: the Maven publication is irreversible, so if a +# release reaches Maven Central but the GitHub release is missing or wrong, you +# must be able to create it without re-running the tag and publish jobs. A +# GitHub release, unlike a Maven coordinate, can be edited or deleted afterwards +# (`gh release edit` / `gh release delete`, which leaves the tag alone), so a +# mistake here is cheap to correct - though correcting it means editing or +# deleting, since `eclipse-zenoh/ci/publish-crates-github`, the shared action +# this calls, runs `gh release create`, which fails rather than replacing an +# existing release. The one thing that cannot be undone is that publishing +# notifies watchers. +# +# These steps used to sit inline in `release.yml`, which cannot be dispatched +# without also running the tag and publish jobs. zenoh-kotlin hit exactly the +# case that needs them separately - 1.10.0 published to Maven Central with no +# GitHub release - and extracted them there (zenoh-kotlin#708). This is the same +# workflow, with the same guards, so both SDKs recover the same way. + +on: + workflow_call: + inputs: + live-run: + description: Live-run + type: boolean + required: true + version: + description: Release number, which must already exist as a tag + type: string + required: true + branch: + description: Release branch the tag lives on + type: string + required: true + check-maven: + description: Require the version to be on Maven Central before releasing + type: boolean + required: false + # False from the pipeline, which only reaches this job when + # maven_publish was on - so the publish job that just ran is the + # evidence, and a freshly released coordinate takes a while to appear on + # repo1.maven.org, so checking here would fail every live release. + default: false + workflow_dispatch: + inputs: + live-run: + description: Live-run + type: boolean + required: false + version: + description: Release number, which must already exist as a tag (e.g. 1.10.0) + type: string + required: true + branch: + description: Release branch the tag lives on (e.g. release/1.10.0) + type: string + required: true + check-maven: + description: Require the version to be on Maven Central before releasing + type: boolean + required: false + default: true + +jobs: + publish-github: + name: Create the GitHub release + # ubuntu-latest, not the macos-latest these steps ran on inline: nothing + # here builds anything, it creates a release from a tag. + runs-on: ubuntu-latest + # Three questions about the version the operator typed, answered in order. + # `version` is used verbatim as the tag name - bump-and-tag runs + # `git tag --force "$version"` - so the one string names both. + # + # Does a tag of that name exist? `--verify-tag`, which + # publish-crates-github passes to + # `gh release create` + # Is the tag self-consistent? version.txt at the tag == version input + # Was the version published? check-maven + # + # The second is narrow. bump-and-tag writes version.txt and creates the tag + # in one run, so every tag the pipeline made passes it. What it rejects is a + # tag pointing at a commit whose version.txt names a different version - one + # moved onto another version's commit, or created there. A tag made by hand + # on the right commit passes, so this does not establish who made the tag. + # + # The third is what rejects a rehearsal tag. Rehearsals are tagged too and + # are self-consistent - version.txt at `1.10.0-rc1` reads `1.10.0-rc1` - so + # a rehearsal tag passes the second check and is caught only here. + # + # None of the three ties the published artifact to this commit: nothing + # published records the commit it was built from. + steps: + # `version` and `branch` reach publish-crates-github, which builds a shell + # command string from them and runs it with the bot token in the + # environment (eclipse-zenoh/ci#470). Until that is fixed upstream, both + # are constrained here to characters that cannot become shell syntax. + # The value is never echoed back: an invalid one is attacker-controlled + # and would itself be interpreted as a workflow command. + - name: Validate the inputs + env: + VERSION: ${{ inputs.version }} + BRANCH: ${{ inputs.branch }} + run: | + set -euo pipefail + check() { + if [[ ! "$2" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]]; then + echo "::error::input '$1' is not a plain ref name." \ + "Allowed: letters, digits, and . _ - / after a leading" \ + "letter or digit." >&2 + exit 1 + fi + } + check version "$VERSION" + check branch "$BRANCH" + + # Fully qualified, because actions/checkout resolves a bare name to a + # remote branch before a tag. A branch named `1.10.0` beside the tag would + # have this step read version.txt from the branch while + # publish-crates-github publishes the tag - the check failing open in the + # one case it exists to catch. + # + # Third-party actions are pinned to a commit, with the version in a + # trailing comment: a tag is mutable, and a moved tag runs code nobody + # reviewed. The eclipse-zenoh/ci action below is ours, and stays on a + # branch. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ inputs.version }} + + # `version` is operator-supplied, so it reaches the shell through the + # environment rather than being pasted into the script source. + - name: Verify the tag is the release tag for this version + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + tagged="$(tr -d '[:space:]' < version.txt)" + if [[ "$tagged" != "$VERSION" ]]; then + echo "::error::tag '$VERSION' points at a commit whose version.txt" \ + "reads '$tagged'. The tag and the commit name different versions." >&2 + exit 1 + fi + echo "tag $VERSION carries version.txt=$tagged" >> "$GITHUB_STEP_SUMMARY" + + # A 404 and an unanswered request are different problems - fix the + # version, versus try again later - so they get different messages, but + # both fail. Releasing during a Central outage is what `check-maven` off + # is for; the check should not decide that on its own. + - name: Verify the version is on Maven Central + if: ${{ inputs.check-maven }} + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + pom="https://repo1.maven.org/maven2/org/eclipse/zenoh/zenoh-java/$VERSION/zenoh-java-$VERSION.pom" + + # curl already writes 000 on a connection failure; don't append another. + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -I "$pom") || true + [[ -n "$code" ]] || code=000 + + case "$code" in + 200) + echo "zenoh-java:$VERSION resolves from Maven Central" >> "$GITHUB_STEP_SUMMARY" + ;; + 404) + echo "::error::zenoh-java:$VERSION is not on Maven Central." \ + "Releasing it on GitHub would announce a version nobody can depend on." \ + "Check the version, or wait if it was published minutes ago -" \ + "a release takes a while to propagate." >&2 + exit 1 + ;; + *) + echo "::error::Maven Central did not answer (HTTP $code), so $VERSION" \ + "could not be verified. Try again when it is reachable, or re-run" \ + "with check-maven off to release without this check." >&2 + exit 1 + ;; + esac + + # The org's shared release action, unchanged from what these steps called + # inline. It creates the release from the tag with generated notes, then + # attaches any `*-standalone.zip` / `*-debian.zip` build archives - of + # which this repository produces none, so that half is inert here and + # every release it has ever made carried notes only. + # + # Two known upstream defects are tracked in eclipse-zenoh/ci#470; neither + # affects a normal release. They are deliberately not worked around here: + # a fix upstream reaches every Zenoh repository, one here reaches this + # repository only. + - uses: eclipse-zenoh/ci/publish-crates-github@main + with: + repo: ${{ github.repository }} + live-run: ${{ inputs.live-run || false }} + version: ${{ inputs.version }} + branch: ${{ inputs.branch }} + github-token: ${{ secrets.BOT_TOKEN_WORKFLOW }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d5448ef..de07e15a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,11 @@ on: description: Publish the package to Maven Central required: false default: true + github_release: + type: boolean + description: Create the GitHub release + required: false + default: true jobs: tag: @@ -51,6 +56,28 @@ jobs: version: ${{ steps.create-release-branch.outputs.version }} branch: ${{ steps.create-release-branch.outputs.branch }} steps: + # First, so nothing is created when the combination is rejected. + # + # `maven_publish` suppresses the upload; it does not make a run a + # rehearsal. Combined with `live-run` it would cut the real release + # branch, force-push the real tag, and then upload, deploy and announce + # nothing - leaving a tag for a version that exists nowhere, which is the + # state a failed release leaves and that PUBLISHING.md says to avoid. + # + # Nothing is lost by refusing it. Rehearsing without uploading is + # `live-run` off, which does the same build and leaves a prunable + # dry-run branch; a GitHub-only release is the Release (GitHub) workflow, + # which verifies against Central instead of assuming an upload happened. + - name: Reject a live run with publishing disabled + if: ${{ (inputs.live-run || false) && contains(inputs.maven_publish, 'false') }} + run: | + echo "::error::live-run with maven_publish disabled would force-push the real tag" \ + "and publish nothing, leaving a tag for a version that exists nowhere." \ + "To rehearse without uploading, uncheck live-run." \ + "To create a GitHub release for a version already on Central, run" \ + "the 'Release (GitHub)' workflow." >&2 + exit 1 + - id: create-release-branch uses: eclipse-zenoh/ci/create-release-branch@main with: @@ -91,12 +118,22 @@ jobs: packages: write secrets: inherit + # `live-run` is ANDed with `maven_publish` rather than passed through. With + # uploads off the publish job still succeeds, so a live run would otherwise + # deploy the documentation of a version that exists nowhere but this + # repository, over the docs of the version people are actually using. The job + # still runs and still builds the site - that is the part worth exercising - + # it just does not publish it. + # + # The tag job now rejects that combination outright, so this is defence in + # depth: the invariant "nothing outward-facing without an upload" is stated + # where it applies, not only where it is currently enforced. publish-dokka: name: Publish documentation needs: [tag, publish] uses: ./.github/workflows/publish-dokka.yml with: - live-run: ${{ inputs.live-run || false }} + live-run: ${{ (inputs.live-run || false) && !contains(inputs.maven_publish, 'false') }} branch: ${{ needs.tag.outputs.branch }} # peaceiris/actions-gh-pages pushes the generated site to the gh-pages # branch, which the default read-only token cannot do. @@ -104,14 +141,30 @@ jobs: contents: write secrets: inherit + # Last, because a GitHub release is the announcement: it notifies watchers, + # and it should not fire for a version whose artifacts failed to publish. + # These steps used to be inline here; they now live in `release-github.yml`, + # which is also dispatchable on its own, so a release that was published to + # Maven Central without one can be recovered without re-running the tag and + # publish jobs - see the comment at the top of that file. + # + # Gated on `maven_publish` as well as `github_release`. The publish job + # succeeds either way - with `maven_publish` off it only assembles locally - + # so without this a live run with uploads disabled would announce a release + # for a coordinate that was never published. + # + # The tag job rejects `live-run` with `maven_publish` off before anything is + # created, so the `maven_publish` half of this condition is defence in depth; + # the `github_release` half is what actually varies. A genuine GitHub-only + # release is the standalone workflow, which verifies against Central rather + # than assuming an upload happened. publish-github: + name: Create the GitHub release needs: [tag, publish] - runs-on: macos-latest - steps: - - uses: eclipse-zenoh/ci/publish-crates-github@main - with: - repo: ${{ github.repository }} - live-run: ${{ inputs.live-run || false }} - version: ${{ needs.tag.outputs.version }} - branch: ${{ needs.tag.outputs.branch }} - github-token: ${{ secrets.BOT_TOKEN_WORKFLOW }} + if: ${{ !contains(inputs.github_release, 'false') && !contains(inputs.maven_publish, 'false') }} + uses: ./.github/workflows/release-github.yml + with: + live-run: ${{ inputs.live-run || false }} + version: ${{ needs.tag.outputs.version }} + branch: ${{ needs.tag.outputs.branch }} + secrets: inherit diff --git a/PUBLISHING.md b/PUBLISHING.md index b3bb61c0..0b012544 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -22,7 +22,10 @@ which covers them once for both repositories. - [Before the first run](#before-the-first-run) - [Rehearsal (dry run)](#rehearsal-dry-run) - [The real release](#the-real-release) + - [Creating a GitHub release on its own](#creating-a-github-release-on-its-own) - [After a release](#after-a-release) + - [If a release fails](#if-a-release-fails) + - [If a step after Maven Central fails](#if-a-step-after-maven-central-fails) - [Rehearsing before zenoh-flat-jni is released](#rehearsing-before-zenoh-flat-jni-is-released) - [Rehearsing the release workflow with a snapshot](#rehearsing-the-release-workflow-with-a-snapshot) - [How the pipeline works](#how-the-pipeline-works) @@ -173,6 +176,23 @@ Everything is driven from **Actions → Release → Run workflow** on the defaul branch. The workflow creates the release branch, bumps the version, tags it, builds and publishes. +Two checkboxes decide what it does. `live-run` chooses what "publish" *means*; +`maven_publish` chooses whether the upload happens at all. Three combinations +are accepted and the fourth is refused: + +| `live-run` | `maven_publish` | What it does | +| --- | --- | --- | +| ✗ | ✓ | **The normal rehearsal.** Uploads `-SNAPSHOT` to the mutable snapshot repository — a real signed upload, so credentials and signing are exercised | +| ✗ | ✗ | Assembles both publications and their POMs, uploads nothing | +| ✓ | ✓ | **The real release.** Permanent and immutable on Maven Central | +| ✓ | ✗ | **Refused** by the `tag` job — it would tag a version and publish nothing | + +Note that `maven_publish` defaults to checked, and that a rehearsal uploading a +snapshot is normal rather than something to avoid: the snapshot repository is +mutable, is not on consumers' default resolution path, and is cleaned after 90 +days. A rehearsal that uploads nothing cannot tell you whether the signing key +and credentials work, which is the half of a release most likely to fail. + ### Before the first run - **Secrets are already in place.** `CENTRAL_SONATYPE_TOKEN_*` and `ORG_GPG_*` @@ -192,15 +212,33 @@ builds and publishes. | `version` | a fresh provisional number, not one already used | | `zenoh-flat-jni-version` | a version that exists — today a snapshot, see [below](#rehearsing-the-release-workflow-with-a-snapshot). 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_publish` | checked — or uncheck for the very first run | - -`live-run` and `maven_publish` behave exactly as in zenoh-flat-jni: unchecking +| `github_release` | either — a rehearsal is not a live run, so no release is created regardless | + +`maven_publish` suppresses the **upload**; it does not turn a run into a +rehearsal. Combined with `live-run` it would cut the real release branch and +force-push the real tag while publishing nothing — leaving a tag for a version +that exists nowhere, which is what a +[failed release](#if-a-release-fails) leaves behind. **The `tag` job rejects +that combination before creating anything**, so `maven_publish` is only +meaningful on a rehearsal. + +Nothing is lost by that. To rehearse without uploading, uncheck `live-run`: the +same build runs, and the branch it leaves is a prunable dry-run one. To publish +a GitHub release or the documentation for a version already on Central, use the +standalone workflows under [If a step after Maven Central +fails](#if-a-step-after-maven-central-fails), which verify rather than assume. + +`live-run` and `maven_publish` mean what they mean in zenoh-flat-jni, with one +difference: this repository refuses a live run with `maven_publish` off, which +zenoh-flat-jni's release workflow still accepts. Unchecking `live-run` publishes `-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 +`bump-and-tag.bash` tags whatever version it is handed, rehearsals included — +1.10.0 was rehearsed as `1.10.0-rc1`, and that tag is still on the remote — so never give a rehearsal the number you intend to release. ### The real release @@ -210,12 +248,90 @@ never give a rehearsal the number you intend to release. | `live-run` | **checked** | | `version` | the release number | | `zenoh-flat-jni-version` | the zenoh-flat-jni release to build against — **must already be on Central** | -| `maven_publish` | checked | +| `maven_publish` | checked — the only accepted value on a live run | +| `github_release` | checked | 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. +### Creating a GitHub release on its own + +The **Release (GitHub)** workflow creates the GitHub release for a version that +is already tagged, without re-running the tag or publish jobs. Use it when a +release reached Maven Central without one — as zenoh-kotlin's `1.10.0` did, +which is why these steps were pulled out of `release.yml` into a workflow of +their own. + +| Field | Value | +| --- | --- | +| `live-run` | **checked** — without it the workflow does nothing | +| `version` | the released number, e.g. `1.10.0` | +| `branch` | the release branch the tag is on, e.g. `release/1.10.0` | +| `check-maven` | checked | + +**The release describes the tag, not the branch.** `version` is a release number +such as `1.10.0`, and it is used **verbatim as the Git tag name**: +`ci/scripts/bump-and-tag.bash` — run by the `tag` job of the **Release** +workflow, on the release branch just cut, before anything is published — writes +`version.txt` and then runs `git tag --force "$version"`. Tag name and version +string are the same characters. + +That is what makes `version` sufficient on its own. `publish-crates-github` +creates the release by invoking `gh release create`, the GitHub CLI command for +the job, which takes the tag name as its first argument — so the workflow passes +`version` straight through. The action always passes that command's +`--verify-tag` flag, which makes it abort unless a tag of exactly that name +already exists on the remote. `branch` only names where to cut a tag that does +not exist yet, which here it always does. + +A tag existing is not proof it was ever released, so two more checks run first. +They answer different questions, and it is worth being clear which does what: + +| Check | Question | Catches | +| --- | --- | --- | +| `version.txt` at the tag equals the `version` you entered | do the tag and its commit name the same version? | a tag pointing at a commit whose `version.txt` names a different version | +| `check-maven` | was this version ever published? | a tag for a version that never shipped — **including rehearsal tags** | + +The first is narrower than it looks. `bump-and-tag.bash` writes `version.txt` +and tags it in the same run, so every tag the pipeline produced passes. What it +rejects is a tag pointing at a commit whose `version.txt` names a different +version — moved onto another version's commit, or created there. A tag made by +hand on the right commit passes, so this does not establish who made the tag. + +In particular it does **not** catch a rehearsal. Rehearsal tags are +self-consistent too — `version.txt` at `1.10.0-rc1` reads `1.10.0-rc1` — so +they pass the first check and are stopped only by `check-maven`, which is why +that box should stay ticked for a manual run. + +Be precise about what the pair establishes: **this tag is a release tag for this +version, and this version exists on Central**. They do not tie the published +artifact to this commit. Nothing published carries the commit it was built from +— the POM has `` but no `` — so a tag force-moved onto a different +commit carrying the same `version.txt` would still pass. Reaching that state +means re-running a release whose version Central already accepted, which +[If a release fails](#if-a-release-fails) says not to do. + +`check-maven` confirms the version is on Maven Central before the release is +created, and reports the two ways that can fail separately: + +| What happened | What it means | What to do | +| --- | --- | --- | +| Central says the version is not there | the version is wrong, or was never published | correct it — or wait, if the release is minutes old and has not propagated | +| Central does not answer | Maven Central is unreachable | try again later, or untick `check-maven` to release without this check | + +The release pipeline switches `check-maven` off, because the publish job that +just uploaded the version is proof enough. A manual run has no such proof, so +`check-maven` defaults to on there. + +This is the asymmetry worth remembering: the Maven publication cannot be undone, +but a GitHub release can be edited (`gh release edit`) or removed (`gh release +delete`, which leaves the tag in place), so a mistake here costs nothing to +correct. Note that correcting it means editing or deleting the release — this +workflow runs `gh release create`, which fails rather than replacing one that +already exists. The one effect that cannot be taken back is that publishing +notifies everyone watching releases. + ### After a release Confirm the coordinates resolve, then verify the dependency is right — the POM @@ -226,6 +342,86 @@ curl -s https://repo1.maven.org/maven2/org/eclipse/zenoh/zenoh-java//ze | grep -A2 zenoh-flat-jni ``` +### If a release fails + +**The tag job runs first and pushes before anything is published.** So a run +that dies in `publish` — an unresolvable dependency, a credential problem, a +Central outage — still leaves the release branch and the tag pushed, for a +version that has no artifacts anywhere. The failed runs of 2026-08-10 and +2026-08-11 left exactly that: tag `1.10.0-rc1` and branch +`release/dry-run/1.10.0-rc1`, both still on the remote. + +Nothing downstream runs, though. `publish-dokka` and `publish-github` both +`need` the publish job, so no documentation is deployed and **no GitHub release +is created**. The whole visible residue is a tag pointing at unpublished code. + +**Retrying the same version is safe, as long as Central did not accept it.** +Nothing accumulates across attempts, because every step is recreated rather than +advanced: + +- `create-release-branch` does `git switch --force-create` and `git push + --force`, so the release branch is cut from `main` again, not extended; +- `bump-and-tag.bash` therefore rewrites `version.txt` on a fresh branch, so its + bump commit applies cleanly on a retry; +- the tag is `git tag --force` and `git push --force`. + +Fix the cause and run it again with the same number. Check the Central Portal +first for a staging repository left open by the failed attempt, and drop it. + +**The one case where retrying the same number is wrong** is a run that got far +enough for Central to accept the version. Maven Central is immutable: the +version cannot be republished, and re-running would force-move the tag onto a +new commit while Central keeps the artifact built from the old one — the tag and +the published artifact would then describe different code. Move to a new version +instead. This is also why the release number for a rehearsal must never be one +you intend to release. + +If a version reached Central but produced no GitHub release, that is the +recovery case for +[Release (GitHub)](#creating-a-github-release-on-its-own) — and its +`check-maven` check is what distinguishes it from this one, since a tag left by +a failed publish resolves to nothing on Central and is refused. + +### If a step after Maven Central fails + +Once Central accepts the version, **re-running the release is not an option**: +the version cannot be republished, and the tag would force-move onto a different +commit than the artifact was built from. Everything the pipeline does after that +point must therefore be recoverable on its own, and each piece is: + +| Produced | By | Recover with | +| --- | --- | --- | +| Maven Central coordinates | `publish` | nothing to do — immutable and done | +| Release branch and tag | `tag` | already pushed, before `publish` ran | +| GitHub release | `publish-github` | **Release (GitHub)**, [above](#creating-a-github-release-on-its-own) | +| `gh-pages` documentation | `publish-dokka` | **Publish (Dokka)**, `live-run` checked and `branch` set to `refs/tags/` | + +Both recovery workflows need `live-run` **checked** — unchecked, each builds and +verifies but changes nothing, which is also how you rehearse one. Give +`publish-dokka` the released tag as `branch`, written in full as +`refs/tags/`: the release branch can move afterwards, the tag cannot, +and a bare `1.10.0` would resolve to a branch of that name before the tag. + +Nothing else in a release is one-shot. `main` is deliberately untouched — +`version.txt` is bumped on the release branch only, so `main` keeps naming the +previous version and there is no post-release commit to reconstruct. +`update-release-project.yml` is driven by issues and pull requests, not by +releases. + +Order does not matter. The documentation deploy is safe to repeat — it +overwrites — but **Release (GitHub) is not**: it runs `gh release create`, which +fails when a release already exists for the tag rather than replacing it. To +change one that is already there, edit it directly: + +```bash +gh release edit --repo eclipse-zenoh/zenoh-java --notes-file notes.md +gh release delete --repo eclipse-zenoh/zenoh-java # then re-run +``` + +`gh release delete` leaves the Git tag in place, so deleting and re-running is a +valid way back — it just is not what re-running alone does. Verify with the +[release checklist](#release-checklist) afterwards. + ## Rehearsing before zenoh-flat-jni is released This is the common case during the transition, and it works — only the *live* @@ -326,7 +522,26 @@ in the README. 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. +4. **`publish-github`** — creates the GitHub release from the tag, on a live run + only, and only when `maven_publish` was on. It calls `release-github.yml`, + which is **also dispatchable on its own** — see [Creating a GitHub release on + its own](#creating-a-github-release-on-its-own). + +Jobs 3 and 4 both delegate to workflows that can be dispatched directly, which +is what makes [recovery](#if-a-step-after-maven-central-fails) possible without +re-running a release. + +The release itself is created by `eclipse-zenoh/ci/publish-crates-github`, the +shared action the rest of the Zenoh repositories use. Despite the name it +publishes no crate: it creates the release from the tag with generated notes, +then attaches any `*-standalone.zip` / `*-debian.zip` build archives. This +repository produces none, so that half is inert and every release it has made +here carries notes and GitHub's own source archives only. + +Two upstream defects are known and tracked in +[eclipse-zenoh/ci#470](https://github.com/eclipse-zenoh/ci/issues/470). Neither +affects a normal release, and both are left upstream deliberately — a fix there +reaches every Zenoh repository, whereas working around them here would fix one. 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. @@ -368,19 +583,27 @@ 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. +- **The recovery and gating paths added for this have never run.** The release + path itself has: 1.10.0 published from it on 2026-08-17. What is unexercised + is the standalone recovery of a GitHub release or of the documentation, and + the refusal of a live run with uploads disabled. - **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 GitHub release carries generated notes only** — notes and the source + archives GitHub attaches itself. There are no build artifacts to add: the + binaries live on Maven Central. - **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. +- **The snapshot still pins a `zenoh-flat-jni` version that was never + released.** `gradle.properties` names `1.9.0-java-SNAPSHOT`, our own copy; + `zenoh-flat-jni:1.9.0` is not on Maven Central and never was. `1.10.0` is — + and `zenoh-java:1.10.0` was released against it — so the ordering constraint + is satisfiable now, but a live release still has to be given that version + explicitly. ## Release checklist @@ -393,3 +616,4 @@ repository. - [ ] For an Android release: the Android POM references `zenoh-flat-jni-android`, not the desktop coordinate. - [ ] The released coordinates resolve from Maven Central. +- [ ] The GitHub release exists and its notes start at the previous release. From 0da844279c2e9e6fb8710a36f838aef57663ddc9 Mon Sep 17 00:00:00 2001 From: Michael Ilyin Date: Tue, 18 Aug 2026 14:03:41 +0200 Subject: [PATCH 2/2] CI: align with zenoh-kotlin#708 verbatim Three comments in the extracted workflow said things zenoh-kotlin's copy does not, and one of them was wrong: the shared action installs a Rust helper before it runs `gh`, so the job does not "build nothing". All three go, leaving only the differences the repository forces - the coordinate checked on Maven Central, this repository's rehearsal tag, and why the steps moved. PUBLISHING.md picks up the hunk of #708 that was missed: this repository holds `zenoh-flat-jni-pin` (`Cargo.toml` and `ci/pin.rs`), so the claim that it contains no Rust was wrong. What the released artifacts contain is unchanged - no Rust, no native library. --- .github/workflows/release-github.yml | 18 ++++++------------ .github/workflows/release.yml | 7 +++---- PUBLISHING.md | 19 ++++++++++++------- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release-github.yml b/.github/workflows/release-github.yml index 29df0777..079938ad 100644 --- a/.github/workflows/release-github.yml +++ b/.github/workflows/release-github.yml @@ -80,8 +80,6 @@ on: jobs: publish-github: name: Create the GitHub release - # ubuntu-latest, not the macos-latest these steps ran on inline: nothing - # here builds anything, it creates a release from a tag. runs-on: ubuntu-latest # Three questions about the version the operator typed, answered in order. # `version` is used verbatim as the tag name - bump-and-tag runs @@ -134,11 +132,6 @@ jobs: # have this step read version.txt from the branch while # publish-crates-github publishes the tag - the check failing open in the # one case it exists to catch. - # - # Third-party actions are pinned to a commit, with the version in a - # trailing comment: a tag is mutable, and a moved tag runs code nobody - # reviewed. The eclipse-zenoh/ci action below is ours, and stays on a - # branch. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: refs/tags/${{ inputs.version }} @@ -193,11 +186,12 @@ jobs: ;; esac - # The org's shared release action, unchanged from what these steps called - # inline. It creates the release from the tag with generated notes, then - # attaches any `*-standalone.zip` / `*-debian.zip` build archives - of - # which this repository produces none, so that half is inert here and - # every release it has ever made carried notes only. + # The org's shared release action, as the rest of the Zenoh repositories + # use it, and the same call these steps made inline. It creates the + # release from the tag with generated notes, then attaches any + # `*-standalone.zip` / `*-debian.zip` build archives - of which this + # repository produces none, so that half is inert here and every release + # it has ever made carried notes only. # # Two known upstream defects are tracked in eclipse-zenoh/ci#470; neither # affects a normal release. They are deliberately not worked around here: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de07e15a..8461b0a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,10 +143,9 @@ jobs: # Last, because a GitHub release is the announcement: it notifies watchers, # and it should not fire for a version whose artifacts failed to publish. - # These steps used to be inline here; they now live in `release-github.yml`, - # which is also dispatchable on its own, so a release that was published to - # Maven Central without one can be recovered without re-running the tag and - # publish jobs - see the comment at the top of that file. + # `release-github.yml` is also dispatchable on its own, which is how you + # recover a release that was published to Maven Central without one - see the + # comment at the top of that file. # # Gated on `maven_publish` as well as `github_release`. The publish job # succeeds either way - with `maven_publish` off it only assembles locally - diff --git a/PUBLISHING.md b/PUBLISHING.md index 0b012544..aba36dab 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -41,10 +41,15 @@ org.eclipse.zenoh:zenoh-java: the JVM artifact org.eclipse.zenoh:zenoh-java-android: 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. +Both are **pure JVM/Kotlin**. Neither carries Rust or a native library: those +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. + +The repository does hold one Rust crate, `zenoh-flat-jni-pin` (`Cargo.toml` and +`ci/pin.rs`). It builds nothing that ships. It exists so the zenoh-flat-jni +commit this SDK is tested against is recorded in `Cargo.lock`, which is the file +the organization's lockfile sync knows how to move. `zenoh-flat-jni` is itself a Kotlin Multiplatform library, so this SDK declares **one** dependency on its root coordinate and Gradle resolves the variant @@ -347,9 +352,9 @@ curl -s https://repo1.maven.org/maven2/org/eclipse/zenoh/zenoh-java//ze **The tag job runs first and pushes before anything is published.** So a run that dies in `publish` — an unresolvable dependency, a credential problem, a Central outage — still leaves the release branch and the tag pushed, for a -version that has no artifacts anywhere. The failed runs of 2026-08-10 and -2026-08-11 left exactly that: tag `1.10.0-rc1` and branch -`release/dry-run/1.10.0-rc1`, both still on the remote. +version that has no artifacts anywhere. The failed run of 2026-08-10 left +exactly that: tag `1.10.0-rc1` and branch `release/dry-run/1.10.0-rc1`, both +still on the remote. Nothing downstream runs, though. `publish-dokka` and `publish-github` both `need` the publish job, so no documentation is deployed and **no GitHub release