Skip to main content

Challenge 14: Versioning strategies

Platform: both

This challenge covers versioning strategies applicable to both GitHub and Azure DevOps pipelines.

Exam skills

  • Design and implement a dependency versioning strategy for code assets and packages, including semantic versioning (SemVer) and date-based (CalVer)
  • Design and implement a versioning strategy for pipeline artifacts

Scenario

Contoso has inconsistent versioning across their 15 microservice teams. The auth team uses dates like 20240115, the payments team uses random build numbers, and three teams do not version their packages at all. This causes:

  • Rollback failures because teams cannot identify which version is deployed
  • Dependency conflicts when two libraries with incompatible changes share the same major version
  • Inability to set up automated dependency update policies
  • Audit failures due to untraceable artifact lineage

The VP of Engineering mandates a unified versioning strategy across all teams. Your task is to design and implement it.

Tasks

Task 1: Implement semantic versioning (SemVer)

Semantic versioning follows the format MAJOR.MINOR.PATCH where:

  • MAJOR increments for incompatible API changes
  • MINOR increments for backward-compatible new functionality
  • PATCH increments for backward-compatible bug fixes

Pre-release tags

Pre-release versions append a hyphen and identifiers after the patch number:

1.0.0-alpha.1
1.0.0-beta.3
1.0.0-rc.1

Precedence order: 1.0.0-alpha.1 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0

Build metadata

Build metadata is appended with a plus sign and does not affect version precedence:

1.0.0+20240615
1.0.0-beta.1+build.42

Step 1: Configure SemVer for an npm package

Create a package and set its initial version:

mkdir contoso-data-models && cd contoso-data-models
npm init -y
npm version 1.0.0

Bump versions based on change type:

# Bug fix: 1.0.0 -> 1.0.1
npm version patch

# New feature (backward compatible): 1.0.1 -> 1.1.0
npm version minor

# Breaking change: 1.1.0 -> 2.0.0
npm version major

# Pre-release: 2.0.0 -> 2.1.0-beta.0
npm version preminor --preid=beta

# Increment pre-release: 2.1.0-beta.0 -> 2.1.0-beta.1
npm version prerelease

Step 2: Configure SemVer for a NuGet package

In the .csproj file:

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PackageId>Contoso.DataModels</PackageId>
<Version>1.0.0</Version>
<PackageVersion>1.0.0</PackageVersion>
<Authors>Contoso</Authors>
<Description>Shared data models for Contoso microservices</Description>
</PropertyGroup>
</Project>

Override version at build time:

dotnet pack --configuration Release /p:Version=1.2.0-beta.1

Task 2: Implement calendar versioning (CalVer)

CalVer uses date components as version identifiers. Common formats:

FormatExampleUse case
YYYY.MM.DD2024.06.15Daily releases, rolling deployments
YYYY.MM.MICRO2024.06.3Monthly releases with patch count
YYYY.MINOR.MICRO2024.2.1Yearly major, incremental minor

CalVer works well for:

  • Applications (not libraries) where API compatibility is not the primary concern
  • Products with time-based release trains (Ubuntu uses YY.MM: 24.04)
  • Internal services where "when was this deployed" matters more than "what changed"

Step 1: Generate a CalVer version in a shell script

CALVER=$(date +%Y.%m.%d)
BUILD_NUMBER=${GITHUB_RUN_NUMBER:-0}
VERSION="${CALVER}.${BUILD_NUMBER}"
echo "Version: $VERSION"
# Output: Version: 2024.06.15.42

Step 2: Use CalVer in a Dockerfile

ARG VERSION=0.0.0
FROM mcr.microsoft.com/dotnet/aspnet:8.0
LABEL version="${VERSION}"
COPY ./publish /app
WORKDIR /app
ENTRYPOINT ["dotnet", "Contoso.Api.dll"]

Build with the version:

docker build --build-arg VERSION=$(date +%Y.%m.%d).$GITHUB_RUN_NUMBER -t contoso-api .

Task 3: Automatic versioning in GitHub Actions

Option A: Git tag-based versioning

name: Release with tag version
on:
push:
tags:
- 'v*'

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT

- name: Build and publish
run: |
npm version ${{ steps.version.outputs.VERSION }} --no-git-tag-version
npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Create and push a tag to trigger:

git tag v1.2.0
git push origin v1.2.0

Option B: GitVersion for automatic SemVer calculation

GitVersion analyzes your git history and branching model to automatically calculate the next version.

Install GitVersion as a .NET tool:

dotnet tool install --global GitVersion.Tool

Add a GitVersion.yml configuration:

mode: ContinuousDeployment
branches:
main:
regex: ^main$
tag: ''
increment: Patch
feature:
regex: ^feature/
tag: alpha
increment: Minor
release:
regex: ^release/
tag: rc
increment: None
hotfix:
regex: ^hotfix/
tag: beta
increment: Patch

Use GitVersion in a GitHub Actions workflow:

name: Build with GitVersion
on:
push:
branches: [main, 'feature/**', 'release/**']

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v1
with:
versionSpec: '5.x'

- name: Determine version
id: gitversion
uses: gittools/actions/gitversion/execute@v1

- name: Display version
run: |
echo "SemVer: ${{ steps.gitversion.outputs.semVer }}"
echo "NuGetVersion: ${{ steps.gitversion.outputs.nuGetVersion }}"
echo "InformationalVersion: ${{ steps.gitversion.outputs.informationalVersion }}"

- name: Build with version
run: dotnet pack /p:Version=${{ steps.gitversion.outputs.nuGetVersion }}

Task 4: Automatic versioning in Azure Pipelines

Option A: Using BuildId and pipeline variables

trigger:
- main

variables:
majorVersion: 1
minorVersion: 3
patchVersion: $[counter(format('{0}.{1}', variables['majorVersion'], variables['minorVersion']), 0)]
packageVersion: $(majorVersion).$(minorVersion).$(patchVersion)

pool:
vmImage: ubuntu-latest

steps:
- task: DotNetCoreCLI@2
displayName: 'Pack with version'
inputs:
command: pack
packagesToPack: '**/*.csproj'
versioningScheme: byEnvVar
versionEnvVar: packageVersion

- task: NuGetCommand@2
displayName: 'Push to feed'
inputs:
command: push
publishVstsFeed: contoso-packages

Option B: Branch-based pre-release versioning

variables:
${{ if eq(variables['Build.SourceBranch'], 'refs/heads/main') }}:
versionSuffix: ''
${{ elseif startsWith(variables['Build.SourceBranch'], 'refs/heads/feature/') }}:
versionSuffix: '-alpha.$(Build.BuildId)'
${{ elseif startsWith(variables['Build.SourceBranch'], 'refs/heads/release/') }}:
versionSuffix: '-rc.$(Build.BuildId)'
${{ else }}:
versionSuffix: '-dev.$(Build.BuildId)'

steps:
- script: |
VERSION="1.2.0$(versionSuffix)"
echo "##vso[build.updatebuildnumber]$VERSION"
echo "##vso[task.setvariable variable=packageVersion]$VERSION"
displayName: 'Calculate version'

- script: dotnet pack /p:Version=$(packageVersion) --output $(Build.ArtifactStagingDirectory)
displayName: 'Pack NuGet'

- task: PublishBuildArtifacts@1
inputs:
pathToPublish: $(Build.ArtifactStagingDirectory)
artifactName: packages

Task 5: Pipeline artifact versioning strategies

Pipeline artifacts (build outputs, container images, Helm charts) require their own versioning:

Strategy 1: Semantic version from source

Tag artifacts with the same SemVer as the source package:

# Container image tagged with SemVer
docker build -t contoso.azurecr.io/auth-service:1.2.0 .
docker tag contoso.azurecr.io/auth-service:1.2.0 contoso.azurecr.io/auth-service:latest
docker push contoso.azurecr.io/auth-service:1.2.0
docker push contoso.azurecr.io/auth-service:latest

Strategy 2: BuildId for traceability

steps:
- script: |
SHORT_SHA=$(echo $(Build.SourceVersion) | cut -c1-7)
IMAGE_TAG="$(Build.BuildId)-${SHORT_SHA}"
echo "##vso[task.setvariable variable=imageTag]$IMAGE_TAG"
displayName: 'Generate image tag'

- task: Docker@2
inputs:
containerRegistry: contoso-acr
repository: auth-service
command: buildAndPush
tags: |
$(imageTag)
latest

Strategy 3: Hybrid (SemVer + build metadata)

VERSION="1.2.0+build.${GITHUB_RUN_NUMBER}.sha.${GITHUB_SHA:0:7}"
echo "Artifact version: $VERSION"
# Output: 1.2.0+build.42.sha.a1b2c3d

Break and fix

Scenario: Version conflict when two PRs merge simultaneously

Two developers merge PRs to main within seconds of each other. Both pipelines calculate the next version as 1.3.0 because they read the same latest tag. The second npm publish fails:

npm ERR! 403 Forbidden - PUT https://npm.pkg.github.com/@contoso/data-models
npm ERR! You cannot publish over the previously published versions: 1.3.0
Show solution

Root cause: Both CI runs calculated the version from the same git state. Race conditions in tag-based versioning occur when parallel pipelines read the same "latest" tag before either has pushed a new one.

Fix 1: Use a counter-based approach (Azure Pipelines)

Azure Pipelines counter() expression is atomic and prevents duplicates:

variables:
patchVersion: $[counter(format('{0}.{1}', variables['majorVersion'], variables['minorVersion']), 0)]

Each pipeline run gets a unique, incrementing value regardless of timing.

Fix 2: Use run number in pre-release tag (GitHub Actions)

Include GITHUB_RUN_NUMBER which is unique per workflow:

- name: Calculate version
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
BASE_VERSION=${LATEST_TAG#v}
VERSION="${BASE_VERSION%.*}.$((${BASE_VERSION##*.} + 1))"
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT

Fix 3: Retry with incremented patch

Add retry logic that detects the 403 and increments:

- name: Publish with retry
run: |
MAX_RETRIES=3
ATTEMPT=0
while [ $ATTEMPT -lt $MAX_RETRIES ]; do
npm publish && break
ATTEMPT=$((ATTEMPT + 1))
CURRENT=$(node -p "require('./package.json').version")
NEXT_PATCH=$((${CURRENT##*.} + 1))
npm version "${CURRENT%.*}.$NEXT_PATCH" --no-git-tag-version
echo "Retrying with version $(node -p "require('./package.json').version")"
done

Fix 4 (recommended): Use GitVersion with ContinuousDeployment mode

GitVersion in ContinuousDeployment mode appends the commit count since last tag, ensuring uniqueness:

v1.3.0 tag on main
-> commit A: 1.3.1-ci.1
-> commit B: 1.3.1-ci.2 (always unique)

This avoids the race entirely because each commit produces a distinct version.

Knowledge check

1. According to SemVer, which version has the highest precedence?

2. A team releases their internal API gateway monthly and wants their version to communicate "when" rather than "what changed." Which strategy should they use?

3. In Azure Pipelines, which expression provides an atomic auto-incrementing integer that prevents version collisions between parallel runs?

4. A library tagged '2.1.0' receives two changes: a backward-compatible new method and a bug fix. What should the next version be?

Cleanup

Remove git tags created during this challenge:

git tag -d v1.0.0 v1.2.0 v1.3.0
git push origin --delete v1.0.0 v1.2.0 v1.3.0

Remove GitVersion tool if installed:

dotnet tool uninstall --global GitVersion.Tool

Remove project directories:

rm -rf contoso-data-models

Remove any Azure Pipelines counter state by updating the variable prefix in your pipeline YAML if you wish to reset counters.