Automate Software Versioning using CI/CD

In software engineering, software versioning is the practice of assigning unique version numbers or names to different releases of a software application. Each version number represents a specific set of changes or updates to the software.

Software versioning is important because it ensures applications are reliable, consistent and well-maintained over time. It allows developers to:

  • Keep track of changes: Assigning version numbers to different releases allows developers to easily track changes to the software over time.
  • Communicate effectively: Versioning provides a simple and clear way to communicate which version of the software is being used. This is especially important in collaborative environments where multiple developers are working on the same codebase.
  • Manage releases of software updates and new features
  • Troubleshoot issues: Access a historical record of changes to the software, which can be useful for tracking bugs and issues, identifying the cause of errors, and verifying compliance with regulations and standards.

In this blog, we will learn how to automatically create versions using Buildkite pipeline and GitVersion.

Software versioning using GitVersion and Buildkite

In simple layman language, software versioning involves tagging your code at a particular point in time. It is as simple as tagging your git commit.

A tag in the git world is just an alias for a commit SHA. If it was easier to refer and read a commit SHA, then it could be used for versioning. However, since it is a string of random characters, there is a need to create human readable tags.

Commit SHA — ecfb1a78d7ce3fe5c0080fa305132edd4e84e6d3

Git tag → v1.0.0+ecfb1a7

Different Versioning Schemes

In the scope of versioning an application, there are a couple of different schemes that can be chosen. The choice depends on what the chosen continuous integration software can support.

The most simple versioning scheme could be versioning your system as v1, v2, and so on. But this might not provide any meaningful information to the end user.

Common versioning schemes that teams choose are:

  1. Build numbers: Using an incremental number that is defined by the run of an automatic build pipeline
  2. Date and time: Using the timestamp of a build as a unique timestamp to define a version
  3. Semantic version (SemVer): Creating defined major.minor.patch+<additional_metadata> schema versions

Semantic versioning (SemVer) offers a solution that allows version numbers to be more descriptive. A semantic version number follows the structure MAJOR.MINOR.PATCH.

The different sections are numbers that we increment as:

  • MAJOR – When we introduce incompatible/API breaking changes
  • MINOR – When we add functionality in a backwards compatible manner
  • PATCH – When we make backwards compatible bug fixes

The main disadvantage with the first two schemes is that they aren’t descriptive. When comparing multiple versions that follow an incremented version, it’s hard for a user to understand if non-breaking changes have been introduced in a new version.

The semantic version strategy is the industry standard to version applications.

How to achieve automated versioning using GitVersion

GitVersion is a Command Line Interface (CLI) used to generate these version numbers. GitVersion works well with existing Git branching strategies like Mainline, GitFlow or GitHub Flow. 

Although using a standardised branching strategy is recommended, GitVersion’s flexible configuration allows the tool to be set up according to desired needs.

Versioning Strategy

  • Follow the continuous delivery approach
  • Assumes main branch is protected and can only be committed via a PR process
  • Every PR merge commit to main will increment the minor version
  • Any hotfix branch will increment the patch version
  • Any commit message containing the text ‘major:’ will increment the major version.
  • Create initial tag 1.0.0 manually, otherwise gitversion default version will start from 0.1.0

This strategy will ensure every branch has a unique version and can be published independently.

User Workflow

GitVersion Configuration

Ensure you have a git repository cloned locally, or can use repo.

Checkout a branch using git checkout -b feature/add-versioning

  • Create GitVersion.yml in the root of your project
 

mode: ContinuousDelivery
branches:
main:
# Matches only main branch
  regex: ^main$ | ^master$
  increment: Minor
hotfix:
# Matches branch names starts with hotfix/
# Sample matches –
# – hotfix/fix-bug
# – hotfix/JIRA-1234
  regex: ^hotfix/.*
  increment: patch
  tag: ‘{BranchName}’
pull-request:
# Matches branch names starts with pull-requests/ or pr/ or pull/ or pull-requests- or pr- or pull-
# Sample matches –
# – pull-requests/JIRA-1234
# – pull-requests-JIRA-1234
# – pull/JIRA-1234
# – pull-JIRA-1234
# – pr/JIRA-1234
# – pr-JIRA-1234
  regex: ^(pull|pull\-requests|pr)[/-]
  increment: none
  tag: ‘{BranchName}’
feature:
# Matches any branch that is not main or master or pull requests or hotfix
# Sample matches –
# – feature/JIRA-1234
# – bugfix/JIRA-1234
# – JIRA-1234
  regex: ^(?!.*master|main|(pull|pull\-requests|pr)[/-]|hotfix(es)?[/-]|^\d+(_\d)*).*
  increment: none
  tag: ‘{BranchName}’

  • Create script files to be executed in Buildkite
    • scripts/calculate-version.ps1
    • scripts/push-git-tag.ps1
 

# file scripts/calculate-version.ps1

$ErrorActionPreference = “Stop”
Set-PSDebug -Strict
Import-Module -Name $(Join-Path $PSScriptRoot “modules” “buildkite.psm1”)
Import-Module -Name $(Join-Path $PSScriptRoot “modules” “version.psm1”)

Write-Host “`n— Running GitVersion`n”

$gitVersionMetadata = Get-GitVersionMetaData
if (!$gitVersionMetadata) {
    Exit 1
}
Write-Host “`nGitVersion successful.”
Write-Host “`n— GitVersion Metadata”
Write-Output $gitVersionMetadata

Write-Host “`n— Calculated Versions”
$calulatedVersions = Get-CalculatedVersions $gitVersionMetadata
Write-Output $calulatedVersions | ConvertTo-Json | ConvertFrom-Json

if ($env:BUILDKITE) {
    Write-Host “`n— Setting Buildkite Metadata”
    $calulatedVersions.GetEnumerator() | ForEach-Object {
        Set-BuildkiteMetadata -Name $_.Key -Value $_.Value
    }
    $annotation = “Building Version: <span class=’bold’>$($calulatedVersions.version)</span>”
    buildkite-agent annotate $annotation –style “info” –context “version-info”
}

# file: scripts/push-git-tag.ps1

$ErrorActionPreference = “Stop”
Set-PSDebug -Strict
Import-Module -Name $(Join-Path $PSScriptRoot “modules” “version.psm1”)

Write-Output “`n— Create Git Tag if on Main or Hotfix branch —“

if (!$env:BUILDKITE) {
    Write-Output “`n Create tag in Buildkite only”
    Exit 1
}
$branchName = $env:BUILDKITE_BRANCH
if ((Get-IsMainBranch -BranchName $branchName) –or (Get-IsHotfixBranch -BranchName $branchName )) {
   
    Write-Output “`n— Get Current Version and Create Git Tag”
    $currentVersion = Get-Version

    $message = “Buildkite build: $env:BUILDKITE_BUILD_URL”
    Write-Output “`n— Current Version to create git tag –> $currentVersion”

    git tag “v${currentVersion}” -m “${message}”
    git push –tags
}

Create module files 

  • scripts/modules/git.psm1 
  • scripts/modules/version.psm1
  • scripts/modules/buildkite.psm1
 

# file : scripts/modules/git.psm1

function Get-IsMainBranch {
    param(
        [Parameter(Mandatory = $true)]
        [string]$BranchName
    )

    return (“master”, “main” -contains $BranchName)
}

function Get-IsReleaseBranch {
    param(
        [Parameter(Mandatory = $true)]
        [string]$BranchName
    )

    return ($BranchName -match ‘^(releases?)[/-]\d+(\.?\d*)*$|^\d+_\d$’)
}

function Get-IsHotfixBranch {
    param(
        [Parameter(Mandatory = $true)]
        [string]$BranchName
    )

    return ($BranchName -match ‘^(hotfix(es)?)[/-]\d+(\.?\d*)*$|^\d+(_\d)*.*_BRANCH$’)
}

Version.psm1

# file: scripts/modules/version.psm1

Import-Module -Name $(Join-Path $PSScriptRoot “buildkite.psm1”)
Import-Module -Name $(Join-Path $PSScriptRoot “git.psm1”)

function Get-Version {
    return Get-VersionCore “Version”
}

function Get-VersionCore {
    param (
        [Parameter(Mandatory = $true)]
        [string]$Key
    )

    if ($env:BUILDKITE) {
        return Get-BuildkiteMetadata $Key
    }

    # script is running locally
    $metaData = Get-GitVersionMetaData
    if (!$metaData) {
        return $null
    }
   
    return (Get-CalculatedVersions $metaData).$Key
}

function Get-CalculatedVersions {
    param (
        # A custom object storing git version info
        [Parameter(Mandatory = $true)]
        [object] $GitVersionMetaData
    )
   
    $branchName = $env:BUILDKITE_BRANCH ?? $gitVersionMetadata.BranchName
   
    $version = $GitVersionMetaData.MajorMinorPatch
    $build_number = $env:BUILDKITE_BUILD_NUMBER ?? 1
    if ( !(Get-IsMainBranch -BranchName $branchName) –and !(Get-IsHotfixBranch -BranchName $branchName)) {
        $escapedBranchName = $branchName -replace “[^a-zA-Z0-9-]”, “-“
        $version = “$($version).ci-$build_number-$escapedBranchName.$($GitVersionMetaData.ShortSha)”
    }
   
    return @{
        Version = $version
    }
}

function Get-GitVersionMetaData {
    param (
        # gitversion docker image tag
        [String] $GitVersionImageTag = “5.10.3”
    )

    $repoPath = (Get-Item $PSScriptRoot).Parent.Parent.Parent.Parent
    # calculate-version.env file is created under the tmp folder.
    $tmpPath = $(Join-Path $repoPath “tmp”)
    $envFile = $(Join-Path $tmpPath “.env”)

    try {
        # Created the tmp folder
        New-Item -ItemType Directory -Path $tmpPath -Force | Out-Null

        Write-Host “Setting Docker build environment variables in file $envFile.”
        Set-Content -Path $envFile -Value “”
        Add-Content -Path $envFile -Value (“BUILDKITE”)
        Add-Content -Path $envFile -Value (“BUILDKITE_BRANCH”)
        Add-Content -Path $envFile -Value (“BUILDKITE_PULL_REQUEST”)
       
        Write-Host “docker run –rm -v “$($repoPath):/repo” –env-file $envFile gittools/gitversion:$GitVersionImageTag /repo”
               
        $responseJson = $(docker run –rm -v “$($repoPath):/repo” –env-file $envFile gittools/gitversion:$GitVersionImageTag /repo)
       
        $returnCode = $LASTEXITCODE
        if ($returnCode -ne 0) {
            Write-Host “`nGitVersion failed – docker returned non-zero return code of $returnCode.”
            if ($responseJson) {
                Write-Error “`nGitVersion response ‘$responseJson'”
            }
            return $null
        }
   
        return ($responseJson | ConvertFrom-Json)
    }
    catch {
        Write-Error “`n$_.Exception.Message”
        return $null
    }
    finally {
        if (Test-Path -Path $tmpPath) {
            Remove-Item -Path $tmpPath -Force -Recurse
        }
    }
} 

# file: scripts/modules/buildkite.psm1

function Set-BuildkiteMetadata {

    param(
        [Parameter(Mandatory = $true)]
        [string]$Name,

        [Parameter(Mandatory = $true)]
        [string] $Value
    )

    if ($env:BUILDKITE) {
        Write-Host “Setting $($Name)=$($Value)”
        $response = buildkite-agent meta-data set $Name $Value
        $returnCode = $LASTEXITCODE
        if ($returnCode -ne 0) {
            Write-Error “Failed to set Buildkite meta-data for ‘$($Name)=$($Value)’ with return code $($returnCode) and reponse ‘$($response)'”
        }
    }
}
function Get-BuildkiteMetadata {

    param(
        [Parameter(Mandatory = $true)]
        [string]$Name
    )

    if ($env:BUILDKITE) {
        return buildkite-agent meta-data get $Name
    }
    else {
        return $null
    }
}

 

This is how the folder structure would look:

Lastly, let’s create a Buildkite pipeline to create git tags on every commit to the main branch.

Add a new file .buildkite/pipeline.yml

steps:
  – wait: ~
    depends_on: ~
 
  – group: “:abacus: Calculate Version”
    key: “calculate-version”   
    steps:
      – label: ‘:abacus: Calculate Version using GitVersion’
        key: “calculate-version-gitversion”
        depends_on: ~
        command: ‘pwsh scripts/calculate-version.ps1’
        agents:
          queue: ‘build.linux’
        env:
          BUILDKITE_CLEAN_CHECKOUT: true
          EXTERNAL_CREDENTIALS: docker

  – wait: ~
    depends_on: ~

  – group: “:git: :pushpin: Create Git Tag”
    steps:
      – label: ‘:git: :pushpin: Push git Tag’
        key: “push-git-tag”
        depends_on: calculate-version
        branches: “main hotfix/*”
        command: ‘pwsh scripts/push-git-tag.ps1’
        agents:
          queue: ‘build.linux’
        env:
          BUILDKITE_CLEAN_CHECKOUT: true

Execute command `pwsh scripts/calculate-version.ps1` locally to return below output.

Here is your first calculated version — 0.1.0.ci-1-feature-add-versioning.aa41245

Conclusion

Now, you have a way to automate the versioning process as part of your pipeline, and GitVersion will take care of creating the next git tag for your application. 

Key points:

  • You can keep track of every transition in the software development phase.
  • The first version starts at 0.1.0 and not at 0.0.1, as no bug fixes have taken place. Rather, we start with a set of features as the first draft of the project.
  • Versioning can do the job of explaining to the developers about what type of changes have taken place and the possible updates in the software.
  • It helps to keep things clean and meaningful.
  • It helps other people who might be using your project as a dependency.

Enjoyed this blog?

Share it with your network!

Move faster with confidence