Automating SemVer Versioning

PR Code - Christopher Pateman
Version 1
Published in
12 min readOct 13, 2023

--

Photo by Mikhail Fesenko on Unsplash

Application versioning is a key principle in the DevOps lifecycle, this is to ensure both users and software providers can easily tack any changes made to the application or environment, we therefore want to ensure that any manual process of updating versions is replaced with an automated process.

In this post, I will go through how to implement an automated method to increment and manage the version number in a Semantic Versioning (SemVer) standardisation automatically.

What is SemVer?

Semantic Versioning is a standard versioning schema to describe incremental changes with a product.

This follows a strict pattern that can be used to show what kind of changes and how impacting the changes are.

SemVer format

Major

As per above, the X stands for the Major, which is the indicator for a complete version change that changes the product.

Minor

Y is the Minor change, which is a feature increase, that is backwards compatible with previous changes.

Patch

Finally, you have Z as the Patch, which is small fixes and bugs.

Pre-Release

You also have the Pre-Release+Build that you can optionally choose to use. This is very handy when building a version from a feature branch, as you can utilise the existing version and suffix it with either the branch name or the build version. Example: 1.2.3-myNewFeature-35923

How to use SemVer

What we want to do is use the SemVer formatting and schema definition to identify what sprint version of the build is deployed with what features and/or hotfix version.

For the latter, we will use a simple branching strategy where all features are developed on branches feature/{ticket-name} and hotfixes are developed on hotfix/{ticket-name}. These will then use a Pull Request into the main branch, which in this case will be where we would release from.

Branching Pattern

This will then allow easy highlighting of the following SemVer format.

SemVer

Taking that into account we will have all bug fixes increment the Patch digit, and the new features increment the Minor. This will mean every change that is merged into the main branch will have a unique version.

We will then have the Major that will be incremented manually for each major release you put out for the product.

Implementing

We will be using PowerShell to read, increment and implement the SemVer number. This makes it something that can be adaptable for any sprint tool, branching strategy and deployment tool.

For this example, we are using Azure DevOps for the ticketing system and the Pipelines, which will be triggered on raising/changing to Pull Requests.

These have all been broken down into reusable functions so that you can also change the logic to fit your implementation. For example, I am going to assume a .Net Core Application with an appsettings.json file to store the version number, but your implementation might use something different like a Nuspec file.

Below are each function’s details and then the final logic, with the complete script to finish off.

Get the current version

First, we need to know what version we are currently running. We will use the az devops cli to get the JSON configuration file storing the version number from Azure DevOps. This will target the repository and the default branch as the source, then return the content of the file. As it is JSON content we can convert it to a PowerShell Object to get the version number out.

You will see at the bottom we use a function called Get-SemVer, which will take the version string and convert it to a SemVer Object. There is more detail about that function in the next section.

<#
.SYNOPSIS
Get Current SemVer Version from Source

.PARAMETER organisation
Azure DevOps Organisation Url

.PARAMETER project
Azure DevOps Project Name

.PARAMETER repository
Azure DevOps Repository Name

.PARAMETER sourcePath
Path to the file storing the version

.EXAMPLE
Get-CurrentVersion -organisation https://dev.azure.com/my-org -project "My Project" -repository "My-Repo" -sourcePath "projectsFiles/appsettings.json"
#>
Function Get-CurrentVersion {
param(
[Parameter(Mandatory)]
[string]$organisation,

[Parameter(Mandatory)]
[string]$project,

[Parameter(Mandatory)]
[string]$repository,

[Parameter(Mandatory)]
[string]$sourcePath
)

Write-Host "Get $sourcePath from $organisation$project/$repository"
$fileContent = (az devops invoke --http-method GET --org $organisation --area "git" --resource "items" --route-parameters project=$project repositoryId=$repository --query-parameters includeContent=true path=$sourcePath api-version="6.0" | ConvertFrom-Json).content | ConverFrom-Json

$version = $fileContent.projectInformation.version
Write-Host "Version $version Found."

$semVer = Get-SemVer -semVerStr $version

return $semVer
}

SemVer helper

In the above we get version 1.2.3 as a string, we later need to validate the numbers and update them, therefore we will use the function below to validate that the string is in a valid SemVer format and then return it as an object. This means we can request each SemVer component individually.

<#
.SYNOPSIS
Convert a string to a SemVer Object

.PARAMETER semVerStr
SemVer string

.EXAMPLE
Get-SemVer -semVerStr "1.2.3"
#>
Function Get-SemVer([string]$semVerStr) {

Write-Host "SemVer: $semVerStr"
$semVerArr = $semVerStr.Split(".")

if ($semVerArr.Count -ne 3) {
throw "FAILED: $semVerStr is not a valid SemVer value (1.2.3)."
}

for ($i = 0; $i -lt $semVerArr.Count; $i++) {
if (!($semVerArr[$i] -match "^[\d\.]+$")) {
throw "FAILED: $($semVerArr[$i]) is not an Integer."
}
}

return @{
Major = [int]$semVerArr[0]
Minor = [int]$semVerArr[1]
Patch = [int]$semVerArr[2]
}
}

Get the new version

For this function, we will break it down further, as this is the logic to determine how to increment each SemVer component. It will follow the branching pattern described above, which can be altered if it doesn’t meet your pattern.

We are assuming from the branching pattern that all features will be on feature/X and all hotfixes will be on hotfix/X. This means we can easily determine which component to increment. Having dedicated branching names forces that all changes need to follow the branching pattern as well, or it will error out.

If the source branch matches the feature branch pattern (feature/X), we first need to check the target. We only want to increment when the feature is going to be merged into the main branch. This prevents the number from incrementing when a feature branch is targeting another feature branch.

First is to check the Major version. If someone wants to implement a new release, they would go to the source and update the SemVar versions Major number. We would assume they would only increment it by 1, but instead of relying on human entry, instead we check if the Major has been incremented and if it has we use the target versions Major. With the targets Major number we can be sure, once we increment it, that it has only been incremented by 1. Once the Major is incremented, we will zero out the Minor and the Patch before we return it. If the Major has not changed then we increment the Minor number, zero out the Patch and leave the Major, for the new SemVer.

{ $_ -match 'feature\/' } {
if ($targetBranch -like "*main") {

if ([int]$targetVersion.Major -gt [int]$currentVersion.Major) {
$newMajor = $targetVersion.Major + 1
Write-Host "Major is increasing from $($targetVersion.Major) to $newMajor"
$newSemVer = "$newMajor.0.0"
break;
}

$newMinor = $targetVersion.Minor + 1
Write-Host "Feature target Main: Bump Minor from $($targetVersion.Minor) to $newMinor"
$newSemVer = "$($targetVersion.Major).$newMinor.0"
break;
}
}

If the source branch matches the hotfix branch pattern ( hotfix/X ), we can do all the same actions as previously, but this time we will increase the Patch, and then leave the Major and Minor as the current versions number.

{ $_ -match 'hotfix\/' } {
if ($targetBranch -like "*main") {
$newPatch = $currentVersion.Patch + 1
Write-Host "Hotfix target Main: Bump Patch from $($currentVersion.Patch) to $newPatch"
$newSemVer = "$($currentVersion.Major).$($currentVersion.Minor).$newPatch"
break;
}
}

This enforces the branching pattern, so we know we can correctly handle the version number incrementing.

Default {
throw "FAILED: Current Branch ($currentBranch) and/or Target Branch ($targetBranch) not found in logic."
}

As a result, we get this function.

<#
.SYNOPSIS
Use the inputs with logic to determine the new SemVer Version

.PARAMETER targetBranch
Target Branch Pull Request is merging into

.PARAMETER currentBranch
The current source branch that is merging in

.PARAMETER currentVersion
The current source version

.PARAMETER targetVersion
The current running version

.EXAMPLE
Get-NewVersion -targetBranch "origin/refs/main" -currentBranch "origin/refs/feature/my-feature" -currentVersion {Major:1,Minor:2,Patch:3} -targetVersion {Major:1,Minor:2,Patch:3}

.NOTES
Use the Get-SemVer function to set the currentVersion
#>
Function Get-NewVersion([string]$targetBranch, [string]$currentBranch, [object]$currentVersion, [object]$targetVersion) {

Write-Host "Target Branch - $targetBranch"
Write-Host "Target Version - $($targetVersion.Major + $targetVersion.Minor + $targetVersion.Patch)"
Write-Host "Current Branch - $currentBranch"
Write-Host "Current Version - $($currentVersion.Major + $currentVersion.Minor + $currentVersion.Patch)"

$newSemVer = ""
switch ($currentBranch) {
{ $_ -match 'feature\/' } {
if ($targetBranch -like "*main") {

if ([int]$targetVersion.Major -gt [int]$currentVersion.Major) {
$newMajor = $targetVersion.Major + 1
Write-Host "Major is increasing from $($targetVersion.Major) to $newMajor"
$newSemVer = "$newMajor.0.0"
break;
}

$newMinor = $targetVersion.Minor + 1
Write-Host "Feature target Main: Bump Minor from $($targetVersion.Minor) to $newMinor"
$newSemVer = "$($targetVersion.Major).$newMinor.0"
break;
}
}
{ $_ -match 'hotfix\/' } {
if ($targetBranch -like "*main") {
$newPatch = $targetVersion.Patch + 1
Write-Host "Hotfix target Main: Bump Patch from $($targetVersion.Patch) to $newPatch"
$newSemVer = "$($targetVersion.Major).$($targetVersion.Minor).$newPatch"
break;
}
}
Default {
throw "FAILED: Current Branch ($currentBranch) and/or Target Branch ($targetBranch) not found in logic."
}
}

return $newSemVer
}

Get the source version

Later in the logic, we will want to check if the version in the requesting branch has already been updated. Therefore, we need to get the version from the source branches appsettings.json file.

<#
.SYNOPSIS
Get the current branches version

.PARAMETER sourcePath
Path to the file storing the version

.EXAMPLE
Get-SourceVersion -sourcePath "projectsFiles/appsettings.json"

#>
Function Get-SourceVersion([string]$sourcePath) {

Write-Host "Get $sourcePath"
$fileContent = Get-Content -Path $sourcePath | ConverFrom-Json

Write-Host "Version $($fileContent.projectInformation.version) Found."
return $fileContent.projectInformation.version
}

Set the new version

Once we have got the new version determined we want to set it in the source branch. To do this we are going to use some standard git commands.

First, we will get the new version and update the current source branches file. This uses some PowerShell commands to get the content and reset it after updating the version number.

  Write-Host "Get $sourcePath"
$fileContent = Get-Content -Path $sourcePath | ConverFrom-Json

Write-Host "Version $($fileContent.projectInformation.version) Found."
$fileContent.projectInformation.version = $version

Write-Host "New Version $version Set."
Set-Content -Path $sourcePath -Value $($fileContent | ConvertTo-Json)

Then to make sure we are going to commit in the correct location and only submit the changed version file, we change the location to the JSON files parent.

  Write-Host "Commit new Version"
$configFile = Get-ChildItem $sourcePath
$parentPath = $configFile.Directory.FullName
$fileName = $configFile.Name

Write-Host "GIT: Set new location - $parentPath"
Set-Location $parentPath

We can run the git commands to add the file to the commit with a standard format message for consistency, then push the changes back to the source branch.

  Write-Host "GIT: Add to $fileName"
git add $fileName
git commit -m "CI: Update SemVer Version to $version"

Write-Host "GIT: Push to HEAD:$targetBranch"
git push origin HEAD:$targetBranch --force

In full the function looks like this.

<#
.SYNOPSIS
Set the new version in the file and commit to source branch

.PARAMETER sourcePath
Path to the file storing the version

.PARAMETER version
New SemVer version

.PARAMETER targetBranch
The current source branch that is merging in

.EXAMPLE
Set-NewVersion -sourcePath "projectsFiles/appsettings.json" -version "1.2.3" -targetBranch "origin/refs/feature/my-feature"
#>
Function Set-NewVersion([string]$sourcePath, [string]$version, [string]$targetBranch) {

Write-Host "Get $sourcePath"
$fileContent = Get-Content -Path $sourcePath | ConverFrom-Json

Write-Host "Version $($fileContent.projectInformation.version) Found."
$fileContent.projectInformation.version = $version

Write-Host "New Version $version Set."
Set-Content -Path $sourcePath -Value $($fileContent | ConvertTo-Json)

Write-Host "Commit new Version"
$configFile = Get-ChildItem $sourcePath
$parentPath = $configFile.Directory.FullName
$fileName = $configFile.Name

Write-Host "GIT: Set new location - $parentPath"
Set-Location $parentPath

Write-Host "GIT: Add to $fileName"
git add $fileName
git commit -m "CI: Update SemVer Version to $version"

Write-Host "GIT: Push to HEAD:$targetBranch"
git push origin HEAD:$targetBranch --force

}

The logic

Now that from the above we have all the PowerShell functions, we can bring it together for the final script. This will be in order:

  1. Get the current version
  2. Get the current version
  3. Get the new version

$targetSemVer = Get-CurrentVersion -organisation $adoOrganisation -project $adoProject -repository $adoRepository -sourcePath $appSettingsPath

$currentSemVer = Get-SourceVersion -sourcePath $appSettingsPath

$newSemVer = Get-NewVersion -targetBranch $targetBranch -currentBranch $sourceBranch -currentVersion $currentSemVer -targetVersion $targetSemVer

Before we update the version, we need to check whether the version number should be bumped, when using this in a Pull Request, we do not want to keep bumping the version every commit. When using this in a Pull Request, we don’t want to keep committing a new version every time it runs. We also don’t want the Pull Request to be closed if the version has changed. Therefore, we will check to see if the current version is different from the new version. If they are the same, we can let the Pull Request carry on, but if they are different we will update the source version and fail the script, which in turn fails the Pull Request pipeline preventing someone from completing the Pull Request.

if ($currentSemVer -eq $newSemVer) {
Write-Host "Current Version already updated"
exit 0
}
else {
Write-Host "Version Number needs updating"
Set-NewVersion -sourcePath $appSettingsPath -version $newSemVer -targetBranch $sourceBranch
exit 1
}

The final version

Below is the final script altogether, which can be embedded within your YAML pipeline. This is best to be run as part of a build on the Pull Request to keep incrementing the version number and prevent anyone from changing it manually.

param (
[Parameter(Mandatory = $true)]
[string]$adoOrganisation,
[Parameter(Mandatory = $true)]
[string]$adoProject,
[Parameter(Mandatory = $true)]
[string]$adoTeam,
[Parameter(Mandatory = $true)]
[string]$adoRepository,

[Parameter(Mandatory = $true)]
[string]$appSettingsPath,

[Parameter(Mandatory = $true)]
[string]$targetBranch,
[Parameter(Mandatory = $true)]
[string]$sourceBranch

)

<#
.SYNOPSIS
Get Current SemVer Version from Source

.PARAMETER organisation
Azure DevOps Organisation Url

.PARAMETER project
Azure DevOps Project Name

.PARAMETER repository
Azure DevOps Repository Name

.PARAMETER sourcePath
Path to the file storing the version

.EXAMPLE
Get-CurrentVersion -organisation https://dev.azure.com/my-org -project "My Project" -repository "My-Repo" -sourcePath "projectsFiles/appsettings.json"

#>
Function Get-CurrentVersion {
param(
[Parameter(Mandatory)]
[string]$organisation,

[Parameter(Mandatory)]
[string]$project,

[Parameter(Mandatory)]
[string]$repository,

[Parameter(Mandatory)]
[string]$sourcePath
)

Write-Host "Get $sourcePath from $organisation$project/$repository"
$fileContent = (az devops invoke --http-method GET --org $organisation --area "git" --resource "items" --route-parameters project=$project repositoryId=$repository --query-parameters includeContent=true path=$sourcePath api-version="6.0" | ConvertFrom-Json).content | ConverFrom-Json

$version = $fileContent.projectInformation.version
Write-Host "Version $version Found."

$semVer = Get-SemVer -semVerStr $version

return $semVer
}

<#
.SYNOPSIS
Convert a string to a SemVer Object

.PARAMETER semVerStr
SemVer string

.EXAMPLE
Get-SemVer -semVerStr "1.2.3"

#>
Function Get-SemVer([string]$semVerStr) {

Write-Host "SemVer: $semVerStr"
$semVerArr = $semVerStr.Split(".")

if ($semVerArr.Count -ne 3) {
throw "FAILED: $semVerStr is not a valid SemVer value (1.2.3)."
}
for ($i = 0; $i -lt $semVerArr.Count; $i++) {
if (!($semVerArr[$i] -match "^[\d\.]+$")) {
throw "FAILED: $($semVerArr[$i]) is not an Integer."
}
}

return @{
Major = [int]$semVerArr[0]
Minor = [int]$semVerArr[1]
Patch = [int]$semVerArr[2]
}
}

<#
.SYNOPSIS
Use the inputs with logic to determine the new SemVer Version

.PARAMETER targetBranch
Target Branch Pull Request is merging into

.PARAMETER currentBranch
The current source branch that is merging in

.PARAMETER currentVersion
The current sources version

.PARAMETER targetVersion
The current running version

.EXAMPLE
Get-NewVersion -targetBranch "origin/refs/main" -currentBranch "origin/refs/feature/my-feature" -currentVersion {Major:1,Minor:2,Patch:3} -targetVersion {Major:1,Minor:2,Patch:3}

.NOTES
Use the Get-SemVer function to set the currentVersion
#>
Function Get-NewVersion([string]$targetBranch, [string]$currentBranch, [object]$currentVersion, [object]$targetVersion) {

Write-Host "Target Branch - $targetBranch"
Write-Host "Target Version - $($targetVersion.Major + $targetVersion.Minor + $targetVersion.Patch)"
Write-Host "Current Branch - $currentBranch"
Write-Host "Current Version - $($currentVersion.Major + $currentVersion.Minor + $currentVersion.Patch)"

$newSemVer = ""
switch ($currentBranch) {
{ $_ -match 'feature\/' } {
if ($targetBranch -like "*main") {

if ([int]$targetVersion.Major -gt [int]$currentVersion.Major) {
$newMajor = $targetVersion.Major + 1
Write-Host "Major is increasing from $($targetVersion.Major) to $newMajor"
$newSemVer = "$newMajor.0.0"
break;
}

$newMinor = $targetVersion.Minor + 1
Write-Host "Feature target Main: Bump Minor from $($targetVersion.Minor) to $newMinor"
$newSemVer = "$($targetVersion.Major).$newMinor.0"
break;
}
}
{ $_ -match 'hotfix\/' } {
if ($targetBranch -like "*main") {
$newPatch = $targetVersion.Patch + 1
Write-Host "Hotfix target Main: Bump Patch from $($targetVersion.Patch) to $newPatch"
$newSemVer = "$($targetVersion.Major).$($targetVersion.Minor).$newPatch"
break;
}
}
Default {
throw "FAILED: Current Branch ($currentBranch) and/or Target Branch ($targetBranch) not found in logic."
}
}

return $newSemVer
}

<#
.SYNOPSIS
Get the current branches version

.PARAMETER sourcePath
Path to the file storing the version

.EXAMPLE
Get-SourceVersion -sourcePath "projectsFiles/appsettings.json"

#>
Function Get-SourceVersion([string]$sourcePath) {

Write-Host "Get $sourcePath"
$fileContent = Get-Content -Path $sourcePath | ConverFrom-Json

Write-Host "Version $($fileContent.projectInformation.version) Found."
return $fileContent.projectInformation.version
}

<#
.SYNOPSIS
Set the new version in the file and commit to source branch

.PARAMETER sourcePath
Path to the file storing the version

.PARAMETER version
New SemVer version

.PARAMETER targetBranch
The current source branch that is merging in

.EXAMPLE
Set-NewVersion -sourcePath "projectsFiles/appsettings.json" -version "1.2.3" -targetBranch "origin/refs/feature/my-feature"

#>
Function Set-NewVersion([string]$sourcePath, [string]$version, [string]$targetBranch) {

Write-Host "Get $sourcePath"
$fileContent = Get-Content -Path $sourcePath | ConverFrom-Json

Write-Host "Version $($fileContent.projectInformation.version) Found."
$fileContent.projectInformation.version = $version

Write-Host "New Version $version Set."
Set-Content -Path $sourcePath -Value $($fileContent | ConvertTo-Json)

Write-Host "Commit new Version"
$configFile = Get-ChildItem $sourcePath
$parentPath = $configFile.Directory.FullName
$fileName = $configFile.Name

Write-Host "GIT: Set new location - $parentPath"
Set-Location $parentPath

Write-Host "GIT: Add to $fileName"
git add $fileName
git commit -m "CI: Update SemVer Version to $version"

Write-Host "GIT: Push to HEAD:$targetBranch"
git push origin HEAD:$targetBranch --force

}

$targetSemVer = Get-CurrentVersion -organisation $adoOrganisation -project $adoProject -repository $adoRepository -sourcePath $appSettingsPath

$currentSemVer = Get-SourceVersion -sourcePath $appSettingsPath

$newSemVer = Get-NewVersion -targetBranch $targetBranch -currentBranch $sourceBranch -currentVersion $currentSemVer -targetVersion $targetSemVer


if ($currentSemVer -eq $newSemVer) {
Write-Host "Current Version already updated"
exit 0
}
else {
Write-Host "Version Number needs updating"
Set-NewVersion -sourcePath $appSettingsPath -version $newSemVer -targetBranch $sourceBranch
exit 1
}

About the author

Christopher Pateman is Senior Azure DevOps Engineer here at Version 1.

--

--

PR Code - Christopher Pateman
Version 1

I’m a Azure DevOps Engineer with a wide knowledge of the digital landscape. I enjoy sharing hard to find fixes and solutions for the wider community to use.