#!/usr/bin/env bash PROG=semtag PROG_VERSION="v0.1.0" SEMVER_REGEX="^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$" IDENTIFIER_REGEX="^\-([0-9A-Za-z-]+)\.([0-9A-Za-z-]+)*$" # Global variables FIRST_VERSION="v0.0.0" finalversion=$FIRST_VERSION lastversion=$FIRST_VERSION hasversiontag="false" scope="patch" displayonly="false" forcetag="false" forcedversion= versionname= identifier= HELP="\ Usage: $PROG $PROG getlast $PROG getfinal $PROG (final|alpha|beta|candidate) [-s (major|minor|patch|auto) | -o] $PROG --help $PROG --version Options: -s The scope that must be increased, can be major, minor or patch. The resulting version will match X.Y.Z(-PRERELEASE)(+BUILD) where X, Y and Z are positive integers, PRERELEASE is an optionnal string composed of alphanumeric characters describing if the build is a release candidate, alpha or beta version, with a number. BUILD is also an optional string composed of alphanumeric characters and hyphens. Setting the scope as 'auto', the script will chose the scope between 'minor' and 'patch', depending on the amount of lines added (<10% will choose patch). -v Specifies manually the version to be tagged, must be a valid semantic version in the format X.Y.Z where X, Y and Z are positive integers. -o Output the version only, shows the bumped version, but doesn't tag. -f Forces to tag, even if there are unstaged or uncommited changes. Commands: --help Print this help message. --version Prints the program's version. get Returns both current final version and last tagged version. getlast Returns the latest tagged version. getfinal Returns the latest tagged final version. getcurrent Returns the current version, based on the latest one, if there are uncommited or unstaged changes, they will be reflected in the version, adding the number of pending commits, current branch and commit hash. final Tags the current build as a final version, this only can be done on the master branch. candidate Tags the current build as a release candidate, the tag will contain all the commits from the last final version. alpha Tags the current build as an alpha version, the tag will contain all the commits from the last final version. beta Tags the current build as a beta version, the tag will contain all the commits from the last final version." # Commands and options ACTION="getlast" ACTION="$1" shift # We get the parameters while getopts "v:s:of" opt; do case $opt in v) forcedversion="$OPTARG" ;; s) scope="$OPTARG" ;; o) displayonly="true" ;; f) forcetag="true" ;; \?) echo "Invalid option: -$OPTARG" >&2 exit 1 ;; :) echo "Option -$OPTARG requires an argument." >&2 exit 1 ;; esac done # Gets a string with the version and returns an array of maximum size of 5 with all the parts of the sematinc version # $1 The string containing the version in semantic format # $2 The variable to store the result array: # position 0: major number # position 1: minor number # position 2: patch number # position 3: identifier (or prerelease identifier) # position 4: build info function explode_version { local __version=$1 local __result=$2 if [[ $__version =~ $SEMVER_REGEX ]] ; then local __major=${BASH_REMATCH[1]} local __minor=${BASH_REMATCH[2]} local __patch=${BASH_REMATCH[3]} local __prere=${BASH_REMATCH[4]} local __build=${BASH_REMATCH[5]} eval "$__result=(\"$__major\" \"$__minor\" \"$__patch\" \"$__prere\" \"$__build\")" else eval "$__result=" fi } # Compare two versions and returns -1, 0 or 1 # $1 The first version to compare # $2 The second version to compare # $3 The variable where to store the result function compare_versions { local __first local __second explode_version $1 __first explode_version $2 __second local lv=$3 # Compares MAJOR, MINOR and PATCH for i in 0 1 2; do local __numberfirst=${__first[$i]} local __numbersecond=${__second[$i]} case $(($__numberfirst - $__numbersecond)) in 0) ;; -[0-9]*) eval "$lv=-1" return 0 ;; [0-9]*) eval "$lv=1" return 0 ;; esac done # Identifiers should compare with the ASCII order. local __identifierfirst=${__first[3]} local __identifiersecond=${__second[3]} if [[ -n "$__identifierfirst" ]] && [[ -n "$__identifiersecond" ]]; then if [[ "$__identifierfirst" > "$__identifiersecond" ]]; then eval "$lv=1" return 0 elif [[ "$__identifierfirst" < "$__identifiersecond" ]]; then eval "$lv=-1" return 0 fi elif [[ -z "$__identifierfirst" ]] && [[ -n "$__identifiersecond" ]]; then eval "$lv=1" return 0 elif [[ -n "$__identifierfirst" ]] && [[ -z "$__identifiersecond" ]]; then eval "$lv=-1" return 0 fi eval "$lv=0" } # Returns the last version of two # $1 The first version to compare # $2 The second version to compare # $3 The variable where to store the last one function get_latest_of_two { local __first=$1 local __second=$2 local __result local __latest=$3 compare_versions $__first $__second __result case $__result in 0) eval "$__latest=$__second" ;; -1) eval "$__latest=$__second" ;; 1) eval "$__latest=$__first" ;; esac } # Assigns a 2 size array with the identifier, having the identifier at pos 0, and the number in pos 1 # $1 The identifier in the format -id.# # $2 The vferiable where to store the 2 size array function explode_identifier { local __identifier=$1 local __result=$2 if [[ $__identifier =~ $IDENTIFIER_REGEX ]] ; then local __id=${BASH_REMATCH[1]} local __number=${BASH_REMATCH[2]} if [[ -z "$__number" ]]; then __number=1 fi eval "$__result=(\"$__id\" \"$__number\")" else eval "$__result=" fi } # Gets a list of tags and assigns the base and latest versions # Receives an array with the tags containing the versions # Assigns to the global variables finalversion and lastversion the final version and the latest version function get_latest { local __taglist=("$@") local __tagsnumber=${#__taglist[@]} local __current case $__tagsnumber in 0) finalversion=$FIRST_VERSION lastversion=$FIRST_VERSION ;; 1) __current=${__taglist[0]} explode_version $__current ver if [ -n "$ver" ]; then if [ -n "${ver[3]}" ]; then finalversion=$FIRST_VERSION else finalversion=$__current fi lastversion=$__current else finalversion=$FIRST_VERSION lastversion=$FIRST_VERSION fi ;; *) local __lastpos=$(($__tagsnumber-1)) for i in $(seq 0 $__lastpos) do __current=${__taglist[i]} explode_version ${__taglist[i]} ver if [ -n "$ver" ]; then if [ -z "${ver[3]}" ]; then get_latest_of_two $finalversion $__current finalversion get_latest_of_two $lastversion $finalversion lastversion else get_latest_of_two $lastversion $__current lastversion fi fi done ;; esac if git rev-parse -q --verify "refs/tags/$lastversion" >/dev/null; then hasversiontag="true" else hasversiontag="false" fi } # Gets the next version given the provided scope # $1 The version that is going to be bumped # $2 The scope to bump # $3 The variable where to stoer the result function get_next_version { local __exploded local __fromversion=$1 local __scope=$2 local __result=$3 explode_version $__fromversion __exploded case $__scope in major) __exploded[0]=$((${__exploded[0]}+1)) __exploded[1]=0 __exploded[2]=0 ;; minor) __exploded[1]=$((${__exploded[1]}+1)) __exploded[2]=0 ;; patch) __exploded[2]=$((${__exploded[2]}+1)) ;; esac eval "$__result=v${__exploded[0]}.${__exploded[1]}.${__exploded[2]}" } function bump_version { ## First we try to get the next version based on the existing last one if [ "$scope" == "auto" ]; then get_scope_auto scope fi local __candidatefromlast=$FIRST_VERSION local __explodedlast explode_version $lastversion __explodedlast if [[ -n "${__explodedlast[3]}" ]]; then # Last version is not final local __idlast explode_identifier ${__explodedlast[3]} __idlast # We get the last, given the desired id based on the scope __candidatefromlast="v${__explodedlast[0]}.${__explodedlast[1]}.${__explodedlast[2]}" if [[ -n "$identifier" ]]; then local __nextid="$identifier.1" if [ "$identifier" == "${__idlast[0]}" ]; then # We target the same identifier as the last so we increase one __nextid="$identifier.$(( ${__idlast[1]}+1 ))" __candidatefromlast="$__candidatefromlast-$__nextid" else # Different identifiers, we make sure we are assigning a higher identifier, if not, we increase the version __candidatefromlast="$__candidatefromlast-$__nextid" local __comparedwithlast compare_versions $__candidatefromlast $lastversion __comparedwithlast if [ "$__comparedwithlast" == -1 ]; then get_next_version $__candidatefromlast $scope __candidatefromlast __candidatefromlast="$__candidatefromlast-$__nextid" fi fi fi fi # Then we try to get the version based on the latest final one local __candidatefromfinal=$FIRST_VERSION get_next_version $finalversion $scope __candidatefromfinal if [[ -n "$identifier" ]]; then __candidatefromfinal="$__candidatefromfinal-$identifier.1" fi # Finally we compare both candidates local __resultversion local __result compare_versions $__candidatefromlast $__candidatefromfinal __result case $__result in 0) __resultversion=$__candidatefromlast ;; -1) __resultversion="$__candidatefromfinal" ;; 1) __resultversion=$__candidatefromlast ;; esac eval "$1=$__resultversion" } function increase_version { local __version= if [ -z $forcedversion ]; then bump_version __version else if [[ $forcedversion =~ $SEMVER_REGEX ]] ; then compare_versions $forcedversion $lastversion __result if [ $__result -le 0 ]; then echo "Version can't be lower than last version: $lastversion" exit 1 fi else echo "Non valid version to bump" exit 1 fi __version=$forcedversion fi if [ "$displayonly" == "true" ]; then echo "$__version" else if [ "$forcetag" == "false" ]; then check_git_dirty_status fi local __commitlist if [ "$finalversion" == "$FIRST_VERSION" ] || [ "$hasversiontag" != "true" ]; then __commitlist="$(git log --pretty=oneline | cat)" else __commitlist="$(git log --pretty=oneline $finalversion... | cat)" fi # If we are forcing a bump, we add bump to the commit list if [[ -z $__commitlist && "$forcetag" == "true" ]]; then __commitlist="bump" fi if [[ -z $__commitlist ]]; then echo "No commits since the last final version, not bumping version" else if [[ -z $versionname ]]; then versionname=$(date -u +"%Y-%m-%dT%H:%M:%SZ") fi local __message="$versionname $__commitlist" # We check we have info on the user local __username=$(git config user.name) if [ -z "$__username" ]; then __username=$(id -u -n) git config user.name $__username fi local __useremail=$(git config user.email) if [ -z "$__useremail" ]; then __useremail=$(hostname) git config user.email "$__username@$__useremail" fi git tag -a $__version -m "$__message" # If we have a remote, we push there local __remote=${SEMTAG_REMOTE:-$(git remote)} if [[ -n $__remote ]]; then git push $__remote $__version > /dev/null if [ $? -eq 0 ]; then echo "$__version" else echo "Error pushing the tag $__version to $__remote" exit 1 fi else echo "$__version" fi fi fi } function check_git_dirty_status { local __repostatus= get_work_tree_status __repostatus if [ "$__repostatus" == "uncommitted" ]; then echo "ERROR: You have uncommitted changes" git status --porcelain exit 1 fi if [ "$__repostatus" == "unstaged" ]; then echo "ERROR: You have unstaged changes" git status --porcelain exit 1 fi } # Get the total amount of lines of code in the repo function get_total_lines { local __empty_id="$(git hash-object -t tree /dev/null)" local __changes="$(git diff --numstat $__empty_id | cat)" local __added_deleted=$1 get_changed_lines "$__changes" $__added_deleted } # Get the total amount of lines of code since the provided tag function get_sincetag_lines { local __sincetag=$1 local __changes="$(git diff --numstat $__sincetag | cat)" local __added_deleted=$2 get_changed_lines "$__changes" $__added_deleted } function get_changed_lines { local __changes_numstat=$1 local __result=$2 IFS=$'\n' read -rd '' -a __changes_array <<<"$__changes_numstat" local __diff_regex="^([0-9]+)[[:space:]]+([0-9]+)[[:space:]]+.+$" local __total_added=0 local __total_deleted=0 for i in "${__changes_array[@]}" do if [[ $i =~ $__diff_regex ]] ; then local __added=${BASH_REMATCH[1]} local __deleted=${BASH_REMATCH[2]} __total_added=$(( $__total_added+$__added )) __total_deleted=$(( $__total_deleted+$__deleted )) fi done eval "$2=( $__total_added $__total_deleted )" } function get_scope_auto { local __verbose=$2 local __total=0 local __since=0 local __scope= get_total_lines __total get_sincetag_lines $finalversion __since local __percentage=0 if [ "$__total" != "0" ]; then local __percentage=$(( 100*$__since/$__total )) if [ $__percentage -gt "10" ]; then __scope="minor" else __scope="patch" fi fi eval "$1=$__scope" if [[ -n "$__verbose" ]]; then echo "[Auto Scope] Percentage of lines changed: $__percentage" echo "[Auto Scope] : $__scope" fi } function get_work_tree_status { # Update the index git update-index -q --ignore-submodules --refresh > /dev/null eval "$1=" if ! git diff-files --quiet --ignore-submodules -- > /dev/null then eval "$1=unstaged" fi if ! git diff-index --cached --quiet HEAD --ignore-submodules -- > /dev/null then eval "$1=uncommitted" fi } function get_current { if [ "$hasversiontag" == "true" ]; then local __commitcount="$(git rev-list $lastversion.. --count)" else local __commitcount="$(git rev-list --count HEAD)" fi local __status= get_work_tree_status __status if [ "$__commitcount" == "0" ] && [ -z "$__status" ]; then eval "$1=$lastversion" else local __buildinfo="$(git rev-parse --short HEAD)" local __currentbranch="$(git rev-parse --abbrev-ref HEAD)" if [ "$__currentbranch" != "master" ]; then __buildinfo="$__currentbranch.$__buildinfo" fi local __suffix= if [ "$__commitcount" != "0" ]; then if [ -n "$__suffix" ]; then __suffix="$__suffix." fi __suffix="$__suffix$__commitcount" fi if [ -n "$__status" ]; then if [ -n "$__suffix" ]; then __suffix="$__suffix." fi __suffix="$__suffix$__status" fi __suffix="$__suffix+$__buildinfo" if [ "$lastversion" == "$finalversion" ]; then scope="patch" identifier= local __bumped= bump_version __bumped eval "$1=$__bumped-dev.$__suffix" else eval "$1=$lastversion.$__suffix" fi fi } function init { git fetch > /dev/null TAGS="$(git tag)" IFS=$'\n' read -rd '' -a TAG_ARRAY <<<"$TAGS" get_latest ${TAG_ARRAY[@]} currentbranch="$(git rev-parse --abbrev-ref HEAD)" } case $ACTION in --help) echo -e "$HELP" ;; --version) echo -e "${PROG}: $PROG_VERSION" ;; final) init diff=$(git diff master | cat) if [ "$forcetag" == "false" ]; then if [ -n "$diff" ]; then echo "ERROR: Branch must be updated with master for final versions" exit 1 fi fi increase_version ;; alpha|beta) init identifier="$ACTION" increase_version ;; candidate) init identifier="rc" increase_version ;; getlast) init echo "$lastversion" ;; getfinal) init echo "$finalversion" ;; getcurrent) init get_current current echo "$current" ;; get) init echo "Current final version: $finalversion" echo "Last tagged version: $lastversion" ;; *) echo "'$ACTION' is not a valid command, see --help for available commands." ;; esac