Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/publish-dokka.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>`: 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/<version> for a released version
type: string
required: true
Comment on lines +34 to +37

env:
CARGO_TERM_COLOR: always
Expand All @@ -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:
Expand Down
206 changes: 206 additions & 0 deletions .github/workflows/release-github.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
#
# 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, <zenoh@zettascale.tech>
#
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
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.
- 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, 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:
# a fix upstream reaches every Zenoh repository, one here reaches this
# repository only.
Comment on lines +196 to +199
- 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 }}
72 changes: 62 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -91,27 +118,52 @@ 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.
permissions:
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.
# `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 -
# 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
Loading
Loading