Skip to content

Instantly share code, notes, and snippets.

@aldrichtr
Forked from Jaykul/About Versioning.md
Created November 1, 2022 22:25
Show Gist options
  • Save aldrichtr/85c22e8470408776a7cf253e341938ba to your computer and use it in GitHub Desktop.
Save aldrichtr/85c22e8470408776a7cf253e341938ba to your computer and use it in GitHub Desktop.
Versioning

Versioning Software

We need our software builds to label the software with a version number.

We want each build to produce a label that is unique enough that we can track a binary back to it's build and the commit it was based on.

We want to follow "Semantic Versioning" because our industry as a whole has decided it's useful.

We want the version number to be predictable, so that (as developers) we know what version of the software we're working on.

We use a centralized git workflow commonly known as feature branch or topic branch.

A note on semantic versioning

The official Semantic Versioning spec (as listed on SemVer.org requires a three part version number:

Given a version number MAJOR.MINOR.PATCH, increment the:

MAJOR version when you make incompatible API changes, MINOR version when you add functionality in a backwards compatible manner, and PATCH version when you make backwards compatible bug fixes. Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.

A "version number" is therefore a series of three numbers, like "18.10.0" -- it does not include any prefix which might be in the release tag, nor a fourth digit which might be used by assembly versions, nor any pre-release or metadata information.

A "semantic version" is a version number with an optional labels for pre-release and build metadata:

  • A pre-release version MAY be denoted by appending a hyphen and a series of dot separated identifiers immediately following the patch version.
  • Build metadata MAY be denoted by appending a plus sign and a series of dot separated identifiers immediately following the patch or pre-release version.

We follow the identifier syntax of semantic versioning as prescribed by (SemVer 2), which was revised to ensure that semantic versions will be canonical. Thus:

Identifiers are dot separated. Identifiers MUST comprise only ASCII alphanumerics and hyphens [0-9A-Za-z-]. Identifiers MUST NOT be empty. Numeric identifiers MUST NOT include leading zeroes.

NOTE: When dealing with systems that only support SemVer 1 -- notably NuGet 2.0 as used by PowerShell and Chocolatey -- we convert our pre-release identifiers only, by zero-padding the counters and removing the dots.

Feature Branching Development

Feature branching is a simple form of mainline development where a "feature" or "topic" is any kind of change to a project: whether it's a bug fix, new functionality, or even just more documentation. In agile terms, a topic represents a "releasable iteration" or a work item in our backlog.

In mainline development, the main (sometimes called master) branch is special, and represents the official project history. It is always in a state that could be deployed, and indeed, the tip of master inherently represents the current release. Only releasable iterations are merged to master as this triggers the deployment pipeline, including integration testing.

In Feature Branching, a developer creates a branch to start an iteration, and merges it to master only when the work is complete.

NOTE: this is in contrast to Continuous Integration, where you merge back and forth between master and open topic branches frequently.

How SemVer works with feature branching

  • Each commit to master increments the version number
  • Each commit to any other branch increments a pre-release counter
  • Any build can generate a unique, predictable build number by counting commits

We can put additional semantic metadata into the informational version number, like the branch name, the commit sha, the build id, or even the date.

In the case of .NET assembly file versions, the revision number (the 4th digit) may carry the pre-release counter or the build id, since System.Version doesn't support semantic version -prerelease tags. We'll still put the full semantic version into the "Informational Version" on the assembly.

Using GitVersion for versioning

We use GitVersion to calculate the version based on tagged commits and the feature branch workflow. It is fully configurable, and each repository can tweak it via a GitVersion.yml file.

By default GitVersion increments the patch, so if you don't do anything, you'll get basically the same results you do today.

GitVersion supports adding a line to your commit or merge message to specify the type of change. You can change the pattern of the message that's required in your GitVersion.yml but by default you would use:

- `+semver:breaking` or `+semver:major` to increment the major version when you make incompatible API changes
- `+semver:feature` or `+semver:minor` to increment the minor version when you add functionality in a backwards compatible manner
- `+semver:fix` or `+semver:patch` to increment the patch version when you make backwards compatible bug fixes

Since it calculates the version based on tags and commit messages, the version number is fully controllable, predictable, and semantic. You should be able to get the same version number building locally as on the build server. Note that when merging, if your branch contains multiple +semver indicators in the commit history, the highest one takes effect. A simple option to prevent confusion is to configure GitVersion to look only at merge messages, and then require the version line in your PR descriptions -- your CI system usually adds the description to the merge commit message by default.

  1. If the commit being built is tagged with a version string, the version will always match the tag
  2. In mainline mode, the version is incremented for every commit on master since the last tag
  3. We tag master when we successfully complete a build on master, to ensure builds from other feature branches increment appropriately
  4. On feature branches, we are implicitly working on the next version, and we use the branch name as a pre-release label, and increment the counter for each commit
  5. On hotfix branches, we use "rc" as the pre-release label, and tag manually when we release from a hotfix branch

GitVersion calculates the build number and includes semantic metadata which we can use in the "informational version" on assemblies and the release notes in PowerShell module manifests. A full informational build number should include at least the sha and the date, but depending on configuration, your build might be numbered like this:

19.6.0-beta.5+Build.8125.Branch.joelbennett-13452-add-abort-button.Date.2019-05-22.Sha.df9d5e2f6246344c5763b1ea4801edddcfe8e0c7

Developing software in feature branches

NOTE: This is just documentation of an agreed-upon work process at one of my previous teams...

Changes, whether for stories, bugs, or documentation, must be made on feature branches.

Developers create a new branch from the tip of master each time they begin to work on a new work item. The branches should be as small, focused, and short-lived as is practical. We give them descriptive names representing the focus of the branch. This may include a work item number, but that number is insufficient on it's own. To accommodate multiple feature teams working within shared source control projects, we recommend that each team or developer use their name as a prefix on their branch names, and use forward slashes to separate the prefix. A good branch name might be, for example: joelbennett/13452/add-abort-button ...

git switch -c $branchName

As they work, developers should commit their work frequently, and push their branch to the central repository for backup and collaboration. Developers must also regularly update their feature branch from their source branch by rebasing on top of any changes that are added to it. Each time they update, they must use the --force-with-lease git option to push the rebased feature branch back to the remote server (see --force considered harmful).

git commit -am $commitMessage
git rebase origin/master
git push origin HEAD --force-with-lease

When developers need to discuss a feature branch with others (as they must, before merging it), they may create a pull-request at any time, and can simply mention in the description if the code is not ready to be merged (put "WIP" on the front, or leave the pull-request in draft mode).

# azure repos (see https://github.com/Azure/azure-devops-cli-extension)
az repos pr create

# github (see https://hub.github.com/)
hub pull-request

Developers should consider the state of their feature branch's commit history before creating any pull request: if it's messy or confusing, it may be cleaned up with a git rebase --interactive.

Changes can be merged to master only after approval of the pull-request. Normally, each repository will have an automated CI build which runs the full test suite on pull requests, and produces a release candidate build or deployment package which can be used for further testing.

Developers can fix any issues or address code-review comments by simply adding commits to the feature branch to update the pull request.

Project teams should only merge pull requests when:

  • The team is happy with the changes
  • The branch is up-to-date with it's source branch
  • The pull-request passes the full test suite
  • The changes are ready to be released
  • The feature is intended for the next release
mode: Mainline
assembly-versioning-format: '{Major}.{Minor}.{Patch}.{env:BuildCount ?? 0}'
assembly-informational-format: '{NuGetVersionV2}+Build.{env:BuildCount ?? 0}.Date.{CommitDate}.Branch.{env:SafeBranchName ?? unknown}.Sha.{Sha}'
commit-date-format: yyyyMMddTHHmmss
commit-message-incrementing: MergeMessageOnly
# We don't like the "+semver:minor" noise, but we support them for legacy reasons
# We prefer english messages with issue numbers:
# Breaking change: #32
# Adds feature: #43
# Fixes: #9
# SkipCi
major-version-bump-message: '(breaking|major)\s?(feature|change):\s*#?\d+|\+semver:\s?(breaking|major)'
minor-version-bump-message: '(related|adds) feature:\s*#?\d+|\+semver:\s?(feature|minor)'
patch-version-bump-message: '(related|fixes)\s?(bug|issue)?:\s*#?\d+|\+semver:\s?(fix|patch)'
no-bump-message: 'NoChange|SkipCi|DocChange|\+semver:\s?(none|skip)'
branches:
master:
tag: '' # explicitly no tag for master builds
increment: Patch
is-mainline: true
tracks-release-branches: true
hotfix:
tag: 'rc' # explicitly no tag for release builds
regex: hotfix(es)?/\d+\.\d+\.\d+
increment: None
is-release-branch: true
prevent-increment-of-merged-branch-version: true
source-branches: ['master']
release:
tag: 'rc' # explicitly no tag for release builds
regex: releases?/\d+\.\d+\.\d+
increment: None
is-release-branch: true
prevent-increment-of-merged-branch-version: true
source-branches: ['master']
pull-requests:
regex: pull/
tag: rc
increment: Patch
source-branches: ['master', 'feature', 'release', 'hotfix']
feature:
regex: .*/
tag: useBranchName
source-branches: ['master', 'feature']
track-merge-target: true
tracks-release-branches: true
increment: Patch
[CmdletBinding()]
param(
# Path to a folder which exists, where we can create a "VersionTest" folder
$TestRoot = "~/Projects"
)
if ($DebugPreference -ne "SilentlyContinue") {
$DebugPreference = "Continue"
}
if (!(Test-Path $TestRoot)) {
throw "The -TestRoot paramter must be the path to a working directory"
}
Get-Item Variable:VersionCounter_* | Remove-Item
function New-Build {
# Fake the Azure build counter:
$Result = GitVersion -NoCache | ConvertFrom-Json
$Counter = "VersionCounter_$($Result.MajorMinorPatch)"
if (Test-Path Variable:$Counter) {
Set-Variable -Scope Global $Counter (1 + (Get-Variable -Scope Global $Counter -ValueOnly))
} else {
Set-Variable -Scope Global $Counter 0
}
$Env:BuildCount = Get-Variable -Scope Global $Counter -ValueOnly
$Env:SafeBranchName = $Result.BranchName -replace '[\\/]','-'
Write-Debug "BuildCount ${Env:BuildCount} for $Counter"
}
function Test-Version {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$Version
)
New-Build
try {
GitVersion -NoCache | ConvertFrom-Json -ov Result | ForEach-Object {
Write-Host "Informational SemVer: " $_.InformationalVersion -Fore DarkCyan
}
# JSON parse errors should stop everything
} catch {
Write-Warning "Error parsing GitVersion output:"
GitVersion -NoCache | Out-Host
throw $_
}
if(($Version -split '\.').Count -eq 3) {
$Version = "$Version.0"
}
if ($Result.AssemblySemVer -ne $Version) {
Write-Warning "Expected $Version but got $($Result.AssemblySemVer)"
if($DebugPreference -ne "SilentlyContinue") {
$Result | Out-String | Write-Debug
Wait-Debugger
}
} else {
Write-Host "AssemblySemVer: $($Result.AssemblySemVer)" -Fore Green
}
}
function New-Commit {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Version,
[string]$branch
)
if ($branch) {
git checkout $branch
}
$Version >> touch.log
git add *
git commit -m "Want $Version"
Test-Version $Version
}
function New-Feature {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Branch,
$Team = "forge",
$WorkItem = 1234122,
$From = "master"
)
if ($Branch -match "^\w+/") {
$Team = ($Branch -split "/")[0]
}
if ($Branch -match "/\d+/") {
$WorkItem = $Branch -replace ".*/(\d+)/.*", '$1'
}
$branch = ($Team, $WorkItem, ($Branch -split "/")[-1]) -join "/"
git checkout $From | out-host
git checkout -b $branch | out-host
$branch
}
function New-Hotfix {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Version,
$From = "master"
)
$Tag = (([Version]$Version).ToString(3))
$branch = "hotfix", $Tag -join "/"
git checkout $From | out-host
git checkout -b $branch | out-host
$branch
}
function New-Tag {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Version
)
$Tag = (([Version]$Version).ToString(3))
Write-Host "Tagging Master: $Tag"
git tag $Tag
Test-Version $Version
}
function New-Merge {
[CmdletBinding(DefaultParameterSetName = "BugFix")]
param(
[Parameter(Mandatory, Position = 0)]
[string]$Version,
[Parameter(Mandatory, Position = 1)]
[string]$branch,
[Parameter(Mandatory, ParameterSetName = "BugFix")]
[Alias("Bug", "BugFix", "Patch")]
[int]$Fix,
[Parameter(Mandatory, ParameterSetName = "Feature")]
[Alias("Minor","Adds")]
[int]$Feature,
[Parameter(Mandatory, ParameterSetName = "Breaking")]
[Alias("Major")]
[int]$Breaking,
[string]$commitMessage = $("Want $Version (merge $branch)"),
[switch]$Tag,
[string]$ToBranch = "master"
)
Write-Host "Merge $branch into master" -Foreground Cyan
if ($Breaking) {
$commitMessage += "`n`nBreaking change: #$Breaking"
} elseif ($Feature) {
$commitMessage += "`n`nRelated feature: #$Feature"
} else {
$commitMessage += "`n`nRelated bug: #$Fix"
}
git checkout $ToBranch
git merge --no-ff -m $commitMessage $branch
# Handle the probable git merge conflict
if ((get-content touch.log) -match "^=+$") {
set-content touch.log ((get-content .\touch.log) -notmatch "[<>=]+")
git add touch.log
git commit -m $commitMessage
}
if ($Tag) {
git tag (([Version]$Version).ToString(3))
}
Test-Version $Version
}
function New-Rebase {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$branch,
$Version
)
git checkout $branch
git rebase master
# Handle the probable git merge conflict
if ((get-content touch.log) -match "^==+$") {
set-content touch.log ((get-content .\touch.log) -notmatch "[<>=]+")
git add touch.log
git rebase --continue
}
if ($Version) {
Test-Version $Version
}
}
## reset
Set-Location $TestRoot
if (Test-Path $TestRoot\VersionTest) {
if(!$PSCmdlet.ShouldProcess("All content of '$(Convert-Path $TestRoot\VersionTest)' will be deleted! Is that OK?", "The test folder already exists.")) {
return
}
Get-Item VersionTest -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ea 0
}
mkdir VersionTest -ea 0
Set-Location VersionTest
## Set up your project:
## Set up git
git init
## First commit, adding GitVersion configured to MAINLINE and PATCH increment
Copy-Item $PSScriptRoot\GitVersion.yml
git add *
git commit -m "Initial commit"
## The baseline version in gitversion is always 0.1 until you change it
Test-Version 0.1.0.0
## Lets change the initial version using a tag
$NewVersion = "18.5.1"
Write-Host "Manually set the version $NewVersion by tagging" -Foreground Cyan
New-Tag $NewVersion
## Now the "NEXT" release will be 18.5.2 (incrementing the patch number)
## Imagine DEV1 starts a bug fix
$branchOne = New-Feature "forge/1234122/one"
## Each feature on master should increment the patch: Major.Minor.PATCH
## While in branches, we expect builds to increment: Major.Minor.Patch.BUILD
Copy-Item $PSScriptRoot\Readme.md
New-Commit "18.5.2"
## Since this is the second commit ...
## The SemVer should be: 18.5.2-one0002+Build.1 (but we use the AssemblyVersion for testing)
New-Commit "18.5.2.1"
## Now imagine DEV2 starts a feature
$branchTwo = New-Feature "forge/1234124/two"
# We wish this was 18.6.0-beta, because it's a feature
# But since we haven't told gitversion that yet ...
New-Commit "18.5.2.2"
New-Commit "18.5.2.3"
## Back on $branchOne, we're still working on that bug fix
## Notice that the two branches share an incrementing counter!
## This semver is 18.5.2-one0003+Build.4
New-Commit "18.5.2.4" -branch $branchOne
## While this semver is 18.5.2-two0002+Build.5
New-Commit "18.5.2.5" -branch $branchTwo
## Whichever merges to master first, will release the version they've been using in pre-release
## When we merge a feature branch, we tag it in the commit message with `adds feature: #1234124`
## Which results in incrementing the version number like we wanted, to: 18.6.0
New-Merge "18.6.0" -branch $branchTwo -Feature $($branchTwo -replace ".*/(\d+)/.*", '$1')
## ################################################################################# #
## To ensure that existing branches pick up that new version, we need to tag master: #
## If we don't do this, it ONLY affects the branch builds #
## They will keep building, e.g. 18.5.2.6 instead of starting on 18.6.1 #
## But when they are merged to master, the merge will be correctly versioned #
## ################################################################################# #
## Note: the tag is "18.6.0" but if this triggers another build, the assembly is 18.6.0.1
New-Tag "18.6.0.1"
## MASTER is now 18.6.0, and feature branches are tracking it
## So new builds on the old feature branch are now up to date:
New-Commit "18.6.1" -branch $branchOne
New-Commit "18.6.1.1"
## We can rebase onto master, but it has no effect on versioning
New-Rebase "18.6.1.2" -branch $branchOne
New-Commit "18.6.1.3" -branch $branchOne
## When we merge the bugfix to MASTER, obviously, this should be ...
New-Merge "18.6.1.4" -branch $branchOne -Fix $($branchOne -replace ".*/(\d+)/.*", '$1')
## Make a hotfix
$NewVersion = "18.6.2"
$hotfixOne = New-Hotfix $NewVersion
New-Commit $NewVersion
New-Commit "18.6.2.1"
# Tag it to release it (and build without the -rc tag)
Write-Host "Tag release to get a final build" -Foreground Cyan
New-Tag "18.6.2.2"
# When we merge it back to master, it should increment again
# Because we _always_ increment when we commit to master
New-Merge "18.6.3" -branch $hotfixOne -Fix '1234127'
# Now we need another hotfix
$hotFix = "18.6.4"
$hotfixTwo = New-Hotfix $hotFix -From "refs/tags/18.6.2"
# While DEV2 needs to start another feature
$branchThree = New-Feature "forge/1234132/three"
New-Commit "18.6.5" -branch $branchThree
# What happens when we commit to our hotfix?
New-Commit $hotFix -branch $hotfixTwo
# While DEV2 continues work on her feature
New-Commit "18.6.5.1" -branch $branchThree
# DEV2 finishes her feature while DEV1 is still working on the hotfix
# And that increments the release:
New-Merge "18.7.0" -branch $branchThree -Feature $($branchThree -replace ".*/(\d+)/.*", '$1')
# DEV1 finishes the hotfix, and tags it to remove the pre-release
Write-Host "Tag release to get a final build" -Foreground Cyan
New-Tag "18.6.4.1"
# Notice that we released 18.7.0 and then the 18.6.4 hotfix ...
# So when we merge, it's 18.7.1 ...
New-Merge "18.7.1" -branch $hotfixTwo -Fix '1234128'
## Major Breaking changes, or a new year
$branchFour = New-Feature "forge/1234138/four"
New-Commit "18.7.2"
New-Merge "19.0.0" -branch $branchFour -Breaking $($branchFour -replace ".*/(\d+)/.*", '$1')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment