Manging repository standards using MSBuild and NuGet

At Particular Software, we do have quite a few repositories to maintain. When writing this, there are about ~75 public repositories (that are not archived) in our organization. Using common repository settings and style checks helps to ensure that the same coding guidelines and practices apply to every repository. But these standards also change, evolve, or need updates.

We have a standards repository that acts as our baseline, basically like a repository template. The repository contains all the essential files that we want to share across all code repositories, and we make changes to the standard settings here. It contains some pretty common files like:

We use Azure Functions to sync these files on a schedule to every repository that contains a .reposync.yml file. An Azure Function using timed triggers checks all repositories that need to be updated. Because we have quite a few repositories to update, the synchronization work is handled separately. For every identified repository, the function uses NServiceBus to send a command via Azure ServiceBus (using NServiceBus for Azure Functions) to a message handler that will update a specific repository. For updates, we use the GitHubSync open-source project from @SimonCropp that takes care of the details. The .reposync.yml isn’t just an empty marker file. It can also be used to exclude specific files from being synchronized if a repository needs to override the default or doesn’t want this file. The content of a .reposync file might look like this (although most of them are empty):

exclusions:
- LICENSE.md
- src/NServiceBus.snk
- .github/workflows/virus-scan.yml

This is an example customization of the .reposync.yml file for our Particular.Packaging reposoitory. Since this package is under MIT license, it doesn’t use our standard license file. It’s also not strong-named and doesn’t produce binary outputs (it only contains special msbuild .props and .targets files), so it doesn’t require the certificate or the virus scanning workflow. File synchronization is very useful and makes managing shared standards a lot easier.

Let’s look at the Directory.Build.props file because this is where the magic for our C# projects (the majority of our codebase) comes from. Directory.Build.props is a particular file to customize the build process in MSBuild. It will be automatically picked up if there is such a file inside your folder hierarchy based on the built project file (a targets file also behaves very similar). We can drop this file into our repository via the syncing process. This allows controlling the build behavior for every repository from a central file. Let’s look at some interesting parts of that file (check out the whole file if you’re interested in every single detail):

<Import Project="Custom.Build.props" Condition="Exists('Custom.Build.props')" />

This import happens first, allowing further customization or overrides in a specific repository. Instead of excluding the Directory.Build.props in the .reposync file and copying the file over manually, create a Custom.Build.props next to the Directory.Build.props, and it will be run before the following parts in this post.

<ItemGroup>
    <PackageReference Include="Particular.Analyzers" Version="$(ParticularAnalyzersVersion)" PrivateAssets="All" />
</ItemGroup>

A neat feature that allows defining NuGet package references. As you can see, we have a package reference to the Particular.Analyzers package. The Particular.Analyzers package contains code analyzers that detect specific code patterns. This allows us to enforce fairly sophisticated coding guidelines (e.g., handling cancellation tokens, tasks, or dates) and even provide code fixes supported directly by the IDE.

image showing a code analyzer warning

The image shows one of our custom analyzers complaining about implicitly casting a DateTime to a DateTimeOffset.

Note: An important detail when using Directory.Build.props files is that the file detection stops at the first file found. Therefore, nested Directory.Build.props do not work out of the box but you can work around this by specifying <Import Project="$([MSBuild]::GetPathOfFileAbove($(MSBuildThisFile), $(MSBuildThisFileDirectory)..))" Condition="Exists($([MSBuild]::GetPathOfFileAbove($(MSBuildThisFile), $(MSBuildThisFileDirectory)..)))" /> on the nested file. The analyzer repository makes use of this because it registers a custom Directory.Build.props file to remove the default dependency on the Particular.Analyzers package for itself.

There are also these settings we can briefly look into:

<PropertyGroup>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <EnableNETAnalyzers>true</EnableNETAnalyzers>
    <AnalysisLevel>5.0</AnalysisLevel>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
    <!-- To lock the version of Particular.Analyzers, for example, in a release branch, set this property in Custom.Build.props -->
    <ParticularAnalyzersVersion Condition="'$(ParticularAnalyzersVersion)' == ''">1.8.0</ParticularAnalyzersVersion>
    <NServiceBusKey>0024...3b92</NServiceBusKey>
    <NServiceBusTestsKey>0024...b1c5</NServiceBusTestsKey>
</PropertyGroup>

Those are a bunch of code-style-related settings we can skim over quickly:

<InternalsVisibleTo Include="NServiceBus.Core.Tests" Key="$(NServiceBusTestsKey)" />

This post is meant to be a brief overview of how we distribute and maintain repository standards and coding standards across many repositories with little effort and quite powerful capabilities like code analyzers. Thanks to a simple file-syncing mechanism and the Directory.Build.props file, we can control every aspect from development to build via a central repository.

a diagram of showing how shared files are distributed across repositories