Last active
August 15, 2024 07:28
-
-
Save OleksandrKucherenko/9fb14f81a29b46886ccd63b774c5959f to your computer and use it in GitHub Desktop.
Calculate Next Suitable Version Tag for Your Git based project
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 | |
# |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
migrated to GitHub Project - https://github.com/OleksandrKucherenko/e-bash