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.
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 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 default branch (usually called main
or previously master
or trunk
) is special, and represents the official project history. It is always in a state that could be deployed, and indeed, the tip of your default branch inherently represents the current release. Only releasable iterations are merged to this branch, 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 the default branch only when the work is complete.
NOTE: compare this to Martin Fowler's description of Continuous Integration, where you merge back and forth between the default branch and open feature branches frequently, and use feature flags to hide incomplete work.
Releases are represented by tags on the repository --usually on the default branch.
When release processes are long, or when there are arbitrary release cycles, or a long-term servicing release, it is acceptable to create a release branch. Short term patching branches should only be created on-demand, for issues found during the release process -- and should have the full version of the intended patch as their branch name, like "release/1.2.5". They must be tagged upon release, and then merged back into the default branch. Some teams will call these "hotfix" releases -- but they should follow the same pattern.
Long term servicing branches are not appropriate for continuous delivery apps like web apps and consumer/mobile apps. They are created with a name like "release/1.2" so that multiple releases can be made from them. Each release should still be tagged upon release, and merged back into the default branch.
- Each commit to the default branch 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.
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.
- If the commit being built is tagged with a version string, the version will always match the tag
- In mainline mode, the version is incremented for every commit on the default branch since the last tag
- We tag that default branch when we successfully complete a build, to ensure builds from other feature branches increment appropriately
- 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
- On release or 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
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 the default branch 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/main
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 the default branch 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
As a final note, there are many formal workflows based on this type of branching, such as OneFlow and CommonFlow and GitHub Flow -- I can't endorse any particular one, and each place I've helped to implement these has ended up customizing the workflow for themselves.
In the enterprise, we often separate the concept of build (and deliver) from release (and deploy). Continuous delivery becomes about building and delivering a tested and deployable package (for instance: pushing a docker image to a registry, or publishing a nuget package to a feed). In most cases, only test environments will continuously deploy those packages, and there's a separate release step where that package is deployed to the production environment. Releases might be manual, and might depend on a separate approval process such as a change advisory board, or be scheduled for a specific date and time.
In this situation, Teams should create continuous integration and delivery pipelines which build and publish packages for each pull request and merge to the default branch, and then create a separate release pipeline which can deploy any given package to any specified environment. That deployment pipeline should be automatically triggered by the delivery pipeline to deploy to a test environment, and the same process should be used to release the package to the production environment when the time comes. It's critical that release pipelines be able to deploy any given version to any environment, and be able to re-deploy packages which have already been deployed.