This post is part of the Advanced MSBuild series.
The end result can be seen on my github.
The use-case: exposing build information at runtime
Sometimes it is necessary to use some piece of information during the build and then also at runtime.
As always, it is desirable to have a single source of truth for that piece of information, e. g. to declare it only once.
In my particular use-case I needed to expose the package version of an external dependency at runtime.
The information would then be used to choose a “most compatible” adapter for a given product version of a third party application.
(Yes, this was about the 3rd party API again mentioned in some of my other MSBuild posts)
Let’s call the external package “SomeExternalPackage”.
We want to build against a particular version of the package,
We want to have this version available as some class property in C# at runtime.
We need to change the version regularly, which is why we would benefit from a single source of truth.
Changing the version in a single place will ensure that the reported package version and the version used for building will always be consistent.
There are brute-force ways to share information between the MSBuild world and the C# world, of course.
We could e. g. define an MSBuild task that writes the information to a file which the code at runtime can read.
However, this is cumbersome, because the target needs to reliably add the file to the build output so that it is copied along with the assemblies.
The path of this file would need to be magically known to the code, too, because otherwise we would have the same problem again of sharing build information with the runtime.
A much simpler approach is to piggy-back on the .NET SDK’s own assembly attribute generation mechanism.
Using the AssemblyAttribute item to generate custom assembly attributes
Remember that SDK-style projects generate assembly-scoped attributes like AssemblyVersionAttribute
and AssemblyProductAttribute
from project properties.
We can hook into that mechanism to have MSBuild generate a custom attribute that contains the information we want.
It looks like this in the csproj
:
<PropertyGroup> <__SomeVersion>1.2.3.4</__SomeVersion> </PropertyGroup> <ItemGroup> <AssemblyAttribute Include="GlobalAttributeDemo.MyMetadataAttribute"> <_Parameter1>$(__SomeVersion)</_Parameter1> </AssemblyAttribute> <PackageReference Include="SomeExternalPackage" Version="$(__SomeVersion)" /> </ItemGroup>
And like this in the C# code:
The attribute:
namespace GlobalAttributeDemo { [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] public sealed class MyMetadataAttribute : Attribute { public MyMetadataAttribute( string someVersion) { SomeVersion = Version.Parse(someVersion); } public Version SomeVersion { get; } } }
And the data retrieval:
public static class MetadataRetrieval { static readonly Assembly AssemblyWithMetadataAttribute = typeof(MetadataRetrieval).Assembly; static readonly MyMetadataAttribute MetadataAttribute = AssemblyWithMetadataAttribute.GetCustomAttribute<MyMetadataAttribute>() ?? throw new InvalidOperationException("expected metadata attribute to be defined, but it was not"); public static Version SomeVersion => MetadataAttribute.SomeVersion; }
I did not come up with this, but read it in an answer on stackoverflow by Martin Ullrich (thanks, Martin).
The answer however does not explain why this works.
After all, at the time of writing, the AssemblyAttribute
item does not seem to be documented anywhere.
So, let’s dive in.
If you only care about the pitfalls and limits of the approach, skip the next section.
If you don’t care about any of this and want to see a working example, see my github,
Breakdown: Why does this work?
With SDK-style projects, everything is defined in MSBuild terms, i. e. properties, items, targets etc..
We can thus look through our local SDK folder (e. g. C:\Program Files\dotnet\sdk\6.0.402
) with a text editor and see how and where the AssemblyAttribute
item is handled.
We find the above-mentioned attribute generation mechanism in one of the Microsoft.NET.Sdk targets files called Microsoft.NET.GenerateAssemblyInfo.targets
.
All the well-known project properties for assembly info generation are transformed into elements of the AssemblyAttribute
item in a target called GetAssemblyAttributes
:
It even shows us which MSBuild syntax to use for calling an attribute’s constructor.
This target is implicitly executed through other targets each deeclaring a dependency on its predecessor (
).GetAssemblyAttributes
<-- CreateGeneratedAssemblyInfoInputsCacheFile <-- CoreGenerateAssemblyInfo
<-- GenerateAssemblyInfo
The GenerateAssemblyInfo
target declares that it should be executed before BeforeCompile
and CoreCompile
, which will effectively hook itself and by extension GetAssemblyAttributes
into pretty much every build process.
The actual file output is written by the WriteCodeFragment
task called in the CoreGenerateAssemblyInfo
target.
We can confirm this by checking that our own custom attribute ended up together with all the default project attributes in obj\Debug\<tfm>\<assemblyname>.AssemblyInfo.cs
:
Can we rely on the SDK’s mechanism?
We can see from the Microsoft.NET.GenerateAssemblyInfo.targets
file that the GenerateAssemblyInfo
target is executed conditionally on the GenerateAssemblyInfo
property.
Also, that the property’s default value is true
for any SDK-style project (because it is not set anywhere else).
As long as the assembly attribute generation mechanism stays the same, the proposed method will work.
In general, the properties, items, targets and tasks that the MSBuild team considers implementation detail and which are thus not considered part of the SDK API are by convention named with an underscore prefix (e. g. _InformationalVersionContainsPlus
).
Everything else should be stable enough to rely on, including AssemblyAttribute
.
Only the item metadata _Parameter1
etc. has the leading underscore, so may be subject to change.
What are the limits?
Number of parameters
The first limit we have to note is the maximum number of parameters.
We can look at the WriteCodeFragment
task itself, which is part of MSBuild and can be found in the repo: https://github.com/dotnet/msbuild/blob/main/src/Tasks/WriteCodeFragment.cs.
The code loops over all the item metadata and parses the parameter index.
There is no restriction on their number.
However, Microsoft.NET.GenerateAssemblyInfo.targets
defines a target CreateGeneratedAssemblyInfoInputsCacheFile
in which only the first eight parameters are taken into account.
Without diving deeper into where this hash is used, we may be satisfied with the prospect of reliably being able to use at least eight parameters.
Type of parameters
The approach is also limited by what can be expressed as a property in MSBuild.
Only strings can be properties, so our attribute constructor parameters must be strings.
Attributes in C# are themselves limited in that they can only have compile-time constant constructor parameters.
We thus lose the primitive number types, booleans and enums on the C# side of things.
Ultimately, everything that is not natively a string, we have to serialize into a string property and then parse back in C# code.
But what about compile-time safety?
We have the compiler to avoid runtime errors in code.
We have MSBuild to warn us about potential build problems.
When using MSBuild tricks like this, we want to be sure to get a build-time error when something goes wrong.
We would not want to build successfully only to get a runtime exception because of something as trivial as a typo.
Because the generated attribute declaration will be compiled, we will get compiler errors when the number or type of constructor parameters is wrong.
But, since we can only have string parameters, in our Version
use-case we need to parse the string at some point.
Naturally, we will not be warned by MSBuild about a typo that would lead to a parsing error.
We can however use MSBuild’s static property functions to have our build already try and parse the value, thus fail when parsing fails:
<PropertyGroup> <__SomeVersion>1.2.3.4</__SomeVersion> <__VersionCheckDummy>$([System.Version]::Parse($(__SomeVersion)))</__VersionCheckDummy> </PropertyGroup>
The key here is to use the same method for parsing the value that the C# code uses, in this case Version.Parse
without any additional arguments.
You may now say: “but this is a second source of truth” and you would be right.
BUT: it is a second source of truth only to the validity rules of the value, not to the value itself.
As long as validity rules do not change, this duplication is not a problem.
The single source of truth for the value is preserved and now even type-safe.