This is an automated email from the ASF dual-hosted git repository. hgruszecki pushed a commit to branch bump-versions-script in repository https://gitbox.apache.org/repos/asf/iggy.git
commit 1f471379fcd559b1d23bc9ae8b122ebcf0468ba6 Author: Hubert Gruszecki <[email protected]> AuthorDate: Fri Mar 20 15:19:04 2026 +0100 feat(ci): add version bump tooling and consistency checks Bumping versions across 14 components in 6 ecosystems was entirely manual - editing 30+ files with no validation. Add bump-version.sh for automated version bumps with ecosystem-aware translation (Rust -edge.N, Python .devN, Java -SNAPSHOT), a state machine enforcing valid transitions, and dry-run support. Supports individual components, rust-all, and --all targets. Extend extract-version.sh with --all (version table) and --check (workspace dep + Python dual-file validation). Add CI job and pre-commit hook for consistency checks. Rename python-version-sync.sh to python-sdk-version-sync.sh. NOTE: this script NOT supposed to be called by CI machinery. It's intended to be used ONLY manually by maintainers. --- .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
