Skip to content

Instantly share code, notes, and snippets.

@OleksandrKucherenko
Last active August 15, 2024 07:28
Show Gist options
  • Save OleksandrKucherenko/9fb14f81a29b46886ccd63b774c5959f to your computer and use it in GitHub Desktop.
Save OleksandrKucherenko/9fb14f81a29b46886ccd63b774c5959f to your computer and use it in GitHub Desktop.
Calculate Next Suitable Version Tag for Your Git based project
#!/usr/bin/env bash
# shellcheck disable=SC2155
## Copyright (C) 2017, Oleksandr Kucherenko
## Last revisit: 2023-09-30
## Version: 2.0.2
## License: MIT
## Fix: 2023-10-01, prefix for initial INIT_VERSION was not applied
## Fix: 2023-10-01, correct extraction of latest tag that match version pattern
## Added: 2023-09-30, @mrares prefix modification implemented
## Bug fixes: 2021-06-09, @slmingol changes applied
# For help:
# ./version-up.sh --help
# For developer / references:
# https://ryanstutorials.net/bash-scripting-tutorial/bash-functions.php
# http://tldp.org/LDP/abs/html/comparison-ops.html
# https://misc.flogisoft.com/bash/tip_colors_and_formatting
## display help
function help() {
echo 'usage: ./version-up.sh [-r|--release] [-a|--alpha] [-b|--beta] [-c|--release-candidate]'
echo ' [-m|--major] [-i|--minor] [-p|--patch] [-e|--revision] [-g|--git-revision]'
echo ' [--prefix root|sub-folder|any] [--stay] [--default] [--help]'
echo ''
echo 'Switches:'
echo ' --release switch stage to release, no suffix, -r'
echo ' --alpha switch stage to alpha, -a'
echo ' --beta switch stage to beta, -b'
echo ' --release-candidate switch stage to rc, -c'
echo ' --major increment MAJOR version part, -m'
echo ' --minor increment MINOR version part, -i'
echo ' --patch increment PATCH version part, -p'
echo ' --revision increment REVISION version part, -e'
echo ' --git-revision use git revision number as a revision part, -g'
echo ' --stay compose version.properties but do not do any increments, -s'
echo ' --default increment last found part of version, keeping the stage. Increment applied up to MINOR part.'
echo ' --apply run GIT command to apply version upgrade'
echo ' --prefix provide tag prefix or use on of the strategies: root, sub-folder (default), any_string'
echo ''
echo 'Version: [PREFIX]MAJOR.MINOR[.PATCH[.REVISION]][-STAGE]'
echo ''
echo 'Reference:'
echo ' https://semver.org/'
echo ''
echo 'Versions priority:'
echo ' 1.0.0-alpha < 1.0.0-beta < 1.0.0-rc < 1.0.0'
exit 0
}
## find the monorepo root folder
function monorepo_root() {
# Navigate up from the script directory until we find the .git sub-folder to determine the monorepo root
local monorepoRootDir=$(
dir="$(dirname "${BASH_SOURCE[0]}")"
while [[ "$dir" != '/' && ! -d "$dir/.git" ]]; do dir="$(dirname "$dir")"; done
echo "$dir"
)
echo "$monorepoRootDir"
}
# https://stackoverflow.com/a/67449155
function get_relative_path() {
local targetFilename=$(basename "$1")
local targetFolder=$(cd "$(dirname "$1")" && pwd) # absolute target folder path
local currentFolder=$(cd "$2" && pwd) # absolute source folder
local result=.
while [ "$currentFolder" != "$targetFolder" ]; do
if [[ "$targetFolder" =~ "$currentFolder"* ]]; then
local pointSegment=${targetFolder#${currentFolder}}
result=$result/${pointSegment#/}
break
fi
result="$result"/..
currentFolder=$(dirname "$currentFolder")
done
result=$result/$targetFilename
echo "${result#./}"
}
## get monorepo sub-folder
function prefix_sub_folder() {
local tmpFileName="temp.file"
local repoDir=$(realpath "$(monorepo_root)")
local relativePath=$(get_relative_path "$(pwd)/$tmpFileName" "$repoDir")
# expected / at the end
echo "${relativePath/$tmpFileName/}"
}
## resolve income parameter into prefix
function prefix_strategy() {
local strategy=${1:-"sub-folder"}
local resolution=""
if [[ "$strategy" == "root" ]]; then
resolution=""
elif [[ "$strategy" == "sub-folder" ]]; then
resolution=$(prefix_sub_folder)
else
resolution="$strategy"
fi
echo "$resolution"
}
## calculate the prefix based on the strategy
function use_prefix() {
PREFIX=$(prefix_strategy "$1")
echo "Tag prefix: '$PREFIX'"
}
## get the highest version tag for all branches
function highest_tag() {
local gitTag=$(git tag --list 2>/dev/null | sort -V | tail -n1 2>/dev/null)
echo "$gitTag"
}
## extract current branch name
function current_branch() {
## expected: heads/{branch_name}
## expected: {branch_name}
local gitBranch=$(git rev-parse --abbrev-ref HEAD | cut -d"/" -f2)
echo "$gitBranch"
}
## get latest/head commit hash number
function head_hash() {
local commitHash=$(git rev-parse --verify HEAD)
echo "$commitHash"
}
## extract tag commit hash code, tag name provided by argument
function tag_hash() {
local tagHash=$(git log -1 --format=format:"%H" "$1" 2>/dev/null | tail -n1)
echo "$tagHash"
}
## extract prefix argument before the actual parsing of flags is done
function preparse_prefix_argument() {
local args=("$@")
local resolved_prefix=$(prefix_strategy)
# if --prefix provided, then required filtering of the tags by provided prefix pattern
if [[ "${args[*]}" =~ "--prefix" ]]; then
local prefix=$(echo "${args[*]}" | sed 's/.*--prefix \([^ ]*\).*/\1/')
resolved_prefix=$(prefix_strategy "$prefix")
fi
echo "$resolved_prefix"
}
## get latest tag in specified branch
# shellcheck disable=SC2001
function latest_tag() {
local resolved_prefix=$(preparse_prefix_argument "$@")
# extract from git latest tag that started from number (OR from prefix and number)
local tag=$(git describe --tags --abbrev=0 --match="${resolved_prefix}[0-9]*" 2>/dev/null)
echo "$tag"
}
## get latest revision number
function latest_revision() {
local gitRevision=$(git rev-list --count HEAD 2>/dev/null)
echo "$gitRevision"
}
## parse first PART of the tage, extract PREFIX if any provided
# shellcheck disable=SC2001
function parse_first() {
# extract into PREFIX variable all non digits chars from the beginning of the PARTS[0]
local prefix=$(echo "${PARTS[0]}" | sed 's#\([^0-9]*\)\(.*\)#\1#')
# leave only digits in the PARTS[0]
local clean_part=$(echo "${PARTS[0]}" | sed 's#\([^0-9]*\)\(.*\)#\2#')
PREFIX=$prefix
PARTS[0]=$clean_part
}
## parse last found tag, extract it PARTS
function parse_last() {
local position=$(($1 - 1))
# two parts found only
# shellcheck disable=SC2206
local segments=(${PARTS[$position]//-/ }) # split by - into array of strings
#echo ${segments[@]}, size: ${#segments}
# found NUMBER
PARTS[$position]=${segments[0]}
#echo ${PARTS[@]}
# found SUFFIX
if [[ ${#segments} -ge 1 ]]; then
PARTS[4]=${segments[1],,} #lowercase
#echo ${PARTS[@]}, ${SUBS[@]}
fi
}
## increment REVISION part, don't touch STAGE
function increment_revision() {
PARTS[3]=$((PARTS[3] + 1))
IS_DIRTY=1
}
## increment PATCH part, reset all others lower PARTS, don't touch STAGE
function increment_patch() {
PARTS[2]=$((PARTS[2] + 1))
PARTS[3]=0
IS_DIRTY=1
}
## increment MINOR part, reset all others lower PARTS, don't touch STAGE
function increment_minor() {
PARTS[1]=$((PARTS[1] + 1))
PARTS[2]=0
PARTS[3]=0
IS_DIRTY=1
}
## increment MAJOR part, reset all others lower PARTS, don't touch STAGE
function increment_major() {
PARTS[0]=$((PARTS[0] + 1))
PARTS[1]=0
PARTS[2]=0
PARTS[3]=0
IS_DIRTY=1
}
## increment the number only of last found PART: REVISION --> PATCH --> MINOR. don't touch STAGE
function increment_last_found() {
if [[ "${#PARTS[3]}" == 0 || "${PARTS[3]}" == "0" ]]; then
if [[ "${#PARTS[2]}" == 0 || "${PARTS[2]}" == "0" ]]; then
increment_minor
else
increment_patch
fi
else
increment_revision
fi
# stage part is not EMPTY
if [[ "${#PARTS[4]}" != 0 ]]; then
IS_SHIFT=1
fi
}
## compose version from PARTS
function compose() {
local major="${PARTS[0]}"
local minor=".${PARTS[1]}"
local patch=".${PARTS[2]}"
local revision=".${PARTS[3]}"
local suffix="-${PARTS[4]}"
if [[ "${#patch}" == 1 ]]; then # if empty {patch}
patch=""
fi
if [[ "${#revision}" == 1 ]]; then # if empty {revision}
revision=""
fi
if [[ "${PARTS[3]}" == "0" ]]; then # if revision is ZERO
revision=""
fi
# shrink patch and revision
if [[ -z "${revision// /}" ]]; then
if [[ "${PARTS[2]}" == "0" ]]; then
patch=""
fi
else # revision is not EMPTY
if [[ "${#patch}" == 0 ]]; then
patch=".0"
fi
fi
# remove suffix if we don't have alpha/beta/rc
if [[ "${#suffix}" == 1 ]]; then
suffix=""
fi
echo "${PREFIX}${major}${minor}${patch}${revision}${suffix}" #full format
}
## print error message about conflict with existing tag and proposed tag
function error_conflict_tag() {
local red=$(tput setaf 1)
local end=$(tput sgr0)
local yellow=$(tput setaf 3)
echo -e "${red}ERROR:${end} "
echo -e "${red}ERROR:${end} Found conflict with existing tag ${yellow}$(compose)${end} / $PROPOSED_HASH"
echo -e "${red}ERROR:${end} Only manual resolving is possible now."
echo -e "${red}ERROR:${end} "
echo -e "${red}ERROR:${end} To Resolve try to add --revision or --patch modifier."
echo -e "${red}ERROR:${end} "
echo ""
}
## print help message how to apply changes manually
function help_manual_apply() {
echo 'To apply changes manually execute the command(s):'
echo -e "\033[90m"
echo " git tag $(compose)"
echo " git push origin $(compose)"
echo -e "\033[0m"
}
## save all support information into version.properties file
function publish_version_file() {
echo "# $(date)" >${VERSION_FILE}
{
echo "snapshot.version=$(compose)"
echo "snapshot.lasttag=$TAG"
echo "snapshot.revision=$REVISION"
echo "snapshot.hightag=$TOP_TAG"
echo "snapshot.branch=$BRANCH"
echo '# end of file'
} >>"${VERSION_FILE}"
}
## apply changes to GIT repository, local changes only
function apply_git_changes() {
echo ''
echo "Applying git repository version up... no push, only local tag assignment!"
echo ''
git tag "$(compose)"
# confirm that tag applied
git --no-pager log \
--pretty=format:"%h%x09%Cblue%cr%Cgreen%x09%an%Creset%x09%s%Cred%d%Creset" \
-n 2 --date=short | nl -w2 -s" "
echo ''
echo ''
}
## print current state of the repository
function report_current_state() {
# do we have any GIT tag for parsing?!
echo ""
if [[ -z "${TAG// /}" ]]; then
TAG=$INIT_VERSION
echo "No tags found."
else
echo "Found tag: $TAG in branch '$BRANCH'"
fi
# print current revision number based on number of commits
echo "Current Revision: $REVISION"
echo "Current Branch : $BRANCH"
echo "Repository Dir : \"$(realpath "$(monorepo_root)")\""
echo "Current Folder : \"$(prefix_sub_folder)\""
echo ""
}
PREFIX=$(preparse_prefix_argument "$@")
# initial version used for repository without tags
INIT_VERSION="${PREFIX}0.0.0.0-alpha"
# do GIT data extracting, globals
TAG=$(latest_tag "$@")
REVISION=$(latest_revision)
BRANCH=$(current_branch)
TOP_TAG=$(highest_tag)
TAG_HASH=$(tag_hash "$TAG")
HEAD_HASH=$(head_hash)
PROPOSED_HASH=""
VERSION_FILE=version.properties
report_current_state
# if tag and branch commit hashes are different, then print info about that
#echo $HEAD_HASH vs $TAG_HASH
# shellcheck disable=SC2199
if [[ "$@" == "" ]]; then
if [[ "$TAG_HASH" == "$HEAD_HASH" ]]; then
echo "Tag $TAG and HEAD are aligned. We will stay on the TAG version."
echo ""
NO_ARGS_VALUE='--stay'
else
PATTERN="^[0-9]+.[0-9]+(.[0-9]+)*(-(alpha|beta|rc))*$"
if [[ "$BRANCH" =~ $PATTERN ]]; then
echo "Detected version branch '$BRANCH'. We will auto-increment the last version PART."
echo ""
NO_ARGS_VALUE='--default'
else
echo "Detected branch name '$BRANCH' than does not match version pattern. We will increase MINOR."
echo ""
NO_ARGS_VALUE='--minor'
fi
fi
fi
#
# [PREFIX]{MAJOR}.{MINOR}[.{PATCH}[.{REVISION}][-(.*)]
#
# Suffix: alpha, beta, rc
# No Suffix --> {NEW_VERSION}-alpha
# alpha --> beta
# beta --> rc
# rc --> {VERSION}
#
# shellcheck disable=SC2206
PARTS=(${TAG//./ })
parse_first
parse_last ${#PARTS[@]} # array size as argument
#echo ${PARTS[@]}
# if no parameters than emulate --default parameter
# shellcheck disable=SC2199
if [[ "$@" == "" ]]; then
# shellcheck disable=SC2086
set -- ${NO_ARGS_VALUE}
fi
# parse input parameters
for i in "$@"; do
key="$i"
case $key in
-a | --alpha) # switched to ALPHA
PARTS[4]="alpha"
IS_SHIFT=1
;;
-b | --beta) # switched to BETA
PARTS[4]="beta"
IS_SHIFT=1
;;
-c | --release-candidate) # switched to RC
PARTS[4]="rc"
IS_SHIFT=1
;;
-r | --release) # switched to RELEASE
PARTS[4]=""
IS_SHIFT=1
;;
-p | --patch) # increment of PATCH
increment_patch
;;
-e | --revision) # increment of REVISION
increment_revision
;;
-g | --git-revision) # use git revision number as a revision part§
PARTS[3]=$((REVISION))
IS_DIRTY=1
;;
-i | --minor) # increment of MINOR by default
increment_minor
;;
--default) # stay on the same stage, but increment only last found PART of version code
increment_last_found
;;
-m | --major) # increment of MAJOR
increment_major
;;
-s | --stay) # extract version info
IS_DIRTY=1
NO_APPLY_MSG=1
;;
--prefix) # version tag prefix provided
use_prefix "$2"
shift # expected one more argument with prefix name
;;
--apply)
DO_APPLY=1
;;
-h | --help)
help
;;
esac
shift
done
# detected shift, but no increment
if [[ "$IS_SHIFT" == "1" ]]; then
# temporary disable stage shift
stage=${PARTS[4]}
PARTS[4]=''
# detect first run on repository, INIT_VERSION was used
if [[ "$(compose)" == "0.0" ]]; then
increment_minor
fi
PARTS[4]=$stage
fi
# no increment applied yet and no shift of state, do minor increase
if [[ "$IS_DIRTY$IS_SHIFT" == "" ]]; then
increment_minor
fi
# instruct user how to apply new TAG
echo -e "Proposed TAG: \033[32m$(compose)\033[0m"
echo ''
# is proposed tag in conflict with any other TAG
PROPOSED_HASH=$(tag_hash "$(compose)")
if [[ "${#PROPOSED_HASH}" -gt 0 && "$NO_APPLY_MSG" == "" ]]; then
error_conflict_tag
fi
if [[ "$NO_APPLY_MSG" == "" ]]; then
help_manual_apply
fi
# compose version override file
if [[ "$TAG" == "$INIT_VERSION" ]]; then
TAG='0.0'
fi
publish_version_file
# should we apply the changes
if [[ "$DO_APPLY" == "1" ]]; then
apply_git_changes
fi
#
# Major logic of the script - "on each run script propose future version of the product".
#
# - if no tags on project --> propose '0.1-alpha'
# - do multiple build iterations until you become satisfied with result
# - run 'version-up.sh --apply' to save result in GIT
#
@OleksandrKucherenko
Copy link
Author

@mrares just a hint, try chatgpt for updating the script - it solve such kind of tasks without any issues

@mrares
Copy link

mrares commented Sep 30, 2023

@mrares just a hint, try chatgpt for updating the script - it solve such kind of tasks without any issues

?!

@OleksandrKucherenko
Copy link
Author

@mrares Updated version with the --prefix approach implementation

Preview

prefix provided as user defined custom string:

image

sub-folder strategy in use: (default settings or can be forced by --prefix sub-folder)
script try to propose tag based on sub-folder path (relative path to monorepo root dir):

image

force root directory logic:

image

@OleksandrKucherenko
Copy link
Author

OleksandrKucherenko commented Sep 30, 2023

version 2.0.0 released

@rafilkmp3 @mrares @slmingol @rasmusskovdk @vlauciani @nicknezis @sadortun @thuitaw @Br3nda

Changes:

  • support for monorepo added
  • sub-folder used as version tag prefix by default, developer should be in the module folder when calling the version-up.sh script
  • --prefix flag allows to change the strategy 'root' - use repo root folder logic, 'sub-folder' (default), any-text - prefix provided by developer
  • refactoring:
    • more functionality placed into functions
    • local variables are in lower/camel case
    • global variables in UPPER case
    • shellcheck proposed/found warnings/errors fixed
  • tested on MacOs only

Known issues:

  • empty prefix or root strategy may select the wrong tag for processing, used hungry wildcard that select 'telemetry-0.0.1' instead of '1.0.0.0' tag from images above... any suggestions how fix are welcome 🙏

@OleksandrKucherenko
Copy link
Author

Fixed known issue from prev comment now:

image


image


image

@mrares
Copy link

mrares commented Oct 2, 2023

@OleksandrKucherenko Thank you, --prefix now works perfectly for my needs

@OleksandrKucherenko
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment