This is an automated email from the ASF dual-hosted git repository.

hgruszecki 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 50fe8ec77 feat(ci): add version bump tooling and consistency checks 
(#2990)
50fe8ec77 is described below

commit 50fe8ec7747ca149993687d9ad5f53d73023ca79
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Wed Apr 1 17:01:41 2026 +0200

    feat(ci): add version bump tooling and consistency checks (#2990)
---
 .github/workflows/_common.yml                      |  31 +-
 .pre-commit-config.yaml                            |  12 +-
 scripts/bump-version.sh                            | 639 +++++++++++++++++++++
 ...-version-sync.sh => python-sdk-version-sync.sh} |  13 +-
 scripts/ci/shellcheck.sh                           |   4 +-
 scripts/extract-version.sh                         | 271 ++++++---
 6 files changed, 879 insertions(+), 91 deletions(-)

diff --git a/.github/workflows/_common.yml b/.github/workflows/_common.yml
index 6b121d537..6dfdb9226 100644
--- a/.github/workflows/_common.yml
+++ b/.github/workflows/_common.yml
@@ -46,7 +46,26 @@ jobs:
       - uses: actions/checkout@v4
 
       - name: Check Python SDK versions are synchronized
-        run: ./scripts/ci/python-version-sync.sh --check
+        run: ./scripts/ci/python-sdk-version-sync.sh --check
+
+  version-consistency:
+    name: Check version consistency
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Setup yq
+        run: |
+          if ! command -v yq >/dev/null 2>&1; then
+            YQ_VERSION="v4.47.1"
+            
YQ_CHECKSUM="0fb28c6680193c41b364193d0c0fc4a03177aecde51cfc04d506b1517158c2fb"
+            curl -sSL -o /usr/local/bin/yq 
https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64
+            echo "${YQ_CHECKSUM}  /usr/local/bin/yq" | sha256sum -c - || exit 1
+            chmod +x /usr/local/bin/yq
+          fi
+
+      - name: Check version consistency
+        run: ./scripts/extract-version.sh --check
 
   pr-title:
     name: Check PR Title
@@ -255,6 +274,7 @@ jobs:
       [
         rust-versions,
         python-versions,
+        version-consistency,
         pr-title,
         license-headers,
         license-list,
@@ -295,6 +315,7 @@ jobs:
           # Always-run checks
           RUST_VERSIONS="${{ needs.rust-versions.result }}"
           PYTHON_VERSIONS="${{ needs.python-versions.result }}"
+          VERSION_CONSISTENCY="${{ needs.version-consistency.result }}"
           LICENSE_HEADERS="${{ needs.license-headers.result }}"
           LICENSE_LIST="${{ needs.license-list.result }}"
           MARKDOWN="${{ needs.markdown.result }}"
@@ -315,6 +336,14 @@ jobs:
             echo "| ⏭️ Python SDK Versions | $PYTHON_VERSIONS | Check skipped 
|" >> $GITHUB_STEP_SUMMARY
           fi
 
+          if [ "$VERSION_CONSISTENCY" = "success" ]; then
+            echo "| ✅ Version Consistency | success | Group and workspace dep 
versions match |" >> $GITHUB_STEP_SUMMARY
+          elif [ "$VERSION_CONSISTENCY" = "failure" ]; then
+            echo "| ❌ Version Consistency | failure | Version mismatch 
detected |" >> $GITHUB_STEP_SUMMARY
+          else
+            echo "| ⏭️ Version Consistency | $VERSION_CONSISTENCY | Check 
skipped |" >> $GITHUB_STEP_SUMMARY
+          fi
+
           if [ "$LICENSE_HEADERS" = "success" ]; then
             echo "| ✅ License Headers | success | All files have Apache 
headers |" >> $GITHUB_STEP_SUMMARY
           elif [ "$LICENSE_HEADERS" = "failure" ]; then
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bf0c242cf..d47136dc2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -79,9 +79,9 @@ repos:
         files: ^rust-toolchain\.toml$
         pass_filenames: false
 
-      - id: python-version-sync
+      - id: python-sdk-version-sync
         name: python sdk version sync
-        entry: ./scripts/ci/python-version-sync.sh
+        entry: ./scripts/ci/python-sdk-version-sync.sh
         args: ["--fix"]
         language: system
         files: ^foreign/python/(Cargo\.toml|pyproject\.toml)$
@@ -95,6 +95,14 @@ repos:
         files: ^(foreign|bdd|examples)/python/(pyproject\.toml|uv\.lock)$
         pass_filenames: false
 
+      - id: version-consistency
+        name: version consistency
+        entry: ./scripts/extract-version.sh
+        args: ["--check"]
+        language: system
+        files: 
(^Cargo\.toml$|^(core|foreign)/.*Cargo\.toml$|^foreign/node/package\.json$|^foreign/python/pyproject\.toml$|^foreign/csharp/.*\.csproj$|^foreign/java/gradle\.properties$|^foreign/go/contracts/version\.go$|^\.github/config/publish\.yml$)
+        pass_filenames: false
+
       - id: trailing-whitespace
         name: trailing whitespace
         entry: ./scripts/ci/trailing-whitespace.sh
diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh
new file mode 100755
index 000000000..fd4582c6b
--- /dev/null
+++ b/scripts/bump-version.sh
@@ -0,0 +1,639 @@
+#!/bin/bash
+# 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.
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+usage() {
+    cat <<'EOF'
+Usage:
+  bump-version.sh <component> <flags>
+  bump-version.sh --status [<component>]
+
+Components:
+  rust-sdk             core/sdk + workspace dep + python iggy dep
+  rust-common          core/common + workspace dep
+  rust-binary-protocol core/binary_protocol + workspace dep
+  rust-server          core/server
+  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)
+  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
+
+Version flags (exactly one required):
+  --patch       Bump patch version (0.9.3 -> 0.9.4)
+  --minor       Bump minor version (0.9.3 -> 0.10.0)
+  --major       Bump major version (0.9.3 -> 1.0.0)
+  --edge        Increment edge counter (0.9.3-edge.1 -> 0.9.3-edge.2)
+                Or add -edge.1 when combined with --patch/--minor/--major
+  --strip-edge  Remove edge suffix (0.9.3-edge.1 -> 0.9.3)
+  --set V       Set explicit version V (use --force to bypass validation)
+
+Modifiers:
+  --dry-run     Preview changes without writing (default: writes immediately)
+  --force       Bypass validation (only with --set)
+
+Examples:
+  bump-version.sh rust-all --patch --edge       # 0.9.3 -> 0.9.4-edge.1
+  bump-version.sh rust-all --edge               # 0.9.3-edge.1 -> 0.9.3-edge.2
+  bump-version.sh rust-all --strip-edge         # 0.9.3-edge.1 -> 0.9.3
+  bump-version.sh rust-all --minor --edge --dry-run
+  bump-version.sh --all --strip-edge
+  bump-version.sh rust-sdk --patch
+  bump-version.sh sdk-python --set 0.8.0
+  bump-version.sh --status
+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}"
+
+# Returns "file:format" lines per component.
+# Format keys: cargo, cargo-ws-dep:PKG, cargo-dep:PKG, python-cargo, 
pyproject, json, csproj, gradle, go
+get_version_files() {
+    local component="$1"
+    case "$component" in
+        rust-sdk)
+            echo "core/sdk/Cargo.toml:cargo"
+            echo "Cargo.toml:cargo-ws-dep:iggy"
+            echo "foreign/python/Cargo.toml:cargo-dep:iggy"
+            ;;
+        rust-common)
+            echo "core/common/Cargo.toml:cargo"
+            echo "Cargo.toml:cargo-ws-dep:iggy_common"
+            ;;
+        rust-binary-protocol)
+            echo "core/binary_protocol/Cargo.toml:cargo"
+            echo "Cargo.toml:cargo-ws-dep:iggy_binary_protocol"
+            ;;
+        rust-server)
+            echo "core/server/Cargo.toml:cargo"
+            ;;
+        rust-cli)
+            echo "core/cli/Cargo.toml:cargo"
+            echo "Cargo.toml:cargo-ws-dep:iggy-cli"
+            ;;
+        rust-connector-sdk)
+            echo "core/connectors/sdk/Cargo.toml:cargo"
+            echo "Cargo.toml:cargo-ws-dep:iggy_connector_sdk"
+            ;;
+        sdk-python)
+            echo "foreign/python/Cargo.toml:python-cargo"
+            echo "foreign/python/pyproject.toml:pyproject"
+            ;;
+        sdk-node)
+            echo "foreign/node/package.json:json"
+            ;;
+        sdk-go)
+            echo "foreign/go/contracts/version.go:go"
+            ;;
+        sdk-csharp)
+            echo "foreign/csharp/Iggy_SDK/Iggy_SDK.csproj:csproj"
+            ;;
+        sdk-java)
+            echo "foreign/java/gradle.properties:gradle"
+            ;;
+        *)
+            echo -e "${RED}Unknown component: ${component}${NC}" >&2
+            echo "Valid: rust-all ${ALL_COMPONENTS}" >&2
+            return 1
+            ;;
+    esac
+}
+
+# Parse canonical semver into "base pre_type pre_num".
+# "0.9.3-edge.1" -> "0.9.3 edge 1", "0.9.3" -> "0.9.3 stable 0"
+parse_version() {
+    local ver="$1"
+    if [[ "$ver" =~ ^([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
+        echo "$ver stable 0"
+    elif [[ "$ver" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-edge\.([0-9]+)$ ]]; then
+        echo "${BASH_REMATCH[1]} edge ${BASH_REMATCH[2]}"
+    else
+        echo -e "${RED}Cannot parse version: ${ver}${NC}" >&2
+        return 1
+    fi
+}
+
+# Compute next version from current + keyword + flags.
+# Args: current keyword add_edge(0|1) strip_edge(0|1)
+compute_next_version() {
+    local current="$1" keyword="$2" add_edge="${3:-0}" strip_edge="${4:-0}"
+    local parsed base pre_type pre_num
+    parsed=$(parse_version "$current") || return 1
+    read -r base pre_type pre_num <<< "$parsed"
+
+    local major minor patch
+    IFS='.' read -r major minor patch <<< "$base"
+
+    # --strip-edge: remove edge suffix
+    if [[ $strip_edge -eq 1 ]]; then
+        if [[ "$pre_type" != "edge" ]]; then
+            echo -e "${RED}Cannot --strip-edge: ${current} has no edge 
suffix${NC}" >&2
+            return 1
+        fi
+        echo "$base"
+        return 0
+    fi
+
+    # edge keyword: increment edge counter
+    if [[ "$keyword" == "edge" ]]; then
+        if [[ "$pre_type" != "edge" ]]; then
+            echo -e "${RED}Cannot 'edge' from stable version ${current}${NC}" 
>&2
+            echo -e "  Use ${GREEN}patch --edge${NC} or ${GREEN}minor 
--edge${NC} instead" >&2
+            return 1
+        fi
+        echo "${base}-edge.$((pre_num + 1))"
+        return 0
+    fi
+
+    # patch/minor/major: bump base version
+    local new_base
+    case "$keyword" in
+        patch) new_base="${major}.${minor}.$((patch + 1))" ;;
+        minor) new_base="${major}.$((minor + 1)).0" ;;
+        major) new_base="$((major + 1)).0.0" ;;
+        *)     echo -e "${RED}Invalid keyword: ${keyword}${NC}" >&2; return 1 
;;
+    esac
+
+    if [[ $add_edge -eq 1 ]]; then
+        echo "${new_base}-edge.1"
+    else
+        echo "${new_base}"
+    fi
+}
+
+# Convert canonical semver to ecosystem-specific format.
+translate_version() {
+    local canonical="$1" format="$2"
+    local parsed base pre_type pre_num
+    parsed=$(parse_version "$canonical" 2>/dev/null) || { echo "$canonical"; 
return 0; }
+    read -r base pre_type pre_num <<< "$parsed"
+
+    case "$format" in
+        cargo|cargo-ws-dep:*|cargo-dep:*|json|csproj|go)
+            echo "$canonical" ;;
+        python-cargo)
+            case "$pre_type" in
+                edge)   echo "${base}-dev${pre_num}" ;;
+                stable) echo "$base" ;;
+            esac ;;
+        pyproject)
+            case "$pre_type" in
+                edge)   echo "${base}.dev${pre_num}" ;;
+                stable) echo "$base" ;;
+            esac ;;
+        gradle)
+            case "$pre_type" in
+                edge)   echo "${base}-SNAPSHOT" ;;
+                stable) echo "$base" ;;
+            esac ;;
+        *)
+            echo -e "${RED}Unknown format: ${format}${NC}" >&2
+            return 1 ;;
+    esac
+}
+
+# Reverse: read ecosystem format, return canonical semver.
+canonicalize_version() {
+    local raw="$1" format="$2"
+    case "$format" in
+        cargo|cargo-ws-dep:*|cargo-dep:*|json|csproj|go)
+            echo "$raw" ;;
+        python-cargo)
+            # Handle both old (0.7.2-dev.1) and new (0.7.2-dev1) formats
+            if [[ "$raw" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-dev\.?([0-9]+)$ ]]; then
+                echo "${BASH_REMATCH[1]}-edge.${BASH_REMATCH[2]}"
+            else
+                echo "$raw"
+            fi ;;
+        pyproject)
+            # Handle both old (0.7.2.dev.1) and new (0.7.2.dev1) formats
+            if [[ "$raw" =~ ^([0-9]+\.[0-9]+\.[0-9]+)\.dev\.?([0-9]+)$ ]]; then
+                echo "${BASH_REMATCH[1]}-edge.${BASH_REMATCH[2]}"
+            else
+                echo "$raw"
+            fi ;;
+        gradle)
+            if [[ "$raw" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-SNAPSHOT$ ]]; then
+                echo "${BASH_REMATCH[1]}-edge.0"
+            else
+                echo "$raw"
+            fi ;;
+        *)
+            echo -e "${RED}Unknown format: ${format}${NC}" >&2
+            return 1 ;;
+    esac
+}
+
+# Extract version string from file based on format.
+read_current_version() {
+    local file="$1" format="$2"
+    local abs_file="${REPO_ROOT}/${file}"
+    local fmt_base="${format%%:*}"
+
+    case "$fmt_base" in
+        cargo)
+            grep '^version = ' "$abs_file" | head -1 | sed 's/version = 
"\(.*\)"/\1/' ;;
+        cargo-ws-dep)
+            local pkg="${format#cargo-ws-dep:}"
+            grep "^${pkg} = " "$abs_file" | head -1 | sed 's/.*version = 
"\([^"]*\)".*/\1/' ;;
+        cargo-dep)
+            local pkg="${format#cargo-dep:}"
+            grep "^${pkg} = " "$abs_file" | head -1 | sed 's/.*version = 
"\([^"]*\)".*/\1/' ;;
+        python-cargo)
+            grep '^version = ' "$abs_file" | head -1 | sed 's/version = 
"\(.*\)"/\1/' ;;
+        pyproject)
+            grep '^version = ' "$abs_file" | head -1 | sed 's/version = 
"\(.*\)"/\1/' ;;
+        json)
+            grep '"version"' "$abs_file" | head -1 | sed 's/.*"version": 
*"\([^"]*\)".*/\1/' ;;
+        csproj)
+            grep '<Version>' "$abs_file" | head -1 | sed 
's/.*<Version>\(.*\)<\/Version>.*/\1/' | tr -d '[:space:]' ;;
+        gradle)
+            grep '^version=' "$abs_file" | head -1 | sed 's/version=//' ;;
+        go)
+            grep 'const Version' "$abs_file" | head -1 | sed 's/.*const 
Version = "\([^"]*\)".*/\1/' ;;
+        *)
+            echo -e "${RED}Unknown format: ${format}${NC}" >&2
+            return 1 ;;
+    esac
+}
+
+# Write translated version into file using sed.
+write_version() {
+    local file="$1" format="$2" new_canonical="$3"
+    local abs_file="${REPO_ROOT}/${file}"
+    local fmt_base="${format%%:*}"
+    local translated
+    translated=$(translate_version "$new_canonical" "$format")
+
+    case "$fmt_base" in
+        cargo)
+            sed -i "1,/^version = \".*\"/s/^version = \".*\"/version = 
\"${translated}\"/" "$abs_file" ;;
+        cargo-ws-dep)
+            local pkg="${format#cargo-ws-dep:}"
+            sed -i "s/^\(${pkg} = .*version = \"\)[^\"]*/\1${translated}/" 
"$abs_file" ;;
+        cargo-dep)
+            local pkg="${format#cargo-dep:}"
+            sed -i "s/^\(${pkg} = .*version = \"\)[^\"]*/\1${translated}/" 
"$abs_file" ;;
+        python-cargo)
+            sed -i "1,/^version = \".*\"/s/^version = \".*\"/version = 
\"${translated}\"/" "$abs_file" ;;
+        pyproject)
+            sed -i '/^\[project\]/,/^\[/{ s/^version = ".*"/version = 
"'"${translated}"'"/ }' "$abs_file" ;;
+        json)
+            sed -i "0,/\"version\": *\"[^\"]*\"/{s/\"version\": 
*\"[^\"]*\"/\"version\": \"${translated}\"/}" "$abs_file" ;;
+        csproj)
+            sed -i 
"0,/<Version>[^<]*<\/Version>/{s/<Version>[^<]*<\/Version>/<Version>${translated}<\/Version>/}"
 "$abs_file" ;;
+        gradle)
+            sed -i "s/^version=.*/version=${translated}/" "$abs_file" ;;
+        go)
+            sed -i "0,/const Version = \"[^\"]*\"/{s/const Version = 
\"[^\"]*\"/const Version = \"${translated}\"/}" "$abs_file" ;;
+        *)
+            echo -e "${RED}Unknown format: ${format}${NC}" >&2
+            return 1 ;;
+    esac
+}
+
+# --status: print current versions for all or one component.
+cmd_status() {
+    local filter="${1:-}"
+    local components
+    if [[ "$filter" == "rust-all" ]]; then
+        components="$RUST_COMPONENTS"
+    elif [[ -n "$filter" ]]; then
+        components="$filter"
+    else
+        components="$ALL_COMPONENTS"
+    fi
+
+    for comp in $components; do
+        echo -e "${CYAN}${comp}${NC}"
+        local first_canonical=""
+        while IFS= read -r entry; do
+            local file="${entry%%:*}"
+            local format="${entry#*:}"
+            local raw canonical
+            raw=$(read_current_version "$file" "$format")
+            canonical=$(canonicalize_version "$raw" "$format")
+            printf "  %-55s %s" "$file" "$raw"
+            if [[ "$raw" != "$canonical" ]]; then
+                printf "  (canonical: %s)" "$canonical"
+            fi
+            printf "\n"
+            if [[ -z "$first_canonical" ]]; then
+                first_canonical="$canonical"
+            fi
+        done < <(get_version_files "$comp")
+        echo ""
+    done
+}
+
+# Collect unique files from version entries for dirty check.
+collect_files() {
+    local component="$1"
+    get_version_files "$component" | while IFS= read -r entry; do
+        echo "${entry%%:*}"
+    done | sort -u
+}
+
+# Pre-flight: check for uncommitted changes in version files.
+preflight_dirty_check() {
+    local component="$1"
+    local dirty_files=()
+    while IFS= read -r file; do
+        if ! git diff --quiet -- "$REPO_ROOT/$file" 2>/dev/null; then
+            dirty_files+=("$file")
+        fi
+        if ! git diff --cached --quiet -- "$REPO_ROOT/$file" 2>/dev/null; then
+            dirty_files+=("$file (staged)")
+        fi
+    done < <(collect_files "$component")
+
+    if [[ ${#dirty_files[@]} -gt 0 ]]; then
+        echo -e "${YELLOW}Warning: uncommitted changes in version files:${NC}" 
>&2
+        for f in "${dirty_files[@]}"; do
+            echo -e "  ${f}" >&2
+        done
+    fi
+}
+
+# Pre-flight: verify grouped components have consistent canonical versions.
+# Returns the canonical version (or exits on inconsistency).
+preflight_consistency_check() {
+    local component="$1"
+    local first_canonical="" first_file=""
+    local inconsistent=0
+
+    while IFS= read -r entry; do
+        local file="${entry%%:*}"
+        local format="${entry#*:}"
+        local raw canonical
+        raw=$(read_current_version "$file" "$format")
+        canonical=$(canonicalize_version "$raw" "$format")
+
+        if [[ -z "$first_canonical" ]]; then
+            first_canonical="$canonical"
+            first_file="$file"
+        elif [[ "$canonical" != "$first_canonical" ]]; then
+            if [[ $inconsistent -eq 0 ]]; then
+                echo -e "${RED}Inconsistent versions in ${component}:${NC}" >&2
+                echo -e "  ${first_file}: ${first_canonical}" >&2
+                inconsistent=1
+            fi
+            echo -e "  ${file}: ${canonical}" >&2
+        fi
+    done < <(get_version_files "$component")
+
+    if [[ $inconsistent -ne 0 ]]; then
+        echo -e "${RED}Fix version inconsistencies before bumping, or use 
--set --force.${NC}" >&2
+        return 1
+    fi
+
+    echo "$first_canonical"
+}
+
+# Main bump logic.
+cmd_bump() {
+    local component="$1"
+    shift
+    local apply=1 set_ver="" force=0 has_edge=0 strip_edge=0 keyword=""
+
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --patch)
+                [[ -n "$keyword" ]] && { echo -e "${RED}Conflicting flags: 
--${keyword} and --patch${NC}" >&2; return 1; }
+                keyword="patch" ;;
+            --minor)
+                [[ -n "$keyword" ]] && { echo -e "${RED}Conflicting flags: 
--${keyword} and --minor${NC}" >&2; return 1; }
+                keyword="minor" ;;
+            --major)
+                [[ -n "$keyword" ]] && { echo -e "${RED}Conflicting flags: 
--${keyword} and --major${NC}" >&2; return 1; }
+                keyword="major" ;;
+            --edge) has_edge=1 ;;
+            --strip-edge) strip_edge=1 ;;
+            --dry-run) apply=0 ;;
+            --set)
+                [[ $# -lt 2 ]] && { echo -e "${RED}--set requires a version 
argument${NC}" >&2; return 1; }
+                set_ver="$2"; shift ;;
+            --force) force=1 ;;
+            *) echo -e "${RED}Unknown flag: ${1}${NC}" >&2; return 1 ;;
+        esac
+        shift
+    done
+
+    # Resolve --edge: standalone = increment counter, combined with bump = add 
suffix
+    local add_edge=0
+    if [[ $has_edge -eq 1 ]]; then
+        if [[ -n "$keyword" ]]; then
+            add_edge=1
+        else
+            keyword="edge"
+        fi
+    fi
+
+    # Validate: need exactly one action
+    if [[ -z "$set_ver" && -z "$keyword" && $strip_edge -eq 0 ]]; then
+        echo -e "${RED}Missing version flag. Use --patch, --minor, --major, 
--edge, --strip-edge, or --set${NC}" >&2
+        return 1
+    fi
+
+    preflight_dirty_check "$component"
+    local current
+    current=$(preflight_consistency_check "$component")
+
+    local new_ver
+    if [[ -n "$set_ver" ]]; then
+        if [[ $force -eq 0 ]]; then
+            if ! parse_version "$set_ver" > /dev/null 2>&1; then
+                echo -e "${RED}Invalid version format: ${set_ver}${NC}" >&2
+                echo "Expected: X.Y.Z or X.Y.Z-edge.N (use --force to bypass)" 
>&2
+                return 1
+            fi
+        fi
+        if [[ $force -eq 0 && "$set_ver" == "$current" ]]; then
+            echo -e "${YELLOW}Already at ${set_ver}, nothing to do.${NC}"
+            return 0
+        fi
+        new_ver="$set_ver"
+    else
+        new_ver=$(compute_next_version "$current" "${keyword:-_none}" 
"$add_edge" "$strip_edge") || return 1
+    fi
+
+    # Java SNAPSHOT idempotency: any edge operation on already-SNAPSHOT is a 
no-op
+    # (SNAPSHOT has no counter, so --edge and --patch --edge both produce 
SNAPSHOT)
+    if [[ "$component" == "sdk-java" ]] && [[ "$keyword" == "edge" || 
$add_edge -eq 1 ]]; then
+        local raw_java
+        raw_java=$(read_current_version "foreign/java/gradle.properties" 
"gradle")
+        if [[ "$raw_java" == *-SNAPSHOT ]]; then
+            echo -e "${GREEN}sdk-java already at SNAPSHOT (${raw_java}), 
nothing to do.${NC}"
+            return 0
+        fi
+    fi
+
+    echo -e "${CYAN}Component:${NC} ${component}"
+    echo -e "${CYAN}Current:${NC}   ${current}"
+    echo -e "${CYAN}New:${NC}       ${new_ver}"
+    echo ""
+
+    local changed_files=()
+    while IFS= read -r entry; do
+        local file="${entry%%:*}"
+        local format="${entry#*:}"
+        local raw_old translated_new
+        raw_old=$(read_current_version "$file" "$format")
+        translated_new=$(translate_version "$new_ver" "$format")
+
+        if [[ "$raw_old" == "$translated_new" ]]; then
+            printf "  %-55s %s (unchanged)\n" "$file" "$raw_old"
+        else
+            printf "  %-55s %s -> ${GREEN}%s${NC}\n" "$file" "$raw_old" 
"$translated_new"
+            changed_files+=("$file")
+        fi
+
+        if [[ $apply -eq 1 ]]; then
+            write_version "$file" "$format" "$new_ver"
+            local verify
+            verify=$(read_current_version "$file" "$format")
+            if [[ "$verify" != "$translated_new" ]]; then
+                echo -e "${RED}FAILED to write ${file}: expected 
'${translated_new}', got '${verify}'${NC}" >&2
+                return 1
+            fi
+        fi
+    done < <(get_version_files "$component")
+
+    echo ""
+    if [[ $apply -eq 1 ]]; then
+        echo -e "${GREEN}Applied.${NC}"
+    else
+        echo -e "${YELLOW}Dry run. Remove --dry-run to write changes.${NC}"
+    fi
+
+    if [[ ${#changed_files[@]} -gt 0 ]]; then
+        local unique_files
+        IFS=$'\n' read -r -d '' -a unique_files < <(printf '%s\n' 
"${changed_files[@]}" | sort -u && printf '\0') || true
+
+        if [[ "${BUMP_MULTI:-0}" -eq 1 ]]; then
+            # In multi-component mode, accumulate files for a single commit 
suggestion
+            for f in "${unique_files[@]}"; do
+                BUMP_MULTI_FILES+=("$f")
+            done
+        else
+            echo ""
+            echo "Next steps:"
+            echo "  cargo generate-lockfile"
+            local git_add="  git add Cargo.lock"
+            for f in "${unique_files[@]}"; do
+                git_add+=" ${f}"
+            done
+            echo "$git_add"
+            echo "  git commit -m \"chore(release): bump ${component} to 
${new_ver}\""
+        fi
+    fi
+}
+
+# --- Argument parsing ---
+
+if [[ $# -eq 0 ]]; then
+    usage
+    exit 0
+fi
+
+case "$1" in
+    -h|--help)
+        usage
+        exit 0 ;;
+    --status)
+        cmd_status "${2:-}"
+        exit 0 ;;
+esac
+
+# Bump multiple components with the same flags.
+# Collects all changed files and prints a single commit suggestion at the end.
+cmd_bump_multi() {
+    local components="$1"
+    shift
+    local failed=0
+    BUMP_MULTI=1
+    BUMP_MULTI_FILES=()
+
+    for comp in $components; do
+        echo -e "${CYAN}--- ${comp} ---${NC}"
+        if cmd_bump "$comp" "$@"; then
+            :
+        else
+            failed=$((failed + 1))
+            echo -e "${RED}FAILED ${comp}${NC}"
+        fi
+        echo ""
+    done
+
+    if [[ $failed -gt 0 ]]; then
+        echo -e "${RED}${failed} component(s) failed.${NC}"
+        echo -e "Use ${CYAN}--set${NC} to handle them individually."
+        echo -e "Run ${CYAN}git checkout${NC} on affected files to revert 
partial changes."
+        BUMP_MULTI=0
+        return 1
+    fi
+
+    if [[ ${#BUMP_MULTI_FILES[@]} -gt 0 ]]; then
+        local unique_files
+        IFS=$'\n' read -r -d '' -a unique_files < <(printf '%s\n' 
"${BUMP_MULTI_FILES[@]}" | sort -u && printf '\0') || true
+        echo "Next steps:"
+        echo "  cargo generate-lockfile"
+        local git_add="  git add Cargo.lock"
+        for f in "${unique_files[@]}"; do
+            git_add+=" ${f}"
+        done
+        echo "$git_add"
+        echo "  git commit -m \"chore(release): bump all components\""
+    fi
+
+    BUMP_MULTI=0
+}
+
+# Resolve component target: --all, rust-all, or named component.
+# Remaining args are flags passed to cmd_bump.
+case "$1" in
+    --all)
+        shift
+        cmd_bump_multi "$ALL_COMPONENTS" "$@" ;;
+    rust-all)
+        shift
+        cmd_bump_multi "$RUST_COMPONENTS" "$@" ;;
+    --*)
+        echo -e "${RED}Expected component name, got flag: ${1}${NC}" >&2
+        echo "Use: bump-version.sh <component> <flags>" >&2
+        exit 1 ;;
+    *)
+        COMPONENT="$1"
+        shift
+        cmd_bump "$COMPONENT" "$@" ;;
+esac
diff --git a/scripts/ci/python-version-sync.sh 
b/scripts/ci/python-sdk-version-sync.sh
similarity index 92%
rename from scripts/ci/python-version-sync.sh
rename to scripts/ci/python-sdk-version-sync.sh
index 7abc966d0..3788573bc 100755
--- a/scripts/ci/python-version-sync.sh
+++ b/scripts/ci/python-sdk-version-sync.sh
@@ -87,16 +87,21 @@ if [ -z "$PYPROJECT_VERSION" ]; then
     exit 1
 fi
 
-# Normalize versions for comparison (both to PEP 440 format with .dev)
+# Normalize versions for comparison (both to strict PEP 440 format: X.Y.Z.devN)
+# Handles both old format (0.7.2-dev.1 / 0.7.2.dev.1) and new (0.7.2-dev1 / 
0.7.2.dev1)
 normalize_version() {
     local v="$1"
-    echo "${v//-dev/.dev}"
+    # -dev -> .dev (Cargo to PEP 440 separator)
+    v="${v//-dev/.dev}"
+    # .dev.N -> .devN (strip dot before counter if present)
+    v=$(echo "$v" | sed -E 's/\.dev\.([0-9])/\.dev\1/')
+    echo "$v"
 }
 
-# Convert PEP 440 format to Cargo format
+# Convert PEP 440 format to Cargo format: .devN -> -devN
 to_cargo_format() {
     local v="$1"
-    echo "${v//.dev/-dev}"
+    echo "$v" | sed -E 's/\.dev([0-9])/-dev\1/'
 }
 
 # Compare two versions, returns:
diff --git a/scripts/ci/shellcheck.sh b/scripts/ci/shellcheck.sh
index 2f769952a..c9cd209b6 100755
--- a/scripts/ci/shellcheck.sh
+++ b/scripts/ci/shellcheck.sh
@@ -86,7 +86,7 @@ if [ "$MODE" = "fix" ]; then
   FAILED=0
   while IFS= read -r -d '' script; do
     echo "Checking: $script"
-    if ! shellcheck -f gcc "$script"; then
+    if ! shellcheck -x -f gcc "$script"; then
       FAILED=1
     fi
     echo ""
@@ -103,7 +103,7 @@ else
   echo "🔍 Checking shell scripts..."
 
   # Run shellcheck on all shell scripts (checks all severities: error, 
warning, info, style)
-  if find . -type f -name "*.sh" "${FIND_EXCLUDE_ARGS[@]}" -exec shellcheck {} 
+; then
+  if find . -type f -name "*.sh" "${FIND_EXCLUDE_ARGS[@]}" -exec shellcheck -x 
{} +; then
     echo "✅ All shell scripts passed shellcheck"
   else
     echo ""
diff --git a/scripts/extract-version.sh b/scripts/extract-version.sh
index c76a3a069..d5ec54424 100755
--- a/scripts/extract-version.sh
+++ b/scripts/extract-version.sh
@@ -24,6 +24,8 @@
 #
 # Usage:
 #   ./extract-version.sh <component> [--tag]
+#   ./extract-version.sh --all
+#   ./extract-version.sh --check
 #
 # Examples:
 #   # Get version for Rust SDK
@@ -41,6 +43,12 @@
 #   # Get version for Go SDK
 #   ./extract-version.sh sdk-go                      # Output: 0.7.0
 #
+#   # List all components with versions
+#   ./extract-version.sh --all
+#
+#   # Validate version consistency across the workspace
+#   ./extract-version.sh --check
+#
 # The script uses the configuration from .github/config/publish.yml to 
determine:
 #   - Where to find the version file (version_file)
 #   - What regex pattern to use for extraction (version_regex)
@@ -49,6 +57,11 @@
 
 set -euo pipefail
 
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+NC='\033[0m'
+
 # Check for required tools
 if ! command -v yq &> /dev/null; then
     echo "Error: yq is required but not installed" >&2
@@ -59,45 +72,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
 CONFIG_FILE="$REPO_ROOT/.github/config/publish.yml"
 
-# Parse arguments
-COMPONENT="${1:-}"
-RETURN_TAG=false
-
-shift || true
-while [[ $# -gt 0 ]]; do
-    case "$1" in
-        --tag)
-            RETURN_TAG=true
-            shift
-            ;;
-        *)
-            echo "Unknown option: $1" >&2
-            exit 1
-            ;;
-    esac
-done
-
-if [[ -z "$COMPONENT" ]]; then
-    echo "Usage: $0 <component> [--tag]" >&2
-    echo "" >&2
-    echo "Available components:" >&2
-    yq eval '.components | keys | .[]' "$CONFIG_FILE" | sed 's/^/  - /' >&2
-    exit 1
-fi
-
-# Check if component exists
-if ! yq eval ".components.\"$COMPONENT\"" "$CONFIG_FILE" | grep -q .; then
-    echo "Error: Unknown component '$COMPONENT'" >&2
-    echo "" >&2
-    echo "Available components:" >&2
-    yq eval '.components | keys | .[]' "$CONFIG_FILE" | sed 's/^/  - /' >&2
-    exit 1
-fi
-
-# Extract component configuration
+# Extract a single config field for a given component
 get_config() {
-    local key="$1"
-    yq eval ".components.\"$COMPONENT\".$key // \"\"" "$CONFIG_FILE"
+    local component="$1"
+    local key="$2"
+    yq eval ".components.\"$component\".$key // \"\"" "$CONFIG_FILE"
 }
 
 # Generic regex-based extraction
@@ -112,18 +91,13 @@ extract_version_with_regex() {
 
     # Special handling for XML files (C# .csproj)
     if [[ "$file" == *.csproj ]] || [[ "$file" == *.xml ]]; then
-        # Extract version from XML tags like <PackageVersion> or <Version>
         grep -E '<(PackageVersion|Version)>' "$REPO_ROOT/$file" | head -1 | 
sed -E 's/.*<[^>]+>([^<]+)<.*/\1/' | tr -d ' '
     elif command -v perl &> /dev/null; then
-        # Use perl for more powerful regex support (supports multiline and 
lookarounds)
-        # Use m{} instead of // to avoid issues with slashes in regex
         perl -0777 -ne "if (m{$regex}) { print \$1; exit; }" "$REPO_ROOT/$file"
     else
-        # Fallback to grep -P if available
         if grep -P "" /dev/null 2>/dev/null; then
             grep -Pzo "$regex" "$REPO_ROOT/$file" | grep -Pao 
'[0-9]+\.[0-9]+\.[0-9]+[^"]*' | head -1
         else
-            # Basic fallback - may not work for all patterns
             grep -E "$regex" "$REPO_ROOT/$file" | head -1 | sed -E 
"s/.*$regex.*/\1/"
         fi
     fi
@@ -133,11 +107,12 @@ extract_version_with_regex() {
 extract_cargo_version() {
     local package="$1"
     local cargo_file="$2"
+    local component="$3"
 
     cd "$REPO_ROOT"
 
-    # Try cargo metadata first (most reliable)
     if command -v cargo &> /dev/null && command -v jq &> /dev/null; then
+        local version
         version=$(cargo metadata --no-deps --format-version=1 2>/dev/null | \
                   jq -r --arg pkg "$package" '.packages[] | select(.name == 
$pkg) | .version' | \
                   head -1)
@@ -148,74 +123,206 @@ extract_cargo_version() {
         fi
     fi
 
-    # Fallback to direct Cargo.toml parsing using the regex from config
     local version_regex
-    version_regex=$(get_config "version_regex")
+    version_regex=$(get_config "$component" "version_regex")
     if [[ -n "$version_regex" && -f "$REPO_ROOT/$cargo_file" ]]; then
         extract_version_with_regex "$cargo_file" "$version_regex"
     fi
 }
 
-# Main version extraction logic
-VERSION=""
-VERSION_FILE=$(get_config "version_file")
-VERSION_REGEX=$(get_config "version_regex")
-PACKAGE=$(get_config "package")
-
-# For Rust components with cargo metadata support
-if [[ "$COMPONENT" == rust-* ]] && [[ -n "$PACKAGE" ]]; then
-    # Use package name from config if available
-    VERSION=$(extract_cargo_version "$PACKAGE" "$VERSION_FILE")
-
-    # Fallback to regex-based extraction if cargo metadata failed
-    if [[ -z "$VERSION" ]] && [[ -n "$VERSION_FILE" ]] && [[ -n 
"$VERSION_REGEX" ]]; then
-        VERSION=$(extract_version_with_regex "$VERSION_FILE" "$VERSION_REGEX")
+# Extract version for a named component. Prints the version string to stdout.
+extract_component_version() {
+    local component="$1"
+    local version=""
+    local version_file
+    local version_regex
+    local package
+
+    version_file=$(get_config "$component" "version_file")
+    version_regex=$(get_config "$component" "version_regex")
+    package=$(get_config "$component" "package")
+
+    if [[ "$component" == rust-* ]] && [[ -n "$package" ]]; then
+        version=$(extract_cargo_version "$package" "$version_file" 
"$component")
+
+        if [[ -z "$version" ]] && [[ -n "$version_file" ]] && [[ -n 
"$version_regex" ]]; then
+            version=$(extract_version_with_regex "$version_file" 
"$version_regex")
+        fi
+    elif [[ -n "$version_file" ]] && [[ -n "$version_regex" ]]; then
+        version=$(extract_version_with_regex "$version_file" "$version_regex")
     fi
-# Generic extraction using version_file and version_regex
-elif [[ -n "$VERSION_FILE" ]] && [[ -n "$VERSION_REGEX" ]]; then
-    VERSION=$(extract_version_with_regex "$VERSION_FILE" "$VERSION_REGEX")
-else
-    echo "Error: No version extraction method available for component 
'$COMPONENT'" >&2
+
+    echo "$version"
+}
+
+# ── --all mode: print a table of all components ──────────────────────────────
+handle_all() {
+    local components
+    components=$(yq eval '.components | keys | .[]' "$CONFIG_FILE")
+
+    printf "%-28s %s\n" "COMPONENT" "VERSION"
+    printf "%-28s %s\n" "---------" "-------"
+
+    while IFS= read -r comp; do
+        local version
+        version=$(extract_component_version "$comp")
+
+        if [[ -z "$version" ]]; then
+            version="(error)"
+        fi
+
+        printf "%-28s %s\n" "$comp" "$version"
+    done <<< "$components"
+}
+
+# ── --check mode: validate version consistency ───────────────────────────────
+handle_check() {
+    local errors=0
+    local passes=0
+
+    # --- Check 1: Workspace dep consistency ---
+    echo "=== Workspace dep consistency ==="
+    local ws_cargo="$REPO_ROOT/Cargo.toml"
+
+    while IFS= read -r line; do
+        local pkg_name dep_version
+        # Extract package name (left of '=') and version from the dep spec
+        pkg_name=$(echo "$line" | sed -E 's/^([a-z_-]+)\s*=.*/\1/')
+        dep_version=$(echo "$line" | sed -E 
's/.*version\s*=\s*"([^"]+)".*/\1/')
+
+        if [[ -z "$pkg_name" ]] || [[ -z "$dep_version" ]]; then
+            continue
+        fi
+
+        # Find the matching component in publish.yml by its package field
+        local comp
+        comp=$(yq eval "[.components | to_entries[] | select(.value.package == 
\"$pkg_name\") | .key] | .[0] // \"\"" "$CONFIG_FILE")
+
+        if [[ -z "$comp" ]]; then
+            # No matching publish.yml component - skip silently
+            continue
+        fi
+
+        local comp_version
+        comp_version=$(extract_component_version "$comp")
+
+        if [[ "$dep_version" == "$comp_version" ]]; then
+            echo -e "  ${GREEN}PASS${NC} $pkg_name: workspace=$dep_version, 
component=$comp_version"
+            passes=$((passes + 1))
+        else
+            echo -e "  ${RED}FAIL${NC} $pkg_name: workspace=$dep_version, 
component($comp)=$comp_version"
+            errors=$((errors + 1))
+        fi
+    done < <(grep -E '^iggy[a-z_-]* = \{ path = .*, version = ".*" \}' 
"$ws_cargo")
+
+    echo ""
+
+    # --- Check 2: Python dual-file sync ---
+    echo "=== Python dual-file sync ==="
+    local python_script="$SCRIPT_DIR/ci/python-sdk-version-sync.sh"
+    if [[ -x "$python_script" ]]; then
+        if "$python_script" --check; then
+            passes=$((passes + 1))
+        else
+            errors=$((errors + 1))
+        fi
+    else
+        echo -e "  ${RED}FAIL${NC} python-sdk-version-sync.sh not found or not 
executable"
+        errors=$((errors + 1))
+    fi
+
+    echo ""
+
+    # --- Summary ---
+    local total=$((passes + errors))
+    echo "=== Summary ==="
+    echo -e "  Passed: ${GREEN}${passes}${NC}/${total}"
+    if [[ $errors -gt 0 ]]; then
+        echo -e "  Failed: ${RED}${errors}${NC}/${total}"
+        exit 1
+    else
+        echo -e "  ${GREEN}All checks passed.${NC}"
+    fi
+}
+
+# ── Argument parsing ─────────────────────────────────────────────────────────
+COMPONENT=""
+RETURN_TAG=false
+
+# Detect mode flags as first argument only
+case "${1:-}" in
+    --all)   handle_all; exit 0;;
+    --check) handle_check; exit 0;;
+esac
+
+# Original single-component flow
+COMPONENT="${1:-}"
+RETURN_TAG=false
+
+shift || true
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        --tag)
+            RETURN_TAG=true
+            shift
+            ;;
+        *)
+            echo "Unknown option: $1" >&2
+            exit 1
+            ;;
+    esac
+done
+
+if [[ -z "$COMPONENT" ]]; then
+    echo "Usage: $0 <component> [--tag]" >&2
+    echo "       $0 --all" >&2
+    echo "       $0 --check" >&2
+    echo "" >&2
+    echo "Available components:" >&2
+    yq eval '.components | keys | .[]' "$CONFIG_FILE" | sed 's/^/  - /' >&2
     exit 1
 fi
 
+# Check if component exists
+if ! yq eval ".components.\"$COMPONENT\"" "$CONFIG_FILE" | grep -q .; then
+    echo "Error: Unknown component '$COMPONENT'" >&2
+    echo "" >&2
+    echo "Available components:" >&2
+    yq eval '.components | keys | .[]' "$CONFIG_FILE" | sed 's/^/  - /' >&2
+    exit 1
+fi
+
+# Main version extraction logic
+VERSION=$(extract_component_version "$COMPONENT")
+
 # Validate version was found
 if [[ -z "$VERSION" ]]; then
     echo "Error: Could not extract version for component '$COMPONENT'" >&2
-    if [[ -n "$VERSION_FILE" ]]; then
-        echo "  Checked file: $VERSION_FILE" >&2
+    local_vf=$(get_config "$COMPONENT" "version_file")
+    local_vr=$(get_config "$COMPONENT" "version_regex")
+    if [[ -n "$local_vf" ]]; then
+        echo "  Checked file: $local_vf" >&2
     fi
-    if [[ -n "$VERSION_REGEX" ]]; then
-        echo "  Using regex: $VERSION_REGEX" >&2
+    if [[ -n "$local_vr" ]]; then
+        echo "  Using regex: $local_vr" >&2
     fi
     exit 1
 fi
 
 # Return tag or version based on flag
 if [[ "$RETURN_TAG" == "true" ]]; then
-    TAG_PATTERN=$(get_config "tag_pattern")
+    TAG_PATTERN=$(get_config "$COMPONENT" "tag_pattern")
     if [[ -z "$TAG_PATTERN" ]]; then
         echo "Error: No tag pattern defined for component '$COMPONENT'" >&2
         exit 1
     fi
 
-    # Replace the capture group in the pattern with the actual version
-    # The pattern has a capture group like 
"^iggy-([0-9]+\\.[0-9]+\\.[0-9]+...)$"
-    # We need to replace the (...) part with the actual version
-
-    # Extract the prefix (everything before the first capture group)
     PREFIX=$(echo "$TAG_PATTERN" | sed -E 's/^(\^?)([^(]*)\(.*/\2/')
-
-    # Extract the suffix (everything after the capture group)
     SUFFIX=$(echo "$TAG_PATTERN" | sed -E 's/.*\)[^)]*(\$?)$/\1/')
-
-    # Build the tag
     TAG="${PREFIX}${VERSION}${SUFFIX}"
-
-    # Remove regex anchors if present
     TAG=$(echo "$TAG" | sed 's/^\^//; s/\$$//')
 
     echo "$TAG"
 else
     echo "$VERSION"
-fi
\ No newline at end of file
+fi


Reply via email to