Devs.site

Automatic SemVer versioning with Git hooks

There was a time when I couldn't understood completely how software versions are created (though it's up to each developer or company in the end). Then I found out Semver.

Semver requires you to declare a public API and it mostly makes sense if your software is meant to be delivered as a dependency for other applications or modules.

It is especially used in Composer, NPM or other package management systems, where it is very important to keep track of dependencies and ensure backward compatibility.

Despite of this primary use case, I don't see why you could not use it for a stand-alone project, too. You change the version based on whether new changes of some portions of the code generate compatibility issues with other internal components.

Gitflow is a workflow design and an extension for GIT that proposes a branching model with clear roles and the possibility to isolate experimental features from the rest of the code.

Very recommended if you have your source inside a repository and you follow a development cycle focused around scheduled releases and milestones. Read more about it here and get the extension from here.

If you are on Windows you just need to have GIT installed because Gitflow comes with it.


When I decided to use proper semantic versioning in my projects, I stumbled upon SemVer. I was already using Gitflow to separate branches depending on the stage of the work. So, I thought: why not have the versions being auto-generated as tags when certain operations are carried on?

What I had in mind was combining Gitflow with some additional GIT hooks and have a script that does the job of bumping the version according to the current Gitflow branch and the GIT operation. A tag is automatically assigned to each version (releases get annotated tags)

It might need some readjustments depending on each one's needs but it follows a simple set of rules:

  1. We have 4 branches that can be used: develop, feature, release candidate (with the name being the version) and release. These are established when git flow init is executed
  2. The version of develop and release candidates takes the form of Major.Minor.Patch-stage.build. Releases look like Major.Minor.Patch
  3. When a project is started, the developer picks the next release version values (starts with 1.0.0) and optionally the name of develop and rc (release candidate) stages (for example dev and rc), which will be appended when working on those branches
  4. Every time a new commit is created in develop and release candidate or a new feature is finished, the build number increases
  5. When a new release candidate is started, the developer can change the version, but only to increase the major or minor numbers
  6. After a release candidate has been finished, the developer can specify the next version for the new cycle
  7. Hotfixes increase the patch number and the build number
  8. Support and bugfix branches are ignored by the script (you can still use them, of course)

Code

As all the code you will find here, this script is rich in comments so you can easily follow my logic and change it to your own liking

#!/bin/sh
#
# This script will automatically bump the semver version and generate a tag for it
#
# It takes $1 as a parameter that specifies the operation that just took place
# before it has been called. It can be 'commit' or 'merge'
#

#####################################################################################
# Settings, configuration, default values
#####################################################################################

# default version values
declare -A version
version[major]=1
version[minor]=0
version[patch]=0
version[devstage]=
version[rcstage]=

# gitflow branches
declare -A branch
branch[master]=master
branch[develop]=develop

# gitflow prefixes
declare -A prefix
prefix[release]=release/
prefix[hotfix]=hotfix/
prefix[feature]=feature/
prefix[versiontag]=

# normal version number pattern
version_pattern="([0-9]*)\.([0-9]*)\.([0-9]*)"
# pre-release version notation pattern
pre_release_pattern="([a-zA-Z]*)?\.?([0-9]*)"
# full semver version pattern
semver_pattern="^$version_pattern-$pre_release_pattern$"

#####################################################################################
# Helper functions
#####################################################################################

#
# Get git flow configuration (branches and prefixes)
#
get_gitflow_config()
{
    # get each branch
    for key in "${!branch[@]}" ;
    do
        branch[$key]="$(git config --get gitflow.branch.$key)"
    done

    # get each prefix
    for key in "${!prefix[@]}" ;
    do
        prefix[$key]="$(git config --get gitflow.prefix.$key)"
    done
}

#
# Get configuration from settings file
#
get_version_config()
{
    # save each key.value in version section
    for key in "${!version[@]}" ;
    do
        local setting="$(git config --get version.$key)"
        if [[ -z $setting ]] && [[ ! -z ${version[$key]} ]] ; then
            # save default value if no setting found
            git config --add version.$key ${version[$key]}
        else
            version[$key]=$setting
        fi
    done
}

#
# Saves or update the required data into the configuration file
#
save_version_config()
{
    # save each key.value in version section
    for key in "${!version[@]}" ;
    do
        local setting="$(git config --get version.$key)"
        if [[ -z $setting ]] ; then
            git config --add version.$key ${version[$key]}
        else
            git config --replace version.$key ${version[$key]}
        fi
    done
}

#
# Ask user for next version major, minor and pre-release and validate it
#
ask_user_for_version()
{
    # assign stdin to keyboard
    exec < /dev/tty
    local umajor=0
    local uminor=0
    local udevstage=${version[devstage]}
    local urcstage=${version[rcstage]}
    read -p "Next major version number [${version[major]}]: " umajor
    # assign default if none specified
    umajor=${umajor:-${version[major]}}
    # major must be greater or equal than the current one
    if [[ $umajor -lt ${version[major]} ]] ; then
        echo "Major number must be greater than or equal to ${version[major]}"
        # try again
        ask_user_for_version
        return
    fi

    # ask for minor if major is the same
    if [[ $umajor -eq ${version[major]} ]] ; then
        read -p "Next minor version number [${version[minor]}]: " uminor
        # make sure the precedence it's correct
        if [[ $umajor -ne 1 ]] && [[ $uminore -eq 0 ]] ; then
            if [[ $uminor -le ${version[minor]} ]] ; then
                echo "Minor number must be greater than ${version[minor]}"
                # try again
                ask_user_for_version
                return
            fi
        fi
        # assign default if none specified
        uminor=${uminor:-${version[minor]}}

        # save both
        version[major]=$umajor
        version[minor]=$uminor
    fi

    if [[ -z ${version[devstage]} ]] ; then
        read -p "Next pre-release identifier (dev): " udevstage
        # assign default if none specified
        udevstage=${udevstage:-alpha}

        # identifier cannot start with zero
        if [[ $udevstage = 0* ]] ; then
            echo "Pre-release identifier cannot start with a zero"
            # try again
            ask_user_for_version
            return
        fi
        version[devstage]=$udevstage
    fi

    if [[ -z ${version[rcstage]} ]] ; then
        read -p "Next release candidate identifier (rc): " urcstage
        # assign default if none specified
        urcstage=${urcstage:-rc}

        # identifier cannot start with zero
        if [[ $urcstage = 0* ]] ; then
            echo "Release candidate identifier cannot start with a zero"
            # try again
            ask_user_for_version
            return
        fi
        version[rcstage]=$urcstage
    fi


    version[patch]=0

    # close stdin
    exec <&-
}

#
# Returns the last version tag based on specified stage and current major/minor
#
# $1: stage (optional) 
#
get_version_tag()
{
    # default tag name to look for
    local tagname="--merged develop"

    # if no stage specified, just get the last full version
    if [[ -z $1 ]] ; then
        tagname="$tagname [0-9+].[0-9+].[0-9+]"
    else
        # if stage specified, look for the stage, too
        tagname="$tagname ${version[major]}.${version[minor]}.[0-9+]-$1.[0-9+]"
    fi

    # find it
    tag=$(git tag --sort=-committerdate $tagname | head -n 1 2> /dev/null)
    echo $tag
}

#
# Create a tag for this commit based on the current version data
#
# $1: stage
# $2: build number
#
create_version_tag()
{
    # get stage from parameter
    local stage=$1
    # get build from paramter
    local build=$2

    # generate new version
    tagname="${version[major]}.${version[minor]}.${version[patch]}"

    # add pre-release stage identifier
    if [[ ! -z $stage ]] ; then
        tagname="$tagname-$stage"
        # add build number for pre-releases or release candidates
        if [[ -z $build ]] ; then
            tagname="$tagname.1"
            build=1
        else
            tagname="$tagname.$build"
        fi
    fi

    # and create tag
    git tag ${prefix[versiontag]}$tagname

    # output created version
    echo "Tag $tagname created"
}

#####################################################################################
# Data collection and checkings
#####################################################################################

# operation
operation=$1

# data
data=$2

# get git flow assigned branches and prefixes from configuration
get_gitflow_config

# get all configuration values (set to default if not found)
get_version_config

# if gitflow branch not found, show error
if [[ -z ${branch[master]} ]] || [[ -z ${branch[develop]} ]] ; then
    echo "Looks like `git flow` was not initialized. No branches defined"
    exit 1
fi

# get current branch name
ref="$(git symbolic-ref --short HEAD)"

# get last tag (by date) name
tag=$(git tag --sort=-committerdate | head -n 1 2> /dev/null)

#####################################################################################
# Version generation and tag creation
#####################################################################################

# validate and get version components from the last tag or start new version
if [[ ! $tag =~ $semver_pattern ]] ; then
    # if branch is develop
    if [[ $ref == ${branch[develop]} ]] ; then
        # ask for the next version (or first) values
        ask_user_for_version
        # create the initial pre-release tag (build 1)
        create_version_tag ${version[devstage]} 1
        # update version configuration
        save_version_config
    else
        echo "No valid version tag. Commit something in ${branch[develop]}"
    fi
    exit 0
fi

# commits and merges
if [[ $operation == "commit" ]] || [[ $operation == "merge" ]] ; then
    # commits in master or hotfix are ignored
    prefexp="((${prefix[hotfix]}|${prefix[feature]})(.*))?"
    if [[ $ref =~ ^(${branch[master]}|$prefexp)$ ]] ; then
        exit 0
    fi

    # set stage based on branch
    if [[ $ref == ${branch[develop]} ]] ; then
        stage=${version[devstage]}
    elif [[ $ref =~ ^${prefix[release]}$version_pattern$ ]] ; then
        stage=${version[rcstage]}
    fi

    # get last tag belonging to this stage
    lasttag="$(get_version_tag $stage)"

    # validate and get build number
    if [[ $lasttag =~ $semver_pattern ]] ; then
        # increase build count
        build=$((BASH_REMATCH[5] + 1))
    fi

    # create the initial/next tag
    create_version_tag $stage $build

    # update version configuration
    save_version_config
fi

# when starting a new release, we make some checks
if [[ $operation == "pre-release-start" ]] ; then
    # check if version major, minor and patch are the same as on pre-release
    if [[ $data =~ ^$version_pattern$ ]] ; then
        major=${BASH_REMATCH[1]}
        minor=${BASH_REMATCH[2]}
        patch=${BASH_REMATCH[3]}

        # patch must be zero
        if [[ patch -ne 0 ]] ; then
            echo "Version patch must be 0"
            exit 1
        fi

        # if version is changed here, rename pre-release tags, too
        if [[ "$major.$minor" != "${version[major]}.${version[minor]}" ]] ; then
            # get last pre-release tag
            lasttag="$(get_version_tag ${version[devstage]})"

            # validate and get build number
            if [[ $lasttag =~ $semver_pattern ]] ; then
                # for each buil of the pre-release version
                for (( i=1; i<=${BASH_REMATCH[5]}; i++ ))
                do
                    # rename the tag to match the new version
                    newtag=$major.$minor.${version[patch]}-${version[devstage]}.$i
                    oldtag=${version[major]}.${version[minor]}.${version[patch]}
                    oldtag=$oldtag-${version[devstage]}.$i

                    git tag $newtag $oldtag 2> /dev/null
                    git tag -d $oldtag 2> /dev/null
                done

                # check if there are any remote repository added
                remote="$(git remote)"
                # if there are, try to update the remote respository's tags
                if [[ ! -z $remote ]] ; then
                    git push origin :refs/tags/$oldtag
                    git push --tags
                fi
            fi

            # update version
            version[major]=$major
            version[minor]=$minor
        fi

        # reset build and patch
        version[patch]=0
        # update version configuration
        save_version_config
    else
        echo "Specified version is not Semver compatible"
        exit 1
    fi
fi

# after starting a new release, we create the first rc tag
if [[ $operation == "post-release-start" ]] ; then
    # create the rc tag corresponding to the last pre-release
    create_version_tag ${version[rcstage]} 1
fi

# when finishing a release
if [[ $operation == "pre-release-finish" ]] ; then
    # ask for the next version (or first) values
    ask_user_for_version
    # update version configuration
    save_version_config
fi

# when starting a hotfix, we validate the version
if [[ $operation == "pre-hotfix-start" ]] ; then
    # check if version is valid and patch is correct
    if [[ $data =~ ^$version_pattern$ ]] ; then
        major=${BASH_REMATCH[1]}
        minor=${BASH_REMATCH[2]}
        patch=${BASH_REMATCH[3]}

        # get last full version
        lasttag="$(get_version_tag)"

        # validate and get components
        if [[ $lasttag =~ ^$version_pattern$ ]] ; then
            if [[ $major -ne ${BASH_REMATCH[1]} ]] ; then
                echo "Major version must be ${BASH_REMATCH[1]}"
                exit 1
            fi

            if [[ $minor -ne ${BASH_REMATCH[2]} ]] ; then
                echo "Minor version must be ${BASH_REMATCH[2]}"
                exit 1
            fi

            if [[ $patch -le ${BASH_REMATCH[3]} ]] ; then
                echo "Patch number must be greater than ${BASH_REMATCH[3]}"
                exit 1
            fi

            version[patch]=$patch

            # update version configuration
            save_version_config
        fi
    else
        echo "Specified version is not Semver compatible"
        exit 1
    fi
fi

exit 0

Links


Requirements

You only need to have GIT installed. On Windows it comes with Gitflow. On other systems you might have to install Gitflow as well.

Download the source (as a ZIP preferably) from the GIT repository in the Links section


Instructions

Here are the main operations that you might perform in a Gitflow workflow and this is how the versions are generated in each case

Initiate gitflow

Once you have created a new repository, run git flow init. You will be asked to specify the names for the branches

Create the main script

The main script is written in Bash. It will be called by the hooks with one or more arguments. Place it in a folder relative to the repository and keep the path in mind.

Create the hooks

Do not commit anything until you have done this. Inside your repository, go to .git/hooks (in Windows you will need to enable the viewing of hidden files). Here you need to add six hooks. Inside each hook we add the path to the main script (relative to the repository root) and one or more arguments depending on the hook.

Let us say the path of the main script is ./tools/hooks/version.sh. These are the six hooks and their content.

post-commit
./tools/hooks/version commit
post-flow-release-start
./tools/hooks/version post-release-start
post-merge
./tools/hooks/version merge
pre-flow-hotfix-start
./tools/hooks/version pre-hotfix-start $1
pre-flow-release-finish
./tools/hooks/version pre-release-finish
pre-flow-release-start
./tools/hooks/version pre-release-start $

The name of each hooks is pretty descriptive. You will have them in the GIT repository that can be found in Links

Commit some changes in develop

In this example the development branch has the dev stage assigned to it and release candidate branches will create versions with rc as stage.

After you make some initial changes you may commit. Because it is the first commit, you will be asked for the name of the stages corresponding to the development and release candidate branches. You will also have to specify the next release version (default is 1.0.0). The commit will be automatically tagged as 1.0.0-dev.1.

Start and finish a feature

If you start a feature with git flow feature start Feature, commit something then finish the feature with git flow feature finnish Feature, the build number will be increased. You will now be at version 1.0.0-dev.2.

Start a release candidate

Let us say that you want to move on and start a release candidate with git flow release start 1.0.0. The script will do some checking and create the version for this new stage.

Therefore, version 1.0.0-dev.2 in develop will become 1.0.0-rc.1. If you were to merge back into develop, you will have both a 1.0.0-rc.1 and a 1.0.0-dev.3

The build number is also increased if you commit some quick changes inside the release candidate branch. The version will become 1.0.0-rc.2.

Release a new version

A new production version is released with git flow release finish. An annotated tag is created now: 1.0.0. Before returning to the develop branch, you will be asked for the next version you have in mind. You can choose 2.0.0 or 1.2.0, depending on your future plans.

You will get another chance to modify the next version when starting the next release candidate, so do not worry if you don't have it very clear. In such a case, the pre-release versions will also be re-adjusted.

If after a release you have decided the next version is 1.2.0, but then, after realizing that your changes are no longer backwards compatible, you think it would be better for the next version to be 2.0.0, you can rename the version with git flow release start 2.0.0. All previous 1.2.0-dev.x development branches will be renamed to 2.0.0-dev.x. This will ensure that versions precedence is correct.

Apply a hotfix

When you start a hotfix for a release, the script makes sure you correctly choose the patch number. It also creates the tag with the patch number updated

0 comments

Specify your e-mail if you want to receive notifications about new comments and replies