This is an automated email from the ASF dual-hosted git repository.
piotr pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 01d4dd94e fix(ci): gate release tags on proven registry availability
(#3124)
01d4dd94e is described below
commit 01d4dd94e324c3fee60b958404b2a13431ad0977
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Tue Apr 14 12:48:35 2026 +0200
fix(ci): gate release tags on proven registry availability (#3124)
Releases must not push a git tag until the target registry is
actually serving the artifact, and reruns must converge instead
of leaving half-published state behind. Today cargo search is
the availability oracle (rate-limited, laggy, and --limit 1
returns a relevance-ranked hit rather than an exact match),
the foreign-SDK matrix pushes tags with no wait gate at all,
and create-git-tag's early-skip silently accepts a pre-existing
tag at the wrong commit.
A new wait-for-crate composite queries the crates.io sparse
index directly (CDN-fronted, unrate-limited, authoritative as
of cargo publish return) and is reused at max_attempts=1 as
the rust/post-merge idempotency pre-check so reruns skip
already-published crates cleanly. A new wait-for-url composite
gates every foreign-SDK tag step on HTTP 200 from its registry
(PyPI, npm, Maven Central with ~25 min propagation budget,
NuGet); Go stays tag-only. create-git-tag's early-skip now
peels the remote ref and compares against the requested commit,
so a wrong-target tag is a hard failure with recovery commands
inline. The tag input regex now accepts semver build metadata.
Plus smaller tightenings: env: indirection on every run block
in rust/post-merge; dry_run kept there as a deprecated short-
circuiting input so forks pinning by SHA do not silently start
real-publishing; check-tags queries the remote; and
_publish_rust_crates.yml requires non-empty inputs.commit
instead of bypassing master-ancestry via a github.sha fallback.
---
.github/actions/rust/post-merge/action.yml | 171 ++++++------
.github/actions/utils/create-git-tag/action.yml | 197 ++++++++++++++
.github/actions/utils/wait-for-crate/action.yml | 149 ++++++++++
.github/actions/utils/wait-for-url/action.yml | 132 +++++++++
.github/workflows/_publish_rust_crates.yml | 265 ++++++++++--------
.github/workflows/publish.yml | 343 ++++++++++++++----------
scripts/bump-version.sh | 8 +-
scripts/extract-version.sh | 54 +++-
8 files changed, 981 insertions(+), 338 deletions(-)
diff --git a/.github/actions/rust/post-merge/action.yml
b/.github/actions/rust/post-merge/action.yml
index 0f22b181e..b303ccf05 100644
--- a/.github/actions/rust/post-merge/action.yml
+++ b/.github/actions/rust/post-merge/action.yml
@@ -16,7 +16,10 @@
# under the License.
name: rust-post-merge
-description: Rust post-merge crates.io publishing github iggy actions
+description: >
+ Publish a single Rust crate to crates.io. Idempotent on rerun via a
+ sparse-index pre-check. Intended to be called once per crate, in
+ dependency order, from .github/workflows/_publish_rust_crates.yml.
inputs:
package:
@@ -26,27 +29,52 @@ inputs:
description: "Version for publishing"
required: true
dry_run:
- description: "Dry run mode"
+ description: |
+ Deprecated. Retained only to avoid silently breaking downstream forks
+ that pin this composite by SHA and still pass `dry_run: true`.
+
+ Dry-run publishing is now handled one level up, by
+ scripts/verify-crates-publish.sh invoked from
+ .github/workflows/_publish_rust_crates.yml on the dry-run path. When
+ this composite is called with dry_run=true it prints a deprecation
+ warning and no-ops every publish step so forks keep getting the
+ "don't touch the real registry" semantics they expected. This input
+ will be removed in a future release once forks have migrated.
required: false
default: "false"
runs:
using: "composite"
steps:
+ - name: Deprecated dry_run warning
+ if: inputs.dry_run == 'true'
+ shell: bash
+ run: |
+ echo "::warning::rust/post-merge: the 'dry_run' input is deprecated."
+ echo "::warning::Dry-run publishing now happens at the workflow level
via"
+ echo "::warning::scripts/verify-crates-publish.sh (see
_publish_rust_crates.yml)."
+ echo "::warning::Honoring dry_run=true by skipping every step in this
composite."
+ echo "::warning::This input will be removed in a future release;
please migrate."
+ echo "âī¸ dry_run=true â skipping all publish steps"
+
- name: Setup Rust with cache
+ if: inputs.dry_run != 'true'
uses: ./.github/actions/utils/setup-rust-with-cache
- name: Validate package
+ if: inputs.dry_run != 'true'
+ env:
+ PACKAGE: ${{ inputs.package }}
+ VERSION: ${{ inputs.version }}
+ shell: bash
run: |
- PACKAGE="${{ inputs.package }}"
- VERSION="${{ inputs.version }}"
+ set -euo pipefail
echo "đĻ Validating Rust crate: $PACKAGE"
echo "Version: $VERSION"
echo ""
- # Check if package exists in workspace
- if ! cargo metadata --format-version 1 | jq -e ".packages[] |
select(.name == \"$PACKAGE\")" > /dev/null; then
+ if ! cargo metadata --format-version 1 | jq -e --arg pkg "$PACKAGE"
'.packages[] | select(.name == $pkg)' > /dev/null; then
echo "â Package '$PACKAGE' not found in workspace"
echo ""
echo "Available packages:"
@@ -54,120 +82,103 @@ runs:
exit 1
fi
- # Get package information
- CARGO_VERSION=$(cargo metadata --format-version 1 | jq -r ".packages[]
| select(.name == \"$PACKAGE\") | .version")
- CARGO_PATH=$(cargo metadata --format-version 1 | jq -r ".packages[] |
select(.name == \"$PACKAGE\") | .manifest_path")
+ CARGO_VERSION=$(cargo metadata --format-version 1 | jq -r --arg pkg
"$PACKAGE" '.packages[] | select(.name == $pkg) | .version')
+ CARGO_PATH=$(cargo metadata --format-version 1 | jq -r --arg pkg
"$PACKAGE" '.packages[] | select(.name == $pkg) | .manifest_path')
echo "Current Cargo.toml version: $CARGO_VERSION"
echo "Target version: $VERSION"
echo "Manifest path: $CARGO_PATH"
- # Check version consistency
if [ "$CARGO_VERSION" != "$VERSION" ]; then
echo "â ī¸ Warning: Cargo.toml version ($CARGO_VERSION) doesn't match
target version ($VERSION)"
echo "Make sure to update Cargo.toml before publishing"
fi
- # Show package dependencies
echo ""
echo "Package dependencies:"
cargo tree -p "$PACKAGE" --depth 1 | head -20
- shell: bash
- name: Build package
+ if: inputs.dry_run != 'true'
+ env:
+ PACKAGE: ${{ inputs.package }}
+ shell: bash
run: |
- PACKAGE="${{ inputs.package }}"
+ set -euo pipefail
echo "đ¨ Building package: $PACKAGE"
cargo build -p "$PACKAGE" --release
- # Verify the build
- if [ $? -eq 0 ]; then
- echo "â
Package built successfully"
- else
- echo "â Build failed"
- exit 1
- fi
- shell: bash
+ echo "â
Package built successfully"
- name: Verify package contents
+ if: inputs.dry_run != 'true'
+ env:
+ PACKAGE: ${{ inputs.package }}
+ shell: bash
run: |
- PACKAGE="${{ inputs.package }}"
+ set -euo pipefail
echo "đ Package contents verification:"
echo ""
- # List what would be included in the package
cargo package -p "$PACKAGE" --list | head -50
echo ""
echo "Package size estimate:"
cargo package -p "$PACKAGE" --list | wc -l
echo "files would be included"
- shell: bash
+
+ # Idempotency pre-check: ask the crates.io sparse index (same data the
+ # publish wait gate uses) whether this exact version is already live.
+ # If it is, we skip `cargo publish` cleanly instead of hard-failing on
+ # "crate version already uploaded", which is the failure mode that blocks
+ # reruns after a transient post-publish issue (e.g. tag push failure).
+ #
+ # continue-on-error: true so an exit 1 ("not there") flips through to
+ # steps.already_published.outcome == 'failure' and gates the publish
+ # step below, instead of failing the job.
+ - name: Check if crate is already on crates.io
+ if: inputs.dry_run != 'true'
+ id: already_published
+ continue-on-error: true
+ uses: ./.github/actions/utils/wait-for-crate
+ with:
+ package: ${{ inputs.package }}
+ version: ${{ inputs.version }}
+ max_attempts: "1"
+ initial_sleep_seconds: "1"
- name: Publish to crates.io
+ if: inputs.dry_run != 'true' && steps.already_published.outcome ==
'failure'
+ shell: bash
env:
CARGO_REGISTRY_TOKEN: ${{ env.CARGO_REGISTRY_TOKEN }}
+ PACKAGE: ${{ inputs.package }}
+ VERSION: ${{ inputs.version }}
run: |
- PACKAGE="${{ inputs.package }}"
- VERSION="${{ inputs.version }}"
+ set -euo pipefail
- if [ "${{ inputs.dry_run }}" = "true" ]; then
- echo "đ Dry run - would publish crate: $PACKAGE"
- echo ""
+ if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then
+ echo "â CARGO_REGISTRY_TOKEN is not set"
+ exit 1
+ fi
- # Use cargo package instead of cargo publish --dry-run to avoid
- # dependency resolution from crates.io â earlier crates in the
- # chain (e.g. iggy_common) haven't been published yet during dry run.
- # Build correctness is already verified by the "Build package" step
- # which uses workspace-local dependencies.
- cargo package -p "$PACKAGE" --no-verify
+ echo "đĻ Publishing $PACKAGE v$VERSION to crates.io..."
+ echo ""
- echo ""
- echo "Would publish:"
- echo " Package: $PACKAGE"
- echo " Version: $VERSION"
- echo " Registry: crates.io"
- echo ""
- echo "After publishing, users could use:"
- echo ' [dependencies]'
- echo " $PACKAGE = \"$VERSION\""
- echo ""
- echo "Or with cargo add:"
- echo " cargo add $PACKAGE@$VERSION"
- else
- if [ -z "$CARGO_REGISTRY_TOKEN" ]; then
- echo "â CARGO_REGISTRY_TOKEN is not set"
- exit 1
- fi
-
- echo "đĻ Publishing crate to crates.io..."
- echo "Package: $PACKAGE"
- echo "Version: $VERSION"
- echo ""
+ cargo publish -p "$PACKAGE"
- # Publish the package
- cargo publish -p "$PACKAGE"
-
- if [ $? -eq 0 ]; then
- echo ""
- echo "â
Successfully published to crates.io"
- echo ""
- echo "Package: $PACKAGE v$VERSION"
- echo "Registry: https://crates.io/crates/$PACKAGE"
- echo ""
- echo "Users can now use:"
- echo ' [dependencies]'
- echo " $PACKAGE = \"$VERSION\""
- echo ""
- echo "Or with cargo add:"
- echo " cargo add $PACKAGE@$VERSION"
- echo ""
- echo "View on crates.io:
https://crates.io/crates/$PACKAGE/$VERSION"
- else
- echo "â Publishing failed"
- exit 1
- fi
- fi
+ echo ""
+ echo "â
Successfully published to crates.io"
+ echo "View on crates.io: https://crates.io/crates/$PACKAGE/$VERSION"
+
+ - name: Publish skipped (crate already on crates.io)
+ if: inputs.dry_run != 'true' && steps.already_published.outcome ==
'success'
shell: bash
+ env:
+ PACKAGE: ${{ inputs.package }}
+ VERSION: ${{ inputs.version }}
+ run: |
+ echo "âī¸ $PACKAGE v$VERSION is already on crates.io, skipping publish"
+ echo "View on crates.io: https://crates.io/crates/$PACKAGE/$VERSION"
diff --git a/.github/actions/utils/create-git-tag/action.yml
b/.github/actions/utils/create-git-tag/action.yml
new file mode 100644
index 000000000..cec8117bc
--- /dev/null
+++ b/.github/actions/utils/create-git-tag/action.yml
@@ -0,0 +1,197 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: create-git-tag
+description: >
+ Idempotently create and push an annotated git tag against a specific commit.
+ Skips cleanly if the tag already exists on the remote AT THE SAME COMMIT
+ (including races where another job pushed the same tag between our pre-check
+ and our push). A pre-existing tag pointing at a different commit is always
+ a hard failure with operator recovery instructions: the whole point of the
+ invariant is "no tag without a matching, reviewed release commit".
+
+inputs:
+ tag:
+ description: "Tag name to create (e.g. iggy-0.10.1, web-ui-0.3.0)"
+ required: true
+ commit:
+ description: "Full commit SHA the tag should point to (never HEAD, never a
short SHA)"
+ required: true
+ message:
+ description: "Annotated tag message. Defaults to 'Release <tag>'."
+ required: false
+ default: ""
+
+runs:
+ using: composite
+ steps:
+ - name: Create and push git tag
+ shell: bash
+ env:
+ TAG: ${{ inputs.tag }}
+ COMMIT: ${{ inputs.commit }}
+ MESSAGE: ${{ inputs.message }}
+ run: |
+ set -euo pipefail
+
+ if [ -z "${TAG}" ]; then
+ echo "â create-git-tag: 'tag' input is empty"
+ exit 1
+ fi
+ if [ -z "${COMMIT}" ]; then
+ echo "â create-git-tag: 'commit' input is empty"
+ exit 1
+ fi
+
+ # Reject inputs that could mangle git invocation. Tag and commit are
+ # both derived from trusted sources today (extract-version.sh outputs
+ # and pre-validated SHAs), but the composite has no caller context, so
+ # validate defensively.
+ #
+ # `+` is allowed in the tag alphabet because every tag_pattern in
+ # .github/config/publish.yml already permits the semver build
+ # metadata suffix `(?:\+[0-9A-Za-z.-]+)?`. Rejecting `+` here would
+ # hard-fail the entire chain after a successful publish the first
+ # time a release uses a `X.Y.Z+build.N` version - the exact rc1
+ # failure shape this PR is trying to eliminate.
+ if ! [[ "${TAG}" =~ ^[A-Za-z0-9._/+-]+$ ]]; then
+ echo "â create-git-tag: tag '${TAG}' contains characters outside
[A-Za-z0-9._/+-]"
+ exit 1
+ fi
+ if ! [[ "${COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "â create-git-tag: commit '${COMMIT}' is not a full 40-char SHA"
+ exit 1
+ fi
+
+ if [ -z "${MESSAGE}" ]; then
+ MESSAGE="Release ${TAG}"
+ fi
+
+ # Ensure the commit object exists locally; required by `git tag -a`.
+ # If the workflow used a shallow checkout, fetch just the one commit.
+ if ! git cat-file -e "${COMMIT}^{commit}" 2>/dev/null; then
+ echo "âšī¸ Commit ${COMMIT} not in local clone, fetching..."
+ if ! git fetch --no-tags --depth=1 origin "${COMMIT}" 2>/dev/null;
then
+ echo "â Failed to fetch commit ${COMMIT} from origin"
+ echo " Recovery:"
+ echo " - verify the caller's checkout step uses fetch-depth: 0"
+ echo " - verify the commit still exists on origin (was it
force-pushed away?)"
+ echo " - verify the commit is reachable from a branch on
origin (not only from a PR ref)"
+ exit 1
+ fi
+ fi
+
+ # Resolve a remote tag ref to the commit it points at, preferring the
+ # peeled form (for annotated tags) and falling back to the raw object.
+ # Used by both the early-skip branch and the post-push race-recovery
+ # branch so that they behave the same way.
+ remote_tag_commit() {
+ local peeled raw
+ peeled=$(git ls-remote --tags origin "refs/tags/${TAG}^{}"
2>/dev/null | awk '{print $1}')
+ raw=$(git ls-remote --tags origin "refs/tags/${TAG}" 2>/dev/null |
awk '{print $1}')
+ echo "${peeled:-${raw}}"
+ }
+
+ # Idempotency: if the tag already exists on the remote, accept it
+ # ONLY if it points at the same commit we're being asked to tag.
+ # Before this, the early-skip path checked name existence only and
+ # silently passed a pre-existing wrong-target tag (the exact state
+ # rc1 left behind), which is incompatible with the "no tag without
+ # a matching, reviewed commit" invariant.
+ if git ls-remote --tags --exit-code origin "refs/tags/${TAG}"
>/dev/null 2>&1; then
+ EXISTING_SHA="$(remote_tag_commit)"
+ if [ -z "${EXISTING_SHA}" ]; then
+ echo "â Tag ${TAG} exists on remote but its target commit could
not be resolved"
+ exit 1
+ fi
+ if [ "${EXISTING_SHA}" = "${COMMIT}" ]; then
+ echo "âī¸ Tag ${TAG} already exists on remote at ${COMMIT},
skipping"
+ exit 0
+ fi
+ echo "â Tag ${TAG} exists on remote at ${EXISTING_SHA}, but this run
wants it at ${COMMIT}"
+ echo " Recovery (verify the intended release commit first):"
+ echo " - delete the wrong tag: git push --delete origin ${TAG}"
+ echo " - or bump the version and rerun the publish workflow"
+ exit 1
+ fi
+
+ # Local fallback for clones that already fetched the tag locally but
+ # somehow failed the ls-remote check above (e.g. a transient network
+ # blip). Same symmetric-SHA rule applies.
+ if git rev-parse --verify --quiet "refs/tags/${TAG}" >/dev/null; then
+ LOCAL_PEELED=$(git rev-parse --verify --quiet
"refs/tags/${TAG}^{commit}" 2>/dev/null || true)
+ LOCAL_SHA="${LOCAL_PEELED:-$(git rev-parse --verify --quiet
"refs/tags/${TAG}" 2>/dev/null || true)}"
+ if [ "${LOCAL_SHA}" = "${COMMIT}" ]; then
+ echo "âī¸ Tag ${TAG} already exists locally at ${COMMIT}, skipping"
+ exit 0
+ fi
+ echo "â Tag ${TAG} exists locally at ${LOCAL_SHA}, but this run
wants it at ${COMMIT}"
+ echo " Recovery (verify the intended release commit first):"
+ echo " - delete the local tag: git tag -d ${TAG}"
+ echo " - then rerun so the ls-remote check above re-evaluates
the remote state"
+ exit 1
+ fi
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ git tag -a "${TAG}" "${COMMIT}" -m "${MESSAGE}"
+
+ # Push, recovering from the only race the concurrency group does not
+ # cover: a second job inside this same workflow run pushing the same
+ # tag between our ls-remote check and our push. If push fails AND the
+ # tag now exists on the remote pointing at the same commit, treat it
+ # as a benign skip; any other failure (permission denied, missing
+ # commit, mismatched target) is fatal so the operator sees it.
+ #
+ # `|| push_rc=$?` is needed because `set -e` plus the `if` form would
+ # both swallow the failing exit code: `$?` after a non-taken `if` is
+ # always 0.
+ push_rc=0
+ push_stderr_file="$(mktemp)"
+ trap 'rm -f "${push_stderr_file}"' EXIT
+ git push origin "${TAG}" 2>"${push_stderr_file}" || push_rc=$?
+ if [ "${push_rc}" -eq 0 ]; then
+ echo "â
Created and pushed tag: ${TAG}"
+ exit 0
+ fi
+
+ echo "â ī¸ git push failed (rc=${push_rc}), checking remote for race..."
+ echo " push stderr:"
+ sed 's/^/ /' "${push_stderr_file}"
+
+ REMOTE_RAW=$(git ls-remote --tags origin "refs/tags/${TAG}" | awk
'{print $1}')
+ if [ -z "${REMOTE_RAW}" ]; then
+ echo "â Push failed and tag ${TAG} is not on remote - propagating
failure"
+ echo " The push stderr above should explain why (permission
denied, protected"
+ echo " ref, missing upstream, etc.). If this is a
token/permissions issue,"
+ echo " verify the calling workflow has 'contents: write' on the
job."
+ exit "${push_rc}"
+ fi
+
+ TARGET_SHA="$(remote_tag_commit)"
+ if [ "${TARGET_SHA}" = "${COMMIT}" ]; then
+ echo "âī¸ Tag ${TAG} was created concurrently at the same commit,
treating as skip"
+ exit 0
+ fi
+
+ echo "â Tag ${TAG} exists on remote at ${TARGET_SHA} but this run
wanted ${COMMIT}"
+ echo " This is the 'rc1 failure shape': the tag points at the wrong
commit."
+ echo " Recovery (verify the intended release commit first):"
+ echo " - delete the wrong tag: git push --delete origin ${TAG}"
+ echo " - or bump the version and rerun the publish workflow"
+ exit 1
diff --git a/.github/actions/utils/wait-for-crate/action.yml
b/.github/actions/utils/wait-for-crate/action.yml
new file mode 100644
index 000000000..6a5d994d8
--- /dev/null
+++ b/.github/actions/utils/wait-for-crate/action.yml
@@ -0,0 +1,149 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: wait-for-crate
+description: >
+ Poll the crates.io sparse index for an exact crate + version match.
+ The sparse index is the data `cargo publish` writes to, is CDN-fronted,
+ has no anonymous rate limit, and is authoritative as of the moment
+ `cargo publish` returns. Prefer this over `cargo search`, which uses the
+ laggy search API, returns relevance-ranked top hits instead of exact
+ matches, and is rate-limited for anonymous callers - all three modes
+ wedged the 0.10.0-rc1 publish chain.
+
+ Pass max_attempts=1 to reuse this as a one-shot idempotency pre-check
+ (is this version already on crates.io?). Combine with
+ continue-on-error: true in the caller so the publish can proceed on
+ exit 1 (not yet there) and skip on exit 0 (already there).
+
+inputs:
+ package:
+ description: "Crate name exactly as it appears on crates.io (e.g. iggy,
iggy_common, iggy-cli)."
+ required: true
+ version:
+ description: "Exact version string to match against the sparse index
`vers` field. Matched literally, so semver build metadata (`+`) is safe."
+ required: true
+ max_attempts:
+ description: "Maximum poll attempts before giving up."
+ required: false
+ default: "30"
+ initial_sleep_seconds:
+ description: "Sleep between the first two attempts in seconds. Doubles
each attempt, capped at 30."
+ required: false
+ default: "3"
+
+runs:
+ using: composite
+ steps:
+ - name: Wait for crate on crates.io sparse index
+ shell: bash
+ env:
+ PACKAGE: ${{ inputs.package }}
+ VERSION: ${{ inputs.version }}
+ MAX_ATTEMPTS: ${{ inputs.max_attempts }}
+ INITIAL_SLEEP_SECONDS: ${{ inputs.initial_sleep_seconds }}
+ run: |
+ set -euo pipefail
+
+ if [ -z "${PACKAGE}" ] || [ -z "${VERSION}" ]; then
+ echo "â wait-for-crate: 'package' and 'version' inputs are required"
+ exit 1
+ fi
+
+ # Defensive validation: both inputs become part of a URL and a shell
+ # string comparison. Reject anything outside the crates.io-accepted
+ # crate name alphabet / semver alphabet so the composite cannot be
+ # coerced into fetching a different URL if a caller ever derives
+ # these from less trusted sources.
+ if ! [[ "${PACKAGE}" =~ ^[A-Za-z0-9_-]+$ ]]; then
+ echo "â wait-for-crate: package '${PACKAGE}' contains characters
outside [A-Za-z0-9_-]"
+ exit 1
+ fi
+ if ! [[ "${VERSION}" =~ ^[A-Za-z0-9._+-]+$ ]]; then
+ echo "â wait-for-crate: version '${VERSION}' contains characters
outside the semver alphabet"
+ exit 1
+ fi
+ if ! [[ "${MAX_ATTEMPTS}" =~ ^[0-9]+$ ]] || [ "${MAX_ATTEMPTS}" -lt 1
]; then
+ echo "â wait-for-crate: max_attempts '${MAX_ATTEMPTS}' must be a
positive integer"
+ exit 1
+ fi
+ if ! [[ "${INITIAL_SLEEP_SECONDS}" =~ ^[0-9]+$ ]]; then
+ echo "â wait-for-crate: initial_sleep_seconds
'${INITIAL_SLEEP_SECONDS}' must be a non-negative integer"
+ exit 1
+ fi
+
+ # Compute the sparse-index prefix path from the leading characters
+ # of the lowercased crate name. Layout is documented at
+ # https://doc.rust-lang.org/cargo/reference/registry-index.html
+ # 1-char: 1/<name>
+ # 2-char: 2/<name>
+ # 3-char: 3/<first>/<name>
+ # 4+ char: <first-2>/<chars-3-4>/<name>
+ name_lc=$(echo "${PACKAGE}" | tr '[:upper:]' '[:lower:]')
+ case ${#name_lc} in
+ 1) prefix="1" ;;
+ 2) prefix="2" ;;
+ 3) prefix="3/${name_lc:0:1}" ;;
+ *) prefix="${name_lc:0:2}/${name_lc:2:2}" ;;
+ esac
+ URL="https://index.crates.io/${prefix}/${name_lc}"
+
+ echo "đĄ Sparse index URL: ${URL}"
+ echo "đ¯ Target version: ${VERSION}"
+ echo ""
+
+ sleep_s="${INITIAL_SLEEP_SECONDS}"
+ for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do
+ # -f: fail on HTTP >= 400, so a 404 ("crate not yet on index")
+ # surfaces as a non-zero exit with an empty body, which the
+ # `|| true` swallows so the loop can keep going.
+ # -sS: quiet but still show hard curl errors on stderr (network
+ # failure, DNS, TLS) so operators see them in the step log.
+ # -L: follow redirects (the CDN may redirect to a mirror).
+ body=$(curl -fsSL "${URL}" 2>/dev/null || true)
+
+ # The sparse-index body is newline-delimited JSON: one record
+ # per published version. `jq -r '.vers'` emits one version per
+ # line and `grep -Fxq` does an exact literal full-line match,
+ # so `0.10.0+build.5` is matched as itself, not as a regex
+ # where `.` and `+` would mean something else.
+ if [ -n "${body}" ] && echo "${body}" | jq -r '.vers' 2>/dev/null |
grep -Fxq "${VERSION}"; then
+ echo "â
${PACKAGE} v${VERSION} is on the sparse index"
+ exit 0
+ fi
+
+ if [ "${attempt}" -eq "${MAX_ATTEMPTS}" ]; then
+ break
+ fi
+ echo "âŗ ${PACKAGE} v${VERSION} not yet visible (attempt
${attempt}/${MAX_ATTEMPTS}, sleep ${sleep_s}s)"
+ sleep "${sleep_s}"
+ sleep_s=$(( sleep_s * 2 ))
+ if [ "${sleep_s}" -gt 30 ]; then
+ sleep_s=30
+ fi
+ done
+
+ # Failure message is phrased so it is accurate for both usage modes:
+ # * max_attempts=1 â "not found on a one-shot check"
+ # * max_attempts>1 â "never appeared inside the budget"
+ echo "â ${PACKAGE} v${VERSION} is not on the sparse index after
${MAX_ATTEMPTS} attempt(s)"
+ echo " URL: ${URL}"
+ echo " Common causes:"
+ echo " - cargo publish did not actually land the upload (inspect
the preceding publish step)"
+ echo " - the crates.io sparse-index CDN is lagging (usually
seconds, rarely >1 min)"
+ echo " - the version input does not match the version that was
uploaded"
+ exit 1
diff --git a/.github/actions/utils/wait-for-url/action.yml
b/.github/actions/utils/wait-for-url/action.yml
new file mode 100644
index 000000000..dce8af29a
--- /dev/null
+++ b/.github/actions/utils/wait-for-url/action.yml
@@ -0,0 +1,132 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: wait-for-url
+description: >
+ Poll an HTTP(S) URL until it returns HTTP 200, with exponential backoff
+ and a caller-configurable retry budget. Used to gate post-publish tag
+ creation on the target registry actually serving the uploaded artifact:
+ for npm, NuGet, PyPI JSON and Maven Central POM endpoints, HTTP 200 on
+ a version-qualified URL is a reliable "this version is live" signal.
+
+ This is the foreign-SDK analogue of wait-for-crate. crates.io has a
+ structured sparse index where we can grep a specific `vers` field;
+ every other ecosystem we publish to exposes per-version URLs instead,
+ so the signal is "HTTP 200 on a deterministic URL".
+
+inputs:
+ url:
+ description: "Full https URL to poll. Must contain the target version so
HTTP 200 is unambiguous."
+ required: true
+ max_attempts:
+ description: "Maximum poll attempts before giving up."
+ required: false
+ default: "30"
+ initial_sleep_seconds:
+ description: "Sleep between the first two attempts in seconds. Doubles
each attempt, capped at max_sleep_seconds."
+ required: false
+ default: "3"
+ max_sleep_seconds:
+ description: "Upper bound on per-attempt sleep, in seconds."
+ required: false
+ default: "30"
+ description:
+ description: "Short human-readable description of what is being waited
for, shown in log lines."
+ required: false
+ default: "artifact"
+
+runs:
+ using: composite
+ steps:
+ - name: Wait for URL to return HTTP 200
+ shell: bash
+ env:
+ URL: ${{ inputs.url }}
+ MAX_ATTEMPTS: ${{ inputs.max_attempts }}
+ INITIAL_SLEEP_SECONDS: ${{ inputs.initial_sleep_seconds }}
+ MAX_SLEEP_SECONDS: ${{ inputs.max_sleep_seconds }}
+ DESCRIPTION: ${{ inputs.description }}
+ run: |
+ set -euo pipefail
+
+ if [ -z "${URL}" ]; then
+ echo "â wait-for-url: 'url' input is empty"
+ exit 1
+ fi
+ # Restrict to http/https so a malformed caller cannot coerce curl
+ # into fetching file:// or similar. Every registry we poll uses
+ # https today, so this is not a capability loss.
+ if ! [[ "${URL}" =~ ^https?:// ]]; then
+ echo "â wait-for-url: url '${URL}' must start with http:// or
https://"
+ exit 1
+ fi
+ if ! [[ "${MAX_ATTEMPTS}" =~ ^[0-9]+$ ]] || [ "${MAX_ATTEMPTS}" -lt 1
]; then
+ echo "â wait-for-url: max_attempts '${MAX_ATTEMPTS}' must be a
positive integer"
+ exit 1
+ fi
+ if ! [[ "${INITIAL_SLEEP_SECONDS}" =~ ^[0-9]+$ ]]; then
+ echo "â wait-for-url: initial_sleep_seconds
'${INITIAL_SLEEP_SECONDS}' must be a non-negative integer"
+ exit 1
+ fi
+ if ! [[ "${MAX_SLEEP_SECONDS}" =~ ^[0-9]+$ ]] || [
"${MAX_SLEEP_SECONDS}" -lt 1 ]; then
+ echo "â wait-for-url: max_sleep_seconds '${MAX_SLEEP_SECONDS}' must
be a positive integer"
+ exit 1
+ fi
+
+ echo "đĄ Polling URL: ${URL}"
+ echo "đ Waiting for: ${DESCRIPTION}"
+ echo "đ Budget: up to ${MAX_ATTEMPTS} attempts"
+ echo ""
+
+ sleep_s="${INITIAL_SLEEP_SECONDS}"
+ http_code="000"
+ for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do
+ # -sS: silent but still report hard errors. -L: follow redirects
+ # (registries often sit behind a CDN that issues a 301/302).
+ # No -f: we want the HTTP code on 4xx so we can distinguish 404
+ # ("not yet there") from 5xx ("transient") in the log.
+ # --max-time: cap per-request so a dead TCP connection does not
+ # burn the whole budget on one attempt.
+ http_code=$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 30
"${URL}" 2>/dev/null || echo "000")
+
+ if [ "${http_code}" = "200" ]; then
+ echo "â
${DESCRIPTION} is available (HTTP 200 from ${URL})"
+ exit 0
+ fi
+
+ if [ "${attempt}" -eq "${MAX_ATTEMPTS}" ]; then
+ break
+ fi
+ echo "âŗ HTTP ${http_code} - not yet available (attempt
${attempt}/${MAX_ATTEMPTS}, sleep ${sleep_s}s)"
+ sleep "${sleep_s}"
+ sleep_s=$(( sleep_s * 2 ))
+ if [ "${sleep_s}" -gt "${MAX_SLEEP_SECONDS}" ]; then
+ sleep_s="${MAX_SLEEP_SECONDS}"
+ fi
+ done
+
+ echo "â Timed out waiting for ${DESCRIPTION} after ${MAX_ATTEMPTS}
attempts"
+ echo " URL: ${URL}"
+ echo " Last HTTP code: ${http_code}"
+ echo " Common causes:"
+ echo " - registry propagation is slow (Maven Central especially
can take >10 minutes)"
+ echo " - the publish step did not actually land the artifact
(inspect the preceding publish step)"
+ echo " - the URL is wrong (verify package name, version, and URL
template for the registry)"
+ echo ""
+ echo " Not creating a git tag for this release; the operator can
rerun this job once"
+ echo " the registry has caught up, and the run will converge (the
publish is idempotent)."
+ exit 1
diff --git a/.github/workflows/_publish_rust_crates.yml
b/.github/workflows/_publish_rust_crates.yml
index 8c797f28d..613ab05b4 100644
--- a/.github/workflows/_publish_rust_crates.yml
+++ b/.github/workflows/_publish_rust_crates.yml
@@ -28,11 +28,6 @@ on:
required: false
default: false
description: "Dry run mode - validate without publishing"
- create_tags:
- type: boolean
- required: false
- default: true
- description: "Create git tags after successful publishing"
commit:
type: string
required: false
@@ -97,153 +92,201 @@ jobs:
- name: Setup Rust with cache
uses: ./.github/actions/utils/setup-rust-with-cache
- - name: Extract versions
+ # Normalize the inbound commit to a canonical full SHA. Caller
(publish.yml)
+ # already does this; a direct workflow_call caller must pass an explicit
+ # commit too. We used to fall back to github.sha here, but that bypasses
+ # publish.yml's master-ancestry check - callers get a false sense of
+ # safety from an ambient SHA that was never validated. Require the input
+ # explicitly so the contract is the same regardless of entry point.
+ - name: Resolve commit
+ id: resolve
+ env:
+ INPUT_COMMIT: ${{ inputs.commit }}
+ run: |
+ set -euo pipefail
+ if [ -z "${INPUT_COMMIT}" ]; then
+ echo "â _publish_rust_crates.yml: 'commit' input is required"
+ echo ""
+ echo "The caller must pass a commit SHA explicitly so the tag step"
+ echo "downstream can point at the exact reviewed commit (not
HEAD)."
+ echo "publish.yml resolves and validates this value in its
'validate' job;"
+ echo "direct workflow_call callers must do the same before
invoking."
+ exit 1
+ fi
+ FULL_SHA=$(git rev-parse --verify "${INPUT_COMMIT}^{commit}"
2>/dev/null || true)
+ if [ -z "$FULL_SHA" ] || ! [[ "$FULL_SHA" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "â Could not resolve commit '${INPUT_COMMIT}' to a full SHA"
+ exit 1
+ fi
+ echo "commit=$FULL_SHA" >> "$GITHUB_OUTPUT"
+ echo "â
Resolved commit: $FULL_SHA"
+
+ - name: Extract versions and tags
id: versions
run: |
chmod +x scripts/extract-version.sh
- echo "common=$(scripts/extract-version.sh rust-common)" >>
"$GITHUB_OUTPUT"
- echo "protocol=$(scripts/extract-version.sh rust-binary-protocol)"
>> "$GITHUB_OUTPUT"
- echo "sdk=$(scripts/extract-version.sh rust-sdk)" >> "$GITHUB_OUTPUT"
- echo "cli=$(scripts/extract-version.sh rust-cli)" >> "$GITHUB_OUTPUT"
+
+ common=$(scripts/extract-version.sh rust-common)
+ protocol=$(scripts/extract-version.sh rust-binary-protocol)
+ sdk=$(scripts/extract-version.sh rust-sdk)
+ cli=$(scripts/extract-version.sh rust-cli)
+
+ {
+ echo "common=$common"
+ echo "protocol=$protocol"
+ echo "sdk=$sdk"
+ echo "cli=$cli"
+ echo "common_tag=$(scripts/extract-version.sh rust-common --tag)"
+ echo "protocol_tag=$(scripts/extract-version.sh
rust-binary-protocol --tag)"
+ echo "sdk_tag=$(scripts/extract-version.sh rust-sdk --tag)"
+ echo "cli_tag=$(scripts/extract-version.sh rust-cli --tag)"
+ } >> "$GITHUB_OUTPUT"
echo "đĻ Versions to publish:"
- echo " iggy_common: $(scripts/extract-version.sh rust-common)"
- echo " iggy_binary_protocol: $(scripts/extract-version.sh
rust-binary-protocol)"
- echo " iggy: $(scripts/extract-version.sh rust-sdk)"
- echo " iggy-cli: $(scripts/extract-version.sh rust-cli)"
+ echo " iggy_common: $common"
+ echo " iggy_binary_protocol: $protocol"
+ echo " iggy: $sdk"
+ echo " iggy-cli: $cli"
+
+ # Dry-run path: route the entire chain through
scripts/verify-crates-publish.sh,
+ # which spins up cargo-http-registry locally and publishes the four
crates in
+ # topological order. `cargo package --no-verify` cannot be used here: it
still
+ # resolves dependencies against crates.io (to lock the .crate's
Cargo.lock),
+ # so the second crate in the chain (iggy_common -> iggy_binary_protocol)
fails
+ # whenever the in-tree version isn't already on crates.io. The script
bypasses
+ # that by pointing path deps at a real local registry for the duration
of the
+ # publish, mirroring what the pre-merge `verify-publish` task does on
PRs.
+ - name: Dry-run publish chain via local alt-registry
+ if: inputs.dry_run == true
+ env:
+ CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
+ AUTO_INSTALL: "1"
+ run: ./scripts/verify-crates-publish.sh
+
+ # Per-crate ordering is publish -> wait-for-availability -> tag. The wait
+ # step gates on the crates.io sparse index actually serving the new
+ # version, so the git tag is only pushed after we have positive proof
+ # the upload landed. If the wait times out, the tag is never pushed and
+ # a rerun can safely re-enter the chain: the publish step is idempotent
+ # via a sparse-index pre-check inside actions/rust/post-merge.
# Step 1: Publish iggy_binary_protocol (depends on nothing in-tree)
- name: Publish iggy_binary_protocol
- if: contains(inputs.crates, 'rust-binary-protocol')
+ if: inputs.dry_run == false && contains(inputs.crates,
'rust-binary-protocol')
uses: ./.github/actions/rust/post-merge
with:
package: iggy_binary_protocol
version: ${{ steps.versions.outputs.protocol }}
- dry_run: ${{ inputs.dry_run }}
- name: Wait for iggy_binary_protocol to be available
- if: contains(inputs.crates, 'rust-binary-protocol') && inputs.dry_run
== false
- run: |
- echo "âŗ Waiting for iggy_binary_protocol to be available on
crates.io..."
- found=false
- for i in {1..30}; do
- if cargo search iggy_binary_protocol --limit 1 | grep -q
"^iggy_binary_protocol = \"${{ steps.versions.outputs.protocol }}\""; then
- echo "â
iggy_binary_protocol is now available"
- found=true
- break
- fi
- echo "Waiting... (attempt $i/30)"
- sleep 10
- done
- if [ "$found" != "true" ]; then
- echo "â Timed out waiting for iggy_binary_protocol on crates.io
index"
- exit 1
- fi
+ if: inputs.dry_run == false && contains(inputs.crates,
'rust-binary-protocol')
+ uses: ./.github/actions/utils/wait-for-crate
+ with:
+ package: iggy_binary_protocol
+ version: ${{ steps.versions.outputs.protocol }}
+
+ - name: Tag iggy_binary_protocol
+ if: inputs.dry_run == false && contains(inputs.crates,
'rust-binary-protocol')
+ uses: ./.github/actions/utils/create-git-tag
+ with:
+ tag: ${{ steps.versions.outputs.protocol_tag }}
+ commit: ${{ steps.resolve.outputs.commit }}
+ message: |
+ Release iggy_binary_protocol ${{ steps.versions.outputs.protocol }}
+
+ Component: rust-binary-protocol
+ Tag: ${{ steps.versions.outputs.protocol_tag }}
+ Commit: ${{ steps.resolve.outputs.commit }}
+ Released by: GitHub Actions (workflow ${{ github.run_id }})
# Step 2: Publish iggy_common (depends on iggy_binary_protocol)
- name: Publish iggy_common
- if: contains(inputs.crates, 'rust-common')
+ if: inputs.dry_run == false && contains(inputs.crates, 'rust-common')
uses: ./.github/actions/rust/post-merge
with:
package: iggy_common
version: ${{ steps.versions.outputs.common }}
- dry_run: ${{ inputs.dry_run }}
- name: Wait for iggy_common to be available
- if: contains(inputs.crates, 'rust-common') && inputs.dry_run == false
- run: |
- echo "âŗ Waiting for iggy_common to be available on crates.io..."
- found=false
- for i in {1..30}; do
- if cargo search iggy_common --limit 1 | grep -q "^iggy_common =
\"${{ steps.versions.outputs.common }}\""; then
- echo "â
iggy_common is now available"
- found=true
- break
- fi
- echo "Waiting... (attempt $i/30)"
- sleep 10
- done
- if [ "$found" != "true" ]; then
- echo "â Timed out waiting for iggy_common on crates.io index"
- exit 1
- fi
+ if: inputs.dry_run == false && contains(inputs.crates, 'rust-common')
+ uses: ./.github/actions/utils/wait-for-crate
+ with:
+ package: iggy_common
+ version: ${{ steps.versions.outputs.common }}
+
+ - name: Tag iggy_common
+ if: inputs.dry_run == false && contains(inputs.crates, 'rust-common')
+ uses: ./.github/actions/utils/create-git-tag
+ with:
+ tag: ${{ steps.versions.outputs.common_tag }}
+ commit: ${{ steps.resolve.outputs.commit }}
+ message: |
+ Release iggy_common ${{ steps.versions.outputs.common }}
+
+ Component: rust-common
+ Tag: ${{ steps.versions.outputs.common_tag }}
+ Commit: ${{ steps.resolve.outputs.commit }}
+ Released by: GitHub Actions (workflow ${{ github.run_id }})
# Step 3: Publish iggy SDK (depends on common and protocol)
- name: Publish iggy SDK
- if: contains(inputs.crates, 'rust-sdk')
+ if: inputs.dry_run == false && contains(inputs.crates, 'rust-sdk')
uses: ./.github/actions/rust/post-merge
with:
package: iggy
version: ${{ steps.versions.outputs.sdk }}
- dry_run: ${{ inputs.dry_run }}
- name: Wait for iggy SDK to be available
- if: contains(inputs.crates, 'rust-sdk') && inputs.dry_run == false
- run: |
- echo "âŗ Waiting for iggy to be available on crates.io..."
- found=false
- for i in {1..30}; do
- if cargo search iggy --limit 1 | grep -q "^iggy = \"${{
steps.versions.outputs.sdk }}\""; then
- echo "â
iggy SDK is now available"
- found=true
- break
- fi
- echo "Waiting... (attempt $i/30)"
- sleep 10
- done
- if [ "$found" != "true" ]; then
- echo "â Timed out waiting for iggy SDK on crates.io index"
- exit 1
- fi
+ if: inputs.dry_run == false && contains(inputs.crates, 'rust-sdk')
+ uses: ./.github/actions/utils/wait-for-crate
+ with:
+ package: iggy
+ version: ${{ steps.versions.outputs.sdk }}
+
+ - name: Tag iggy SDK
+ if: inputs.dry_run == false && contains(inputs.crates, 'rust-sdk')
+ uses: ./.github/actions/utils/create-git-tag
+ with:
+ tag: ${{ steps.versions.outputs.sdk_tag }}
+ commit: ${{ steps.resolve.outputs.commit }}
+ message: |
+ Release iggy ${{ steps.versions.outputs.sdk }}
+
+ Component: rust-sdk
+ Tag: ${{ steps.versions.outputs.sdk_tag }}
+ Commit: ${{ steps.resolve.outputs.commit }}
+ Released by: GitHub Actions (workflow ${{ github.run_id }})
# Step 4: Publish iggy-cli (depends on SDK and protocol)
- name: Publish iggy-cli
- if: contains(inputs.crates, 'rust-cli')
+ if: inputs.dry_run == false && contains(inputs.crates, 'rust-cli')
uses: ./.github/actions/rust/post-merge
with:
package: iggy-cli
version: ${{ steps.versions.outputs.cli }}
- dry_run: ${{ inputs.dry_run }}
+
+ - name: Wait for iggy-cli to be available
+ if: inputs.dry_run == false && contains(inputs.crates, 'rust-cli')
+ uses: ./.github/actions/utils/wait-for-crate
+ with:
+ package: iggy-cli
+ version: ${{ steps.versions.outputs.cli }}
+
+ - name: Tag iggy-cli
+ if: inputs.dry_run == false && contains(inputs.crates, 'rust-cli')
+ uses: ./.github/actions/utils/create-git-tag
+ with:
+ tag: ${{ steps.versions.outputs.cli_tag }}
+ commit: ${{ steps.resolve.outputs.commit }}
+ message: |
+ Release iggy-cli ${{ steps.versions.outputs.cli }}
+
+ Component: rust-cli
+ Tag: ${{ steps.versions.outputs.cli_tag }}
+ Commit: ${{ steps.resolve.outputs.commit }}
+ Released by: GitHub Actions (workflow ${{ github.run_id }})
- name: Set final status output
id: final-status
if: always()
run: echo "status=${{ job.status }}" >> "$GITHUB_OUTPUT"
-
- # Tag creation runs in a separate job so it only fires when every crate
- # in the `publish` job has been uploaded successfully. Inlining this at
- # the end of `publish` created zombie tags whenever a mid-chain publish
- # step failed (leaving a version burned on crates.io with no matching
- # git tag, which then prevented re-running the publish at that version).
- create-tags:
- name: Create git tags
- needs: [publish]
- runs-on: ubuntu-latest
- if: success() && inputs.create_tags && inputs.dry_run == false
- permissions:
- contents: write
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- ref: ${{ inputs.commit || github.sha }}
- # We only need the tip commit to run the tagging script, but tags
- # must be available for the existence probe (`git rev-parse`) so
- # we don't try to re-create tags that were already pushed.
- fetch-depth: 1
- fetch-tags: true
- - name: Create and push tags
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
-
- for crate in $(echo "${{ inputs.crates }}" | tr ',' ' '); do
- TAG=$(scripts/extract-version.sh "$crate" --tag)
- if ! git rev-parse "$TAG" >/dev/null 2>&1; then
- git tag -a "$TAG" -m "Release $TAG"
- git push origin "$TAG"
- echo "â
Created tag: $TAG"
- else
- echo "âī¸ Tag $TAG already exists"
- fi
- done
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 5a73d5cac..766ab892c 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -165,22 +165,40 @@ jobs:
- name: Resolve commit
id: resolve
+ env:
+ INPUT_COMMIT: ${{ inputs.commit }}
+ IS_WORKFLOW_CALL: ${{ steps.detect.outputs.is_workflow_call }}
+ DRY_RUN: ${{ inputs.dry_run }}
run: |
- COMMIT="${{ inputs.commit }}"
+ set -euo pipefail
+ COMMIT="${INPUT_COMMIT}"
if [ -z "$COMMIT" ]; then
COMMIT="${{ github.sha }}"
echo "âšī¸ No commit specified, using github.sha: $COMMIT"
fi
- if ! git rev-parse --verify "$COMMIT^{commit}" >/dev/null 2>&1; then
+ # Validate AND normalize to the canonical full 40-char SHA in one
+ # shot. Downstream jobs (rust crates, docker manifests, SDK matrix,
+ # tag creation) all need the same fully-qualified SHA so they cannot
+ # drift if HEAD moves on the input branch mid-run.
+ FULL_SHA=$(git rev-parse --verify "${COMMIT}^{commit}" 2>/dev/null
|| true)
+ if [ -z "$FULL_SHA" ]; then
echo "â Invalid commit: $COMMIT"
exit 1
fi
+ if ! [[ "$FULL_SHA" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "â git rev-parse returned non-canonical SHA: $FULL_SHA"
+ exit 1
+ fi
+ if [ "$FULL_SHA" != "$COMMIT" ]; then
+ echo "âšī¸ Normalized $COMMIT -> $FULL_SHA"
+ fi
+ COMMIT="$FULL_SHA"
# Skip master branch check for workflow_call (caller already
verified on master)
- if [ "${{ steps.detect.outputs.is_workflow_call }}" = "true" ]; then
+ if [ "$IS_WORKFLOW_CALL" = "true" ]; then
echo "â
Called from workflow, skipping master check"
- elif ${{ inputs.dry_run }}; then
+ elif [ "$DRY_RUN" = "true" ]; then
echo "đĩ Dry run, skipping master branch check"
else
echo "đ Verifying commit is on master branch..."
@@ -490,12 +508,21 @@ jobs:
exit 1
fi
- # Check if tag exists
- if git rev-parse "$TAG" >/dev/null 2>&1; then
+ # Check if tag exists on the remote. Querying the REMOTE (not
+ # local `git rev-parse`) so this operator summary agrees with
+ # the authoritative view used by create-git-tag: a tag can exist
+ # on origin without being in this checkout, and the old local
+ # query hid exactly that case - you would see "will create" in
+ # the summary and then create-git-tag would refuse the push.
+ REMOTE_LINE=$(git ls-remote --tags origin "refs/tags/${TAG}"
2>/dev/null || true)
+ if [ -n "$REMOTE_LINE" ]; then
EXISTING_TAGS+=("$TAG")
- COMMIT_SHA=$(git rev-parse "$TAG" | head -c 8)
- echo "â ī¸ Tag exists: $TAG (points to $COMMIT_SHA)"
- echo "| $NAME | $VERSION | $TAG | â ī¸ Exists at $COMMIT_SHA |" >>
$GITHUB_STEP_SUMMARY
+ REMOTE_PEELED=$(git ls-remote --tags origin
"refs/tags/${TAG}^{}" 2>/dev/null | awk '{print $1}')
+ REMOTE_RAW=$(echo "$REMOTE_LINE" | awk '{print $1}')
+ EXISTING_SHA="${REMOTE_PEELED:-${REMOTE_RAW}}"
+ SHORT_SHA=$(echo "$EXISTING_SHA" | head -c 8)
+ echo "â ī¸ Tag exists on remote: $TAG (points to $SHORT_SHA)"
+ echo "| $NAME | $VERSION | $TAG | â ī¸ Exists at $SHORT_SHA |" >>
$GITHUB_STEP_SUMMARY
else
NEW_TAGS+=("$TAG")
echo "â
Tag will be created: $TAG"
@@ -573,7 +600,6 @@ jobs:
with:
crates: ${{ inputs.publish_crates }}
dry_run: ${{ inputs.dry_run }}
- create_tags: false # publish.yml handles tags separately in create-tags
job
commit: ${{ needs.validate.outputs.commit }}
use_latest_ci: ${{ inputs.use_latest_ci }}
secrets:
@@ -705,16 +731,48 @@ jobs:
- uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.commit }}
+ # Full history so create-git-tag can `git tag -a <commit>` against
+ # any historical SHA the operator passed in, not just HEAD.
+ fetch-depth: 0
- name: Ensure version extractor is executable
run: |
test -x scripts/extract-version.sh || chmod +x
scripts/extract-version.sh
- - name: Extract version
+ - name: Extract version & tag
id: ver
+ shell: bash
+ env:
+ MATRIX_KEY: ${{ matrix.key }}
+ MATRIX_TAG_PATTERN: ${{ matrix.tag_pattern }}
+ CREATE_EDGE_DOCKER_TAG: ${{ inputs.create_edge_docker_tag }}
run: |
- VERSION=$(scripts/extract-version.sh "${{ matrix.key }}")
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ set -euo pipefail
+ VERSION=$(scripts/extract-version.sh "$MATRIX_KEY")
+ TAG=""
+ if [ -n "$MATRIX_TAG_PATTERN" ] && [ "$MATRIX_TAG_PATTERN" != "null"
]; then
+ TAG=$(scripts/extract-version.sh "$MATRIX_KEY" --tag)
+ fi
+
+ # Base should_tag rule (SNAPSHOT + tag_pattern presence) is owned by
+ # extract-version.sh so it stays in sync with the SDK matrix below.
+ # The Docker-only auto-publish stable-skip rule layers on top: in
+ # auto-publish mode (create_edge_docker_tag=true) stable versions
+ # only get the rolling :edge tag, never a versioned git tag. This
+ # mirrors the manifest push policy a few steps below.
+ SHOULD_TAG=$(scripts/extract-version.sh "$MATRIX_KEY" --should-tag)
+ if [ "$SHOULD_TAG" = "true" ] \
+ && [ "$CREATE_EDGE_DOCKER_TAG" = "true" ] \
+ && [[ ! "$VERSION" =~ -(edge|rc) ]]; then
+ SHOULD_TAG=false
+ fi
+
+ {
+ echo "version=$VERSION"
+ echo "tag=$TAG"
+ echo "should_tag=$SHOULD_TAG"
+ } >> "$GITHUB_OUTPUT"
+ echo "â
Resolved $MATRIX_KEY -> version=$VERSION tag=${TAG:-<none>}
should_tag=$SHOULD_TAG"
- name: Resolve image from config
id: config
@@ -812,6 +870,27 @@ jobs:
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
fi
+ # Inline per-component tagging: tightly couple the git tag to the
+ # multi-arch manifest that just shipped. should_tag was computed in the
+ # version step above and already encodes the SNAPSHOT and auto-publish
+ # stable-Docker skip rules. dry_run is gated at the job level.
+ - name: Tag Docker release (${{ matrix.key }})
+ if: |
+ success() &&
+ inputs.skip_tag_creation == false &&
+ steps.ver.outputs.should_tag == 'true'
+ uses: ./.github/actions/utils/create-git-tag
+ with:
+ tag: ${{ steps.ver.outputs.tag }}
+ commit: ${{ needs.validate.outputs.commit }}
+ message: |
+ Release ${{ matrix.key }} ${{ steps.ver.outputs.version }}
+
+ Component: ${{ matrix.key }}
+ Tag: ${{ steps.ver.outputs.tag }}
+ Commit: ${{ needs.validate.outputs.commit }}
+ Released by: GitHub Actions (workflow ${{ github.run_id }})
+
# Non-Docker, non-Rust publishing (Python, Node, Java, C#, Go SDKs)
# Note: This job runs in parallel with Docker publishing - no dependency
between them
publish:
@@ -884,18 +963,26 @@ jobs:
- name: Extract version & tag
id: ver
shell: bash
+ env:
+ MATRIX_KEY: ${{ matrix.key }}
+ MATRIX_TAG_PATTERN: ${{ matrix.tag_pattern }}
run: |
set -euo pipefail
- VERSION=$(scripts/extract-version.sh "${{ matrix.key }}")
+ VERSION=$(scripts/extract-version.sh "$MATRIX_KEY")
# If a tag pattern exists for this component, ask the script for a
tag as well
- if [ -n "${{ matrix.tag_pattern }}" ] && [ "${{ matrix.tag_pattern
}}" != "null" ]; then
- TAG=$(scripts/extract-version.sh "${{ matrix.key }}" --tag)
+ if [ -n "$MATRIX_TAG_PATTERN" ] && [ "$MATRIX_TAG_PATTERN" != "null"
]; then
+ TAG=$(scripts/extract-version.sh "$MATRIX_KEY" --tag)
else
TAG=""
fi
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- echo "â
Resolved ${{ matrix.key }} -> version=$VERSION
tag=${TAG:-<none>}"
+ # Single source of truth for the SNAPSHOT/no-tag-pattern skip rule.
+ SHOULD_TAG=$(scripts/extract-version.sh "$MATRIX_KEY" --should-tag)
+ {
+ echo "version=$VERSION"
+ echo "tag=$TAG"
+ echo "should_tag=$SHOULD_TAG"
+ } >> "$GITHUB_OUTPUT"
+ echo "â
Resolved $MATRIX_KEY -> version=$VERSION tag=${TAG:-<none>}
should_tag=$SHOULD_TAG"
# âââââââââââââââââââââââââââââââââââââââââ
# Python SDK Publishing
@@ -949,126 +1036,103 @@ jobs:
version: ${{ steps.ver.outputs.version }}
dry_run: ${{ inputs.dry_run }}
- - name: Set status output
- id: status
- if: always()
- run: echo "status=${{ job.status }}" >> "$GITHUB_OUTPUT"
-
- create-tags:
- name: Create Git tags
- needs:
- [
- validate,
- plan,
- check-tags,
- build-python-wheels,
- publish-rust-crates,
- publish-docker,
- docker-manifests,
- publish,
- ]
- if: |
- always() &&
- needs.validate.outputs.has_targets == 'true' &&
- inputs.dry_run == false &&
- inputs.skip_tag_creation == false &&
- (needs.publish.result == 'success' || needs.publish.result == 'skipped')
&&
- (needs.publish-rust-crates.result == 'success' ||
needs.publish-rust-crates.result == 'skipped') &&
- (needs.publish-docker.result == 'success' || needs.publish-docker.result
== 'skipped') &&
- (needs.docker-manifests.result == 'success' ||
needs.docker-manifests.result == 'skipped') &&
- (needs.build-python-wheels.result == 'success' ||
needs.build-python-wheels.result == 'skipped')
- runs-on: ubuntu-latest
- permissions:
- contents: write
- steps:
- - name: Download latest copy script from master
- if: inputs.use_latest_ci
- run: |
- curl -sSL "https://raw.githubusercontent.com/${{ github.repository
}}/master/scripts/copy-latest-from-master.sh" \
- -o /tmp/copy-latest-from-master.sh
- chmod +x /tmp/copy-latest-from-master.sh
- echo "â
Downloaded latest copy script from master"
-
- - uses: actions/checkout@v4
+ # âââââââââââââââââââââââââââââââââââââââââ
+ # Wait gates: do not push the git tag until the registry is actually
+ # serving the new version. The Rust path already does this via
+ # wait-for-crate; mirroring it here closes the "artifact published,
+ # tag never created" rc1 failure class for foreign SDKs.
+ #
+ # Go has no registry wait: the tag IS the release (the Go proxy
+ # fetches from git on demand).
+ # âââââââââââââââââââââââââââââââââââââââââ
+ - name: Wait for PyPI availability
+ if: |
+ success() &&
+ inputs.dry_run == false &&
+ matrix.type == 'python' &&
+ steps.ver.outputs.should_tag == 'true'
+ uses: ./.github/actions/utils/wait-for-url
with:
- ref: ${{ needs.validate.outputs.commit }}
- fetch-depth: 0
-
- - name: Save and apply latest CI from master
- if: inputs.use_latest_ci
- run: |
- /tmp/copy-latest-from-master.sh save \
- .github \
- scripts
-
- /tmp/copy-latest-from-master.sh apply
-
- - name: Configure Git
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
-
- - name: Ensure version extractor is executable
- run: |
- test -x scripts/extract-version.sh || chmod +x
scripts/extract-version.sh
-
- - name: Create and push tags (for tagged components)
- shell: bash
- run: |
- set -euo pipefail
- TARGETS_JSON='${{ needs.plan.outputs.targets }}'
- CREATE_EDGE_DOCKER_TAG="${{ inputs.create_edge_docker_tag }}"
-
- echo "$TARGETS_JSON" | jq -r '.include[] | select(.key!="noop") |
@base64' | while read -r row; do
- _jq() { echo "$row" | base64 -d | jq -r "$1"; }
-
- KEY=$(_jq '.key')
- NAME=$(_jq '.name')
- TAG_PATTERN=$(_jq '.tag_pattern')
- REGISTRY=$(_jq '.registry')
-
- # Only components that define tag_pattern will be tagged
- if [ -z "$TAG_PATTERN" ] || [ "$TAG_PATTERN" = "null" ]; then
- continue
- fi
-
- VERSION=$(scripts/extract-version.sh "$KEY")
- TAG=$(scripts/extract-version.sh "$KEY" --tag)
-
- # SNAPSHOT versions are mutable; tagging would block future
publishes
- if [[ "$VERSION" =~ -SNAPSHOT$ ]]; then
- echo "âšī¸ Skipping git tag for $NAME (SNAPSHOT version)"
- continue
- fi
-
- # In auto-publish mode (create_edge_docker_tag=true), skip Docker
components
- # with stable versions - only pre-release versions should get
versioned tags.
- # This matches the behavior where versioned Docker manifests are
also skipped.
- if [ "$CREATE_EDGE_DOCKER_TAG" = "true" ] && [ "$REGISTRY" =
"dockerhub" ]; then
- if [[ ! "$VERSION" =~ -(edge|rc) ]]; then
- echo "âšī¸ Skipping git tag for $NAME (stable Docker version in
auto-publish mode)"
- continue
- fi
- fi
-
- echo "Creating tag: $TAG for $NAME"
-
- if git rev-parse "$TAG" >/dev/null 2>&1; then
- echo " â ī¸ Tag $TAG already exists, skipping"
- continue
- fi
+ url: https://pypi.org/pypi/apache-iggy/${{ steps.ver.outputs.version
}}/json
+ description: apache-iggy ${{ steps.ver.outputs.version }} on PyPI
+ max_attempts: "15"
+ initial_sleep_seconds: "3"
+
+ - name: Wait for npm availability
+ if: |
+ success() &&
+ inputs.dry_run == false &&
+ matrix.type == 'node' &&
+ steps.ver.outputs.should_tag == 'true'
+ uses: ./.github/actions/utils/wait-for-url
+ with:
+ url: https://registry.npmjs.org/apache-iggy/${{
steps.ver.outputs.version }}
+ description: apache-iggy ${{ steps.ver.outputs.version }} on npm
+ max_attempts: "15"
+ initial_sleep_seconds: "3"
+
+ # Maven Central propagation via the Central Portal is usually minutes
+ # but has a long tail; budget is ~25 minutes of real wall time (cap 30s
+ # sleep à ~50 attempts). `iggy-<version>.pom` is the lightest per-version
+ # URL that only appears once the artifact is fully indexed.
+ - name: Wait for Maven Central availability
+ if: |
+ success() &&
+ inputs.dry_run == false &&
+ matrix.type == 'java' &&
+ steps.ver.outputs.should_tag == 'true'
+ uses: ./.github/actions/utils/wait-for-url
+ with:
+ url: https://repo1.maven.org/maven2/org/apache/iggy/iggy/${{
steps.ver.outputs.version }}/iggy-${{ steps.ver.outputs.version }}.pom
+ description: org.apache.iggy:iggy:${{ steps.ver.outputs.version }}
on Maven Central
+ max_attempts: "50"
+ initial_sleep_seconds: "5"
+ max_sleep_seconds: "30"
+
+ # NuGet v3 flat container URLs use lowercase package id and version;
+ # Apache.Iggy â apache.iggy.
+ - name: Wait for NuGet availability
+ if: |
+ success() &&
+ inputs.dry_run == false &&
+ matrix.type == 'csharp' &&
+ steps.ver.outputs.should_tag == 'true'
+ uses: ./.github/actions/utils/wait-for-url
+ with:
+ url: https://api.nuget.org/v3-flatcontainer/apache.iggy/${{
steps.ver.outputs.version }}/apache.iggy.${{ steps.ver.outputs.version }}.nupkg
+ description: Apache.Iggy ${{ steps.ver.outputs.version }} on NuGet
+ max_attempts: "20"
+ initial_sleep_seconds: "3"
+
+ # Inline per-row tagging: tightly couple the git tag to the publish that
+ # just succeeded for THIS matrix row. should_tag was computed by
+ # extract-version.sh and encodes both the SNAPSHOT skip (mutable Java
+ # releases) and the "no tag_pattern" skip in one place â keeping the
+ # rule symmetric with the docker-manifests job above. The wait gates
+ # above ensure the tag is never pushed before the registry is serving
+ # the artifact (Go is exempt: the tag IS the release).
+ - name: Tag SDK release (${{ matrix.key }})
+ if: |
+ success() &&
+ inputs.dry_run == false &&
+ inputs.skip_tag_creation == false &&
+ steps.ver.outputs.should_tag == 'true'
+ uses: ./.github/actions/utils/create-git-tag
+ with:
+ tag: ${{ steps.ver.outputs.tag }}
+ commit: ${{ needs.validate.outputs.commit }}
+ message: |
+ Release ${{ matrix.key }} ${{ steps.ver.outputs.version }}
- git tag -a "$TAG" "${{ needs.validate.outputs.commit }}" \
- -m "Release $NAME ($TAG)
- Component: $NAME
- Tag: $TAG
+ Component: ${{ matrix.key }}
+ Tag: ${{ steps.ver.outputs.tag }}
Commit: ${{ needs.validate.outputs.commit }}
- Released by: GitHub Actions
- Date: $(date -u +"%Y-%m-%d %H:%M:%S UTC")"
+ Released by: GitHub Actions (workflow ${{ github.run_id }})
- git push origin "$TAG"
- echo " â
Created and pushed tag: $TAG"
- done
+ - name: Set status output
+ id: status
+ if: always()
+ run: echo "status=${{ job.status }}" >> "$GITHUB_OUTPUT"
summary:
name: Publish Summary
@@ -1082,7 +1146,6 @@ jobs:
publish-docker,
docker-manifests,
publish,
- create-tags,
]
if: always() && needs.validate.outputs.has_targets == 'true'
runs-on: ubuntu-latest
@@ -1255,11 +1318,8 @@ jobs:
echo
echo "**âšī¸ Tag creation was skipped as requested**"
else
- case "${{ needs.create-tags.result }}" in
- success) echo "â
**Git tags created successfully**" ;;
- failure) echo "â ī¸ **Tag creation had issues**" ;;
- skipped) echo "âī¸ **Tag creation was skipped (publish
failed)**" ;;
- esac
+ echo
+ echo "**âšī¸ Git tags are created inline with each successful
publish; see individual job logs for tag status**"
fi
echo
echo "---"
@@ -1277,7 +1337,6 @@ jobs:
publish-docker,
docker-manifests,
publish,
- create-tags,
summary,
]
if: failure() && inputs.dry_run == false
diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh
index a3e6ff27d..aa018a7a7 100755
--- a/scripts/bump-version.sh
+++ b/scripts/bump-version.sh
@@ -40,12 +40,13 @@ Components:
rust-cli core/cli + workspace dep
rust-connector-sdk core/connectors/sdk + workspace dep
rust-all All Rust crates at once
- --all All components (Rust + SDKs)
+ --all All components (Rust + SDKs + web-ui)
sdk-python foreign/python/Cargo.toml +
foreign/python/pyproject.toml
sdk-node foreign/node/package.json
sdk-go foreign/go/contracts/version.go
sdk-csharp foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
sdk-java foreign/java/gradle.properties
+ web-ui web/package.json
Version flags (exactly one required):
--patch Bump patch version (0.9.3 -> 0.9.4)
@@ -74,7 +75,7 @@ EOF
RUST_COMPONENTS="rust-sdk rust-common rust-binary-protocol rust-server
rust-cli rust-connector-sdk"
SDK_COMPONENTS="sdk-python sdk-node sdk-go sdk-csharp sdk-java"
-ALL_COMPONENTS="${RUST_COMPONENTS} ${SDK_COMPONENTS}"
+ALL_COMPONENTS="${RUST_COMPONENTS} ${SDK_COMPONENTS} web-ui"
# Returns "file:format" lines per component.
# Format keys: cargo, cargo-ws-dep:PKG, cargo-dep:PKG, python-cargo,
pyproject, json, csproj, gradle, go
@@ -121,6 +122,9 @@ get_version_files() {
sdk-java)
echo "foreign/java/gradle.properties:gradle"
;;
+ web-ui)
+ echo "web/package.json:json"
+ ;;
*)
echo -e "${RED}Unknown component: ${component}${NC}" >&2
echo "Valid: rust-all ${ALL_COMPONENTS}" >&2
diff --git a/scripts/extract-version.sh b/scripts/extract-version.sh
index 972d291d4..8b747800e 100755
--- a/scripts/extract-version.sh
+++ b/scripts/extract-version.sh
@@ -23,7 +23,7 @@
# versions from Cargo.toml, package.json, pyproject.toml, and other formats.
#
# Usage:
-# ./extract-version.sh <component> [--tag]
+# ./extract-version.sh <component> [--tag|--should-tag]
# ./extract-version.sh --all
# ./extract-version.sh --check
#
@@ -34,6 +34,10 @@
# # Get git tag for Rust SDK
# ./extract-version.sh rust-sdk --tag # Output: iggy-0.7.0
#
+# # Check whether component should be tagged in git
+# ./extract-version.sh sdk-java --should-tag # Output: false (SNAPSHOT)
+# ./extract-version.sh rust-sdk --should-tag # Output: true
+#
# # Get version for Python SDK
# ./extract-version.sh sdk-python # Output: 0.5.0
#
@@ -248,6 +252,7 @@ handle_check() {
# ââ Argument parsing âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
COMPONENT=""
RETURN_TAG=false
+RETURN_SHOULD_TAG=false
# Detect mode flags as first argument only
case "${1:-}" in
@@ -257,7 +262,6 @@ esac
# Original single-component flow
COMPONENT="${1:-}"
-RETURN_TAG=false
shift || true
while [[ $# -gt 0 ]]; do
@@ -266,6 +270,10 @@ while [[ $# -gt 0 ]]; do
RETURN_TAG=true
shift
;;
+ --should-tag)
+ RETURN_SHOULD_TAG=true
+ shift
+ ;;
*)
echo "Unknown option: $1" >&2
exit 1
@@ -273,11 +281,22 @@ while [[ $# -gt 0 ]]; do
esac
done
+if [[ "$RETURN_TAG" == "true" && "$RETURN_SHOULD_TAG" == "true" ]]; then
+ echo "Error: --tag and --should-tag are mutually exclusive" >&2
+ exit 1
+fi
+
if [[ -z "$COMPONENT" ]]; then
- echo "Usage: $0 <component> [--tag]" >&2
+ echo "Usage: $0 <component> [--tag|--should-tag]" >&2
echo " $0 --all" >&2
echo " $0 --check" >&2
echo "" >&2
+ echo " --tag Print the git tag this component would use for its
current version." >&2
+ echo " --should-tag Print 'true' if the current version should produce a
git tag, 'false'" >&2
+ echo " otherwise (SNAPSHOT or missing tag_pattern). This is
the SINGLE" >&2
+ echo " source of truth for taggability; publish.yml
consults it for every" >&2
+ echo " SDK matrix row." >&2
+ echo "" >&2
echo "Available components:" >&2
yq eval '.components | keys | .[]' "$CONFIG_FILE" | sed 's/^/ - /' >&2
exit 1
@@ -309,6 +328,35 @@ if [[ -z "$VERSION" ]]; then
exit 1
fi
+# --should-tag: derive whether this version should produce a git tag.
+#
+# This is THE SINGLE SOURCE OF TRUTH for taggability. Every component
+# matrix row in .github/workflows/publish.yml consults this value and
+# gates its create-git-tag step on it. Any new SDK whose versioning
+# model has mutable pre-release states (another -SNAPSHOT-style language,
+# for example) MUST extend the rules here, not add ad-hoc conditions in
+# publish.yml - otherwise the SDK matrix and the Docker manifests job
+# diverge on what counts as a release.
+#
+# A component is taggable when (a) it declares a tag_pattern in
+# publish.yml and (b) its version is not a -SNAPSHOT placeholder (Java
+# mutable releases). The Docker-only "create_edge_docker_tag stable skip"
+# rule depends on a workflow input, not the version, and stays in the
+# workflow (it layers on top of this result).
+if [[ "$RETURN_SHOULD_TAG" == "true" ]]; then
+ TAG_PATTERN=$(get_config "$COMPONENT" "tag_pattern")
+ if [[ -z "$TAG_PATTERN" ]]; then
+ echo "false"
+ exit 0
+ fi
+ if [[ "$VERSION" == *-SNAPSHOT ]]; then
+ echo "false"
+ exit 0
+ fi
+ echo "true"
+ exit 0
+fi
+
# Return tag or version based on flag
if [[ "$RETURN_TAG" == "true" ]]; then
TAG_PATTERN=$(get_config "$COMPONENT" "tag_pattern")