/ scripts / calc-next-version.sh
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}"