#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" VERSION_PATH="plugins/cad/VERSION" VERSION_FILE="$REPO_ROOT/$VERSION_PATH" PART="" SET_VERSION="" FROM_VERSION="" DRY_RUN=0 CHECK=0 CHECK_INCREMENTED_FROM="" COMMIT=0 AMEND=0 NO_EDIT=0 NO_VERIFY=0 SIGNOFF=0 TAG=0 FORCE_TAG=0 MESSAGES=() usage() { cat <<'EOF' Usage: scripts/release/bump-version.sh major|minor|patch [options] scripts/release/bump-version.sh --set-version X.Y.Z [options] scripts/release/bump-version.sh --check scripts/release/bump-version.sh --check-incremented-from REF Bumps or checks the canonical repo release version in plugins/cad/VERSION. Duplicate package and plugin metadata is synchronized from that file by scripts/release/sync-version.mjs during release preparation and production bundling. Common options: --dry-run Show planned edits and git actions without changing files. --no-commit Write files but do not commit. This is the default. --commit Commit the bump. --amend Amend the current commit instead of creating a new commit. -m, --message MSG Pass a commit message to git commit. May be repeated. --no-edit Reuse the current commit message with --amend. --no-verify Pass --no-verify to git commit. --signoff Pass --signoff to git commit. --no-tag Do not create a local tag. This is the default. --tag Create a local tag after committing. Manual fallback only. --force-tag Move an existing local release tag to HEAD. --from-version X Require the current canonical version to be X.Y.Z. EOF } die() { echo "error: $*" >&2 exit 1 } validate_semver() { local version="$1" if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then die "expected a plain semver version like 1.2.3, got '$version'" fi } read_version() { local version [ -f "$VERSION_FILE" ] || die "missing canonical version file: $VERSION_PATH" version="$(tr -d '[:space:]' < "$VERSION_FILE")" validate_semver "$version" printf '%s\n' "$version" } bump_version() { local current="$1" local part="$2" local major minor patch validate_semver "$current" IFS=. read -r major minor patch <<< "$current" case "$part" in major) printf '%s.0.0\n' "$((10#$major + 1))" ;; minor) printf '%s.%s.0\n' "$major" "$((10#$minor + 1))" ;; patch) printf '%s.%s.%s\n' "$major" "$minor" "$((10#$patch + 1))" ;; *) die "unknown bump part: $part" ;; esac } semver_greater() { local left="$1" local right="$2" local left_major left_minor left_patch right_major right_minor right_patch validate_semver "$left" validate_semver "$right" IFS=. read -r left_major left_minor left_patch <<< "$left" IFS=. read -r right_major right_minor right_patch <<< "$right" if [ "$((10#$left_major))" -ne "$((10#$right_major))" ]; then [ "$((10#$left_major))" -gt "$((10#$right_major))" ] return fi if [ "$((10#$left_minor))" -ne "$((10#$right_minor))" ]; then [ "$((10#$left_minor))" -gt "$((10#$right_minor))" ] return fi [ "$((10#$left_patch))" -gt "$((10#$right_patch))" ] } git_text_at_ref() { local ref="$1" [ -n "$ref" ] || die "base ref must not be empty" if [[ "$ref" =~ ^0+$ ]]; then die "base ref must be a real commit, not an empty all-zero ref" fi git -C "$REPO_ROOT" show "$ref:$VERSION_PATH" } stage_paths() { git -C "$REPO_ROOT" add -- "$VERSION_PATH" } commit_version_bump() { local next_version="$1" local command=(commit) stage_paths if [ "$AMEND" -eq 1 ]; then command+=(--amend) fi if [ "$NO_VERIFY" -eq 1 ]; then command+=(--no-verify) fi if [ "$SIGNOFF" -eq 1 ]; then command+=(--signoff) fi if [ "$NO_EDIT" -eq 1 ]; then command+=(--no-edit) elif [ "${#MESSAGES[@]}" -gt 0 ]; then local message for message in "${MESSAGES[@]}"; do command+=(-m "$message") done elif [ "$AMEND" -eq 1 ]; then command+=(--no-edit) else command+=(-m "Bump version to $next_version") fi git -C "$REPO_ROOT" "${command[@]}" } tag_exists() { git -C "$REPO_ROOT" rev-parse --verify --quiet "refs/tags/$1" >/dev/null } create_release_tag() { local tag_name="$1" local tag_commit head_commit if tag_exists "$tag_name"; then tag_commit="$(git -C "$REPO_ROOT" rev-list -n 1 "$tag_name")" head_commit="$(git -C "$REPO_ROOT" rev-parse HEAD)" if [ "$tag_commit" = "$head_commit" ]; then echo "Release tag already exists on HEAD: $tag_name" return fi [ "$FORCE_TAG" -eq 1 ] || die "release tag already exists on a different commit: $tag_name" git -C "$REPO_ROOT" tag -f "$tag_name" HEAD echo "Moved release tag: $tag_name" return fi git -C "$REPO_ROOT" tag "$tag_name" HEAD echo "Created release tag: $tag_name" } while [ "$#" -gt 0 ]; do case "$1" in major|minor|patch) [ -z "$PART" ] || die "provide only one semver bump part" PART="$1" ;; --set-version) [ "$#" -ge 2 ] || die "--set-version requires a value" SET_VERSION="$2" shift ;; --set-version=*) SET_VERSION="${1#--set-version=}" ;; --from-version) [ "$#" -ge 2 ] || die "--from-version requires a value" FROM_VERSION="$2" shift ;; --from-version=*) FROM_VERSION="${1#--from-version=}" ;; --dry-run) DRY_RUN=1 ;; --check) CHECK=1 ;; --check-incremented-from) [ "$#" -ge 2 ] || die "--check-incremented-from requires a ref" CHECK=1 CHECK_INCREMENTED_FROM="$2" shift ;; --check-incremented-from=*) CHECK=1 CHECK_INCREMENTED_FROM="${1#--check-incremented-from=}" ;; --no-commit) COMMIT=0 TAG=0 ;; --commit) COMMIT=1 ;; --amend) AMEND=1 ;; -m|--message) [ "$#" -ge 2 ] || die "$1 requires a value" MESSAGES+=("$2") shift ;; --message=*) MESSAGES+=("${1#--message=}") ;; --no-edit) NO_EDIT=1 ;; --no-verify) NO_VERIFY=1 ;; --signoff) SIGNOFF=1 ;; --no-tag) TAG=0 ;; --tag) TAG=1 ;; --force-tag) FORCE_TAG=1 ;; -h|--help) usage exit 0 ;; *) die "unknown argument: $1" ;; esac shift done cd "$REPO_ROOT" if [ "$CHECK" -eq 1 ]; then if [ -n "$PART" ] || [ -n "$SET_VERSION" ] || [ -n "$FROM_VERSION" ] || [ "$DRY_RUN" -eq 1 ] || [ "$COMMIT" -eq 1 ] || [ "$AMEND" -eq 1 ] || [ "${#MESSAGES[@]}" -gt 0 ] || [ "$NO_EDIT" -eq 1 ] || [ "$NO_VERIFY" -eq 1 ] || [ "$SIGNOFF" -eq 1 ] || [ "$TAG" -eq 1 ] || [ "$FORCE_TAG" -eq 1 ]; then die "version bump, git commit, and tag arguments cannot be combined with check modes" fi current_version="$(read_version)" if [ -n "$CHECK_INCREMENTED_FROM" ]; then base_version="$(git_text_at_ref "$CHECK_INCREMENTED_FROM" | tr -d '[:space:]')" validate_semver "$base_version" if ! semver_greater "$current_version" "$base_version"; then die "current version $current_version must be greater than $base_version from $CHECK_INCREMENTED_FROM" fi echo "Canonical release version is incremented from $CHECK_INCREMENTED_FROM: $base_version -> $current_version" else echo "Canonical release version is valid: $current_version" fi exit 0 fi if [ -n "$PART" ] && [ -n "$SET_VERSION" ]; then die "provide exactly one of major/minor/patch or --set-version" fi if [ -z "$PART" ] && [ -z "$SET_VERSION" ]; then die "provide exactly one of major/minor/patch or --set-version" fi if [ -n "$SET_VERSION" ]; then validate_semver "$SET_VERSION" fi if [ -n "$FROM_VERSION" ]; then validate_semver "$FROM_VERSION" fi if [ "$COMMIT" -eq 1 ] && [ "$AMEND" -eq 1 ]; then die "--commit and --amend are mutually exclusive" fi if [ "$NO_EDIT" -eq 1 ] && [ "$AMEND" -eq 0 ]; then die "--no-edit only applies with --amend" fi if [ "$NO_EDIT" -eq 1 ] && [ "${#MESSAGES[@]}" -gt 0 ]; then die "--no-edit cannot be combined with --message" fi if [ "$TAG" -eq 1 ] && [ "$COMMIT" -eq 0 ] && [ "$AMEND" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then die "--tag requires --commit or --amend" fi if [ "$FORCE_TAG" -eq 1 ] && [ "$TAG" -eq 0 ]; then die "--force-tag requires --tag" fi current_version="$(read_version)" if [ -n "$FROM_VERSION" ] && [ "$current_version" != "$FROM_VERSION" ]; then die "$VERSION_PATH is $current_version, expected $FROM_VERSION" fi if [ -n "$SET_VERSION" ]; then next_version="$SET_VERSION" else next_version="$(bump_version "$current_version" "$PART")" fi if [ "$next_version" = "$current_version" ]; then die "next version matches current version: $current_version" fi echo "Version bump: $current_version -> $next_version" echo "- $VERSION_PATH (canonical plugin version)" if [ "$DRY_RUN" -eq 1 ]; then echo "Dry run only; no files changed." if [ "$COMMIT" -eq 1 ] || [ "$AMEND" -eq 1 ]; then if [ "$AMEND" -eq 1 ]; then echo "Would amend the current commit." else echo "Would create a commit." fi fi if [ "$TAG" -eq 1 ]; then echo "Would create release tag: $next_version" fi echo "Release workflow: gh workflow run release.yml" echo "Local fallback: scripts/release/bump-version.sh --set-version $next_version --no-commit" exit 0 fi printf '%s\n' "$next_version" > "$VERSION_FILE" echo "Updated 1 file." if [ "$COMMIT" -eq 1 ] || [ "$AMEND" -eq 1 ]; then commit_version_bump "$next_version" echo "Committed version bump." fi if [ "$TAG" -eq 1 ]; then create_release_tag "$next_version" elif [ "$COMMIT" -eq 1 ] || [ "$AMEND" -eq 1 ]; then echo "Release tag to create separately: $next_version" else echo "Release workflow: gh workflow run release.yml" echo "Local fallback: scripts/release/bump-version.sh --set-version $next_version --no-commit" fi