For Fake.StaticGen, I wanted to have a semi-automated way of determining the version number for each release. Inspired by Nerdbank.GitVersioning, I liked the idea of having a unique version for every commit, but I didn't quite like how this tool took over the build process. (Or I would have to try and integrate it into MSBuild, which seems to be possible. But MSBuild...)
The versioning system
I came up with the following system, which would be relatively easy to do with the FAKE build script that I was already using. Version information is provided in two files, version and version-pre in the root of the repository. In version the base version for the current commit is specified and version-pre contains the prerelease tag (the "-text" part of a version) if the version is a prerelease. The actual version is calculated based on the 'distance' of the commit to the last commit where the version file was changed.
Example:
# | Commit changes | Version |
---|---|---|
A | version is set to 1.1, version-pre to beta |
1.1.0-beta |
B | code updates | 1.1.1-beta |
C | version-pre removed |
1.1.2 |
D | more updates | 1.1.3 |
E | version is set to 1.2 |
1.2.0 |
F | more updates | 1.2.1 |
Now when we're still at commit F and we start making changes, the patch version will go up one and a -dirty prerelease flag is added, so we end up with version 1.2.2-dirty.
These are the versions as they are built on the master branch or a tagged commit. If they are built on a different branch, the branch label and the short commit hash are added to the prerelease flags. For commit F on the dev branch, that would result in 1.2.1-dev-F.
Some helper functions
FAKE comes with a wide variety of modules, two of which are particularly useful for our scenario: Fake.Core.SemVer for working with versions, and Fake.Tools.Git for executing Git commands. To use some functions of these modules more easily, we'll define a couple of helper functions.
Let's start with some helper functions to get the relevant information from Git:
let [<Literal>] repo = "." module GitHelpers = let isTagged () = Git.CommandHelper.directRunGitCommand repo "describe --exact-match HEAD" let previousChangeCommit file = Git.CommandHelper.runSimpleGitCommand repo ("log --format=%H -1 -- " + file) let fileChanged file = Git.FileStatus.getChangedFilesInWorkingCopy repo "HEAD" |> Seq.exists (fun (_, f) -> f = file)
isTagged
checks if the current commit is associated with a tag. If there is no tag, the command fails anddirectRunGitCommand
returns false.previousChangeCommit
gets the commit where a file was last changed. It requests the log for this file, printing only the commit hash using the%H
format string and limiting the number of commits to 1.runSimpleGitCommand
returns the output of the command as a string.fileChanged
checks if a file is changed in the working copy and not yet committed.
We'll also define some helper functions for modifying versions:
module Version = let withPatch patch version = { version with Patch = patch; Original = None } let appendPrerelease suffix version = let pre = match suffix, version.PreRelease with | Some s, Some p -> PreRelease.TryParse (sprintf "%O-%s" p s) | Some s, None -> PreRelease.TryParse s | None, p -> p { version with PreRelease = pre; Original = None }
withPatch
sets a new number to the patch number of a version.appendPrerelease
adds a suffix to the prerelease tag of a version, accounting for the fact that there might not be a prerelease tag yet.
Calculating the version
Now we can write our logic to calculate the correct version. We'll start with the simple version, which does not care about the branch rules yet:
let [<Literal>] versionFile = "version" let [<Literal>] versionPreFile = "version-pre" module Version = let getCleanVersion () = Trace.trace "Determining version based on Git history" let version = File.readAsString versionFile |> SemVer.parse let height = if GitHelpers.fileChanged versionFile then 0 else let previousVersionChange = GitHelpers.previousChangeCommit versionFile let height = Git.Branches.revisionsBetween repo previousVersionChange "HEAD" if Git.Information.isCleanWorkingCopy repo then height else height + 1 let pre = if File.exists versionPreFile then Some (File.readAsString versionPreFile) else None version |> withPatch (uint32 height) |> appendPrerelease pre
The most interesting part is the calculation of the 'git height', the number of commits between the current commit and the commit where the version file was last changed. If the file is changed in the current working copy, we reset the height to 0. Otherwise, we retrieve the commit where version was last changed, using our previousChangeCommit
helper function. Then we use the built-in FAKE function revisionsBetween
to get the 'height' of our current commit. If the working directory is clean, this is the result, otherwise, we add 1.
The height is set as the patch number of the version read from the version file (don't forget to convert to an unsigned integer) and combined with the prerelease flags read from version-pre to create the correct version.
Other branch rules
To keep things a bit orderly we'll set the correct dirty- and branch-flags in a new function:
module Version = let getVersionWithPrerelease () = let pre = let branch = match Git.Information.getBranchName repo with | "NoBranch" -> None | branch -> Some branch let isClean = Git.Information.isCleanWorkingCopy repo if isClean && (branch = Some "master" || GitHelpers.isTagged ()) then None else let commit = Git.Information.getCurrentSHA1 repo |> fun s -> s.Substring(0, 7) let dirty = if isClean then None else Some "dirty" [ branch; Some commit; dirty ] |> List.choose id |> String.concat "-" |> Some getCleanVersion () |> appendPrerelease pre
First, we get the name of the branch, where Git returning "NoBranch" means that there is no branch checked out. If we are in a clean working copy (i.e. there are no changes since the last commit) and the branch is the master branch or the current commit is tagged, then we don't add any prerelease tags.
If that condition does not hold, we get the abbreviated commit hash, set the dirty flag if necessary and put them together into a dash-separated string. The prerelease flag is then appended onto the 'clean' version.
That's it. Now we can retrieve the calculated version by calling Version.getVersionWithPrerelease ()
.
You can, of course, use this process with any type of application, but chances are you're using FAKE with a .NET project. So let's look at how we can set the correct version when building with the dotnet CLI.
Setting the version in a .NET project
There are some nested records involved when configuring a .NET build in FAKE, so we'll first define some helper functions on the MSBuild parameters:
[<AutoOpen>] module MSBuildParamHelpers = let withVersion version (param : MSBuild.CliArguments) = { param with Properties = ("Version", string version)::param.Properties } let withNoWarn warnings (param : MSBuild.CliArguments) = { param with NoWarn = param.NoWarn |> Option.defaultValue [] |> List.append warnings |> Some } let withDefaults version = withVersion version >> withNoWarn [ "FS2003" ]
The withDefaults
function sets the version and adds a flag to disable warnings for FS2003, which will complain about the AssemblyInformationalVersionAttribute
being set to a string that is not a valid assembly version number, such as a version with a prerelease flag. According to documentation, "this warning is harmless" and it can safely be ignored. For a C# project, the corresponding warning is CS1607.
Now you can build a project with the correct version:
let version = Version.getVersionWithPrerelease () DotNet.build (fun o -> { o with MSBuildParams = o.MSBuildParams |> withDefaults version }) projectOrSolutionPath
Using with Azure DevOps Pipelines
When you're running your builds on Azure Pipelines, there are two more things to consider:
- When your Git repo is cloned, the commit is checked out, not the branch, so
getBranchName
will always return "NoBranch". - You might want to set the build number to match your calculated version.
To do these two things, here are a couple more helper functions:
module AzureDevOps = let tryGetSourceBranch () = System.Environment.GetEnvironmentVariable("BUILD_SOURCEBRANCHNAME") |> Option.ofObj let updateBuildNumber version = sprintf "\n##vso[build.updatebuildnumber]%O" version |> System.Console.WriteLine
tryGetSourceBranch
tries to read the BUILD_SOURCEBRANCHNAME environment variable, where Azure Pipelines stores the branch on which the build was initiated.updateBuildNumber
writes a logging command to standard output to update the build number to a given version.
We'll need to use tryGetSourceBranch
at the point where we try to read the branch from Git in getVersionWithPrerelease
. The updated code to get the branch looks like this:
let branch = match Git.Information.getBranchName repo with | "NoBranch" -> AzureDevOps.tryGetSourceBranch () | branch -> Some branch
You can call updateBuildNumber
at any point, for example just before doing the build:
let version = Version.getVersionWithPrerelease () AzureDevOps.updateBuildNumber version DotNet.build (fun o -> { o with MSBuildParams = o.MSBuildParams |> withDefaults version }) projectOrSolutionPath
Now we have a fully reproducible version numbering system that will automatically assign a version to every commit, and the versions are directly used as the build number in Azure DevOps. If you'd like to see the complete code, it's available in this Gist.