calc-next-version.sh
1 #!/usr/bin/env bash 2 # Calculate next semver from conventional commits since last v* tag. 3 # Rules: breaking (! or BREAKING CHANGE) → major; feat → minor; fix/chore/docs/etc → patch. 4 # Usage: run from repo root. Output: next version (e.g. v1.2.0) to stdout. 5 # 6 # Dependencies: git, bash only (no jq). CI-safe: run in pipeline via devbox or on any runner with git. 7 8 set -e 9 10 REPO_ROOT="$(git rev-parse --show-toplevel)" 11 cd "$REPO_ROOT" 12 13 # When HEAD is exactly a tag (e.g. in CI), we compute the version *for* that release: 14 # range = previous tag..HEAD, base = previous tag's version. Otherwise: latest tag = base, range = tag..HEAD. 15 CURRENT_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null) || true 16 ALL_V_TAGS=() 17 while IFS= read -r t; do [[ -n "$t" ]] && ALL_V_TAGS+=("$t"); done < <(git tag -l 'v*' --sort=-v:refname 2>/dev/null || true) 18 19 if [[ -n "$CURRENT_TAG" && ${#ALL_V_TAGS[@]} -ge 2 ]]; then 20 # We're on a tag; use previous tag for range so we're computing the version this release represents 21 PREV_TAG="${ALL_V_TAGS[1]}" 22 LAST_TAG="$PREV_TAG" 23 REV_RANGE="${PREV_TAG}..HEAD" 24 if [[ "$PREV_TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then 25 MAJOR="${BASH_REMATCH[1]}" 26 MINOR="${BASH_REMATCH[2]}" 27 PATCH="${BASH_REMATCH[3]}" 28 else 29 MAJOR=0 30 MINOR=0 31 PATCH=0 32 fi 33 elif [[ ${#ALL_V_TAGS[@]} -gt 0 ]]; then 34 LAST_TAG="${ALL_V_TAGS[0]}" 35 if [[ "$LAST_TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then 36 MAJOR="${BASH_REMATCH[1]}" 37 MINOR="${BASH_REMATCH[2]}" 38 PATCH="${BASH_REMATCH[3]}" 39 else 40 MAJOR=0 41 MINOR=0 42 PATCH=0 43 fi 44 REV_RANGE="${LAST_TAG}..HEAD" 45 else 46 MAJOR=0 47 MINOR=0 48 PATCH=0 49 REV_RANGE="HEAD" 50 fi 51 52 # Determine bump from commits: 3=major, 2=minor, 1=patch, 0=none 53 MAX_BUMP=0 54 RE_FEAT='^feat(\([^)]*\))?!?:' 55 RE_OTHER='^(fix|docs|style|refactor|perf|test|chore|ci|build)(\([^)]*\))?!?:' 56 57 while IFS= read -r -d '' block; do 58 [[ -z "$block" ]] && continue 59 subject=$(echo "$block" | head -n1) 60 body=$(echo "$block" | tail -n +2) 61 62 # Breaking: in subject (type!: or type(scope)!:) or in body 63 if [[ "$subject" == *'!'* ]] || [[ "$body" =~ BREAKING[[:space:]]*CHANGE ]]; then 64 MAX_BUMP=3 65 break 66 fi 67 if [[ "$subject" =~ $RE_FEAT ]]; then 68 [[ $MAX_BUMP -lt 2 ]] && MAX_BUMP=2 69 fi 70 if [[ "$subject" =~ $RE_OTHER ]]; then 71 [[ $MAX_BUMP -lt 1 ]] && MAX_BUMP=1 72 fi 73 done < <(git log "$REV_RANGE" --format="%s%n%b%n%x00" 2>/dev/null || true) 74 75 # If no commits or no recognized types, still bump patch for "next" version when there are commits 76 COMMIT_COUNT=$(git rev-list --count "$REV_RANGE" 2>/dev/null || echo 0) 77 if [[ "$COMMIT_COUNT" -gt 0 && $MAX_BUMP -eq 0 ]]; then 78 MAX_BUMP=1 79 fi 80 81 case $MAX_BUMP in 82 3) 83 MAJOR=$((MAJOR + 1)) 84 MINOR=0 85 PATCH=0 86 ;; 87 2) 88 MINOR=$((MINOR + 1)) 89 PATCH=0 90 ;; 91 1) PATCH=$((PATCH + 1)) ;; 92 *) ;; 93 esac 94 95 echo "v${MAJOR}.${MINOR}.${PATCH}"