While working on [OPEN-SOURCE PROJECT], a public GitHub repository with a few hundred forks, I kept running into the same question: which build is this? Every fork and every deployment reported the same version number, because the version was a string that somebody had to remember to edit by hand. This article shows how I made Visual Studio generate a new version number on every build, using a T4 template and a pre-build event. It's also where this series of articles really begins, because it was the first time I realized how much a project file will do for you if you ask it.
Every .NET assembly carries version information in a set of attributes. In older projects, these lived in a file called AssemblyInfo.cs, and you edited them by hand. SDK-style projects generate them for you from properties in the .csproj file, but the principle is the same: the version is whatever someone last typed. What I wanted was a version that changed automatically with every build, so that any binary or container could be traced back to when it was built.
There are three version attributes, and they serve different purposes:
| Attribute | Purpose |
|---|---|
AssemblyVersion |
Used by the runtime for binding. Changing it can break anything that references the assembly, so it's best left alone. |
AssemblyFileVersion |
The file version shown in Windows Explorer. Purely informational, so it's safe to change on every build. |
AssemblyInformationalVersion |
The "product version". A free-form string that can include anything you like, such as a branch name or commit hash. |
The plan is to leave AssemblyVersion fixed and generate the other two.
Taking Over From The SDK
The first thing to do is stop the .NET SDK from generating these attributes itself. If you don't, you'll get error CS0579 ("Duplicate 'AssemblyFileVersion' attribute") as soon as your own file appears alongside the one the SDK generates. Add the following to the project file:
<PropertyGroup>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
GenerateAssemblyInfo switches off the lot. If you'd rather keep some of the generated attributes, there are individual switches such as GenerateAssemblyFileVersionAttribute and GenerateAssemblyInformationalVersionAttribute that you can set to false one at a time.
A T4 Template For AssemblyInfo
T4 (Text Template Transformation Toolkit) has been part of Visual Studio for a long time, and it does one job well: running C# code to generate a text file. Add a Properties folder to the project if it doesn't already have one, then add a Text Template file named AssemblyInfo.tt with the following content:
<#@ template debug="true" hostspecific="true" language="C#" #>
<#@ output extension=".cs" #>
<#
string[] date = DateTime.Now.ToString("yy-MM-dd-HHmm").Split('-');
major = date[0]; // Year (YY)
minor = date[1]; // Month (MM)
build = date[2]; // Day (DD)
revision = date[3]; // Time (HHMM)
#>
using System.Reflection;
[assembly: AssemblyTitle("MyApp.Web")]
[assembly: AssemblyProduct("MyApp")]
[assembly: AssemblyVersion("1.0")]
[assembly: AssemblyFileVersion("<#= major #>.<#= minor #>.<#= build #>.<#= revision #>")]
[assembly: AssemblyInformationalVersion("<#= major #>.<#= minor #>.<#= build #>.<#= revision #>")]
<#+
string major = "1";
string minor = "0";
string build = "0";
string revision = "0";
#>
The template has three kinds of block. <# #> contains code that runs when the template is transformed. <#= #> writes a value into the output. <#+ #> declares class-level members, which is why major, minor and so on can be assigned in the first block and used in the attributes further down. Everything outside those blocks is written to the output file as-is, and the output extension directive says that the output is a .cs file. The result is an AssemblyInfo.cs file next to the template, which compiles into the assembly like any other source file.
You might wonder why the version is based on the date and time rather than a counter. That was my first approach: a regular expression read the previous AssemblyInfo.cs, found the build number, and added one. The trouble with a counter is that it needs somewhere to live between builds, and that "somewhere" is a file that changes on every build, and every developer's copy of it disagrees. The date needs no memory at all. A version like 25.03.06.1427 tells you exactly when the build was made, and it only ever goes up. There's one limit to bear in mind: each part of a file version must fit in a 16-bit number (65,534 at most), which is why the time is HHmm rather than something longer.
Running The Template On Every Build
Visual Studio transforms a T4 template when you save it, but that's no good for a version number that should change every time you build. For that, you need to run the transformation yourself, before compilation starts. The easiest way to do that is with a pre-build event. Open the project's properties, go to Build > Events, and enter the following in the Pre-build event box:
"$(DevEnvDir)TextTransform.exe" -a !!BuildConfiguration!$(Configuration) "$(ProjectDir)Properties\AssemblyInfo.tt"
📷 [SCREENSHOT: Project Properties > Build > Events, with the TextTransform command in the Pre-build event box]
TextTransform.exe is the command-line version of the T4 engine, and it's installed with Visual Studio in the folder that $(DevEnvDir) points to. The -a switch passes a parameter into the template. I use it to pass the build configuration so the template can behave differently for Release builds if needed.
Now for the part that surprised me. Save the properties, then open the .csproj file:
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
<Exec Command=""$(DevEnvDir)TextTransform.exe" -a !!BuildConfiguration!$(Configuration) "$(ProjectDir)Properties\AssemblyInfo.tt"" />
</Target>
The friendly text box in the properties page is a thin layer over plain MSBuild. Visual Studio has written a target named PreBuild that runs before a built-in target named PreBuildEvent, and inside it is a single Exec task that runs the command. The quotes have been encoded as " because they're inside an XML attribute. If you enter more than one line in the box, the line breaks are encoded too, as 
.
That was the moment everything else in this series grew from. If a build event is just an Exec inside a target, then the project file can run any command, before or after any stage of the build. Parts 2 and 3 take that idea a long way.
Checking The Result
Build the project, and Properties\AssemblyInfo.cs is regenerated with the current date and time:
[assembly: AssemblyVersion("1.0")]
[assembly: AssemblyFileVersion("25.03.06.1427")]
[assembly: AssemblyInformationalVersion("25.03.06.1427")]
The easiest way to confirm that the attributes have made it into the compiled assembly is to right-click the DLL in bin\Debug\net8.0, choose Properties and look at the Details tab:
📷 [SCREENSHOT: Windows file Properties > Details tab for the built DLL, showing File version and Product version]
It's also useful to show the version inside the application. For a web app, a line in the footer of the layout page is enough to answer "which build is this?" at a glance:
@using System.Reflection
<footer>
Build @(Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion)
</footer>
A couple of notes before moving on. First, TextTransform.exe only exists where Visual Studio is installed, and $(DevEnvDir) is only set when the build runs inside Visual Studio (or a Developer Command Prompt). Run dotnet build from an ordinary terminal and this pre-build event fails. That didn't matter to me at the time, but it comes back later in the series. Second, the generated AssemblyInfo.cs changes on every build, so you may prefer to add it to .gitignore and let each build create it.
Summary
By turning off the SDK's generated assembly attributes and replacing them with a T4 template, you can give every build its own version number based on the date and time. A pre-build event runs the transformation before compilation. And a look at what Visual Studio writes into the .csproj for that pre-build event shows that it's nothing more than an MSBuild target containing an Exec task. In the next article, I'll use that to bring git into the build, so that the version also records which branch and commit it came from.