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")

Reply via email to