Build Infrastructure for 79 .NET Repositories with a Custom MSBuild SDK
My ktsu.dev ecosystem is over 79 independent .NET libraries and applications. Each one lives in its own repository with its own solution, CI pipeline, and NuGet package. Keeping all of them consistent, buildable, and correctly versioned is a real infrastructure problem, and at this scale the build system is where most of the actual engineering ends up.
This post covers the three pillars I built to hold it together: a custom MSBuild SDK that standardizes build configuration across every project, a dependency-aware build orchestrator that works out the correct build order, and an automated versioning system driven entirely by git history.
Configuration at 79x Scale
Without shared infrastructure, every project would need its own independently maintained copy of:
.csprojconfiguration (target frameworks, package metadata, analyzer settings)- CI/CD workflow definitions (build, test, pack, publish)
- Version calculation logic
- License, changelog, and metadata generation
At 79+ projects, copy-pasting this configuration isn't just tedious, it falls apart. A single change, like adding .NET 10 as a target framework, would mean 79 separate pull requests. Configuration drift between projects becomes inevitable, and debugging a build failure in one project means I first have to work out whether its configuration has drifted from the others.
Pillar 1: A Custom MSBuild SDK
My answer to configuration sprawl is a custom MSBuild SDK (ktsu.Sdk) that every project imports with a single line:
<Project Sdk="ktsu.Sdk">
<!-- No target frameworks, no metadata, no analyzer config needed. -->
</Project>That one import gives a project everything it needs to build, test, and publish correctly. Here is what the SDK handles.
Automatic Multi-Targeting
<!-- Sdk/Sdk.props -->
<PropertyGroup>
<TargetFrameworks>net10.0;net9.0;net8.0;net7.0;net6.0;net5.0;netstandard2.0;netstandard2.1</TargetFrameworks>
</PropertyGroup>Every library automatically targets all supported frameworks. Test projects override this to build against only the latest version, since there's no point running tests against every historical target:
<!-- Test projects are detected automatically and narrowed to a single target -->
<PropertyGroup Condition="'$(IsTestProject)' == 'true'">
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>Package Metadata from Markdown
Rather than embedding package metadata in XML inside each .csproj, the SDK reads it from plain markdown files at the solution root:
<!-- Sdk/Sdk.props — reads VERSION.md, DESCRIPTION.md, AUTHORS.md, TAGS.md -->
<PropertyGroup>
<Version>$([System.IO.File]::ReadAllText('$(SolutionDir)VERSION.md').Trim())</Version>
<Description>$([System.IO.File]::ReadAllText('$(SolutionDir)DESCRIPTION.md').Trim())</Description>
<Authors>$([System.IO.File]::ReadAllText('$(SolutionDir)AUTHORS.md').Trim())</Authors>
<PackageTags>$([System.IO.File]::ReadAllText('$(SolutionDir)TAGS.md').Trim())</PackageTags>
</PropertyGroup>This makes VERSION.md, DESCRIPTION.md, AUTHORS.md, and TAGS.md the single source of truth for package metadata. Updating a package description means editing a markdown file, with no XML involved and no risk of breaking the project file.
Sub-SDKs for Different Application Types
Libraries use the base ktsu.Sdk. GUI applications import ktsu.Sdk.App, which handles platform-specific output type selection:
<!-- Sdk.App/Sdk.props -->
<PropertyGroup>
<OutputType Condition="$([MSBuild]::IsOSPlatform('Windows'))">WinExe</OutputType>
<OutputType Condition="!$([MSBuild]::IsOSPlatform('Windows'))">Exe</OutputType>
</PropertyGroup>Console applications use ktsu.Sdk.ConsoleApp. Each sub-SDK handles the platform and project-type differences so that individual project files never have to worry about these details.
Pillar 2: Dependency-Aware Build Ordering
With 79+ projects that depend on each other through NuGet references, build order matters. If library A depends on library B, then B has to be built and published to NuGet before A can resolve its dependency during restore. Getting this order wrong produces cryptic restore failures.
The CrossRepoActions tool handles this with a topological sort over the dependency graph:
internal static Collection<Solution> SortSolutionsByDependencies(
ICollection<Solution> solutions)
{
var unsatisfied = solutions.ToCollection();
var sorted = new Collection<Solution>();
while (unsatisfied.Count != 0)
{
// Collect all packages produced by solutions that have not yet been built
var unsatisfiedPackages = unsatisfied
.SelectMany(s => s.Packages)
.ToCollection();
// A solution is "satisfied" when none of its dependencies are produced
// by any remaining unbuilt solution
var satisfied = unsatisfied
.Where(s => !s.Dependencies
.IntersectBy(unsatisfiedPackages.Select(p => p.Name), p => p.Name)
.Any())
.ToCollection();
foreach (var solution in satisfied)
{
unsatisfied.Remove(solution);
sorted.Add(solution);
}
}
return sorted;
}The algorithm works in layers:
- Identify all solutions whose dependencies are already available (either published previously or not produced by any project in the set).
- Those solutions can build in parallel because their dependencies are satisfied.
- Remove them from the unbuilt set.
- Repeat until every solution has been built.
The result is a layered build order. Foundational packages like Abstractions build first because they have no internal dependencies, followed by common implementations, then consumer libraries, and finally applications. This ordering is computed automatically from the actual dependency graph, so it adapts as projects add or remove dependencies.
Pillar 3: Git-Driven Versioning
Versions are calculated automatically from git history by make-version.ps1. There's no manual version bumping, no version file to edit, and no risk of forgetting to increment. The rules are:
Find the last git tag matching
vX.Y.ZorvX.Y.Z-pre.N.Scan all commits since that tag for version markers:
[major]in a commit message triggers a major version bump[minor]triggers a minor bump[patch]triggers a patch bump[pre]bumps the pre-release number
If no markers are found, the script infers from which files changed. It works by exclusion rather than by naming the interesting extensions:
- Any changed file that isn't hidden,
.md,.txt,.sln,.*proj,.url,Directory.Build.*,.github/workflows/*or.ps1implies a minor bump - Only files on that exclusion list changed implies a patch bump
- No substantive changes results in a pre-release bump
Working by exclusion means the list never has to be updated for a new source language, and it means a changed
.jsonor.pngcounts as a minor. That is the right default for a library, where an embedded resource can be part of the surface.- Any changed file that isn't hidden,
Version Markers in Practice
[major] Rename IProvider to IBaseProvider (breaking change)
[minor] Add async overloads to HashProvider
[patch] Fix null reference in FileSystemProvider
[pre] Experimental compression algorithm support
This convention keeps versioning decisions close to the code change that motivates them. A developer making a breaking API change just includes [major] in their commit message, and the pipeline handles the rest.
The Complete Release Pipeline
Push to main
-> make-version.ps1 calculates version from git history
-> make-changelog.ps1 generates CHANGELOG.md from commit messages
-> make-license.ps1 ensures LICENSE.md is current
-> commit-metadata.ps1 commits the generated files
-> dotnet build -> dotnet test -> dotnet pack
-> Publish to NuGet
-> Create GitHub release with version tag
After the initial push, the whole pipeline runs without intervention. No manual version bumping, no changelog editing, no release creation. Every step is deterministic and reproducible from the git history alone.
Cross-Repository Synchronization
With 79+ repositories, CI workflows and build scripts have to stay in sync. The SyncFileContents tool handles this by propagating template files from the SDK repository to all dependent repositories:
.github/workflows/project.yml, the standard build/test/publish workflow.github/workflows/dependabot-merge.yml, automatic merge for dependency update PRs- Common scripts (
make-version.ps1,make-changelog.ps1, and others)
When a template file changes in the SDK repository, SyncFileContents propagates the update to all 79+ repos. This is what turns "add .NET 10 targeting" from 79 pull requests into a single change.
The ProjectDirector Tool
Day to day, the thing that makes the ecosystem manageable is ProjectDirector, a Dear ImGui desktop application that treats many repositories as one surface.
It scans a development directory for local clones and lists them beside the remotes it can see through GitHub and Azure DevOps, so the first question it answers is which repositories exist and which are actually checked out on this machine. From there it does three things:
- Bulk fetch and pull across every repository, with per-repository status and timing rather than one spinner for the whole run.
- Cross-repository file diff, which takes a single file path and shows line by line how its contents differ across every repository that has that file.
- File propagation, which takes one repository's version of that file and copies it out to the others.
The last two are the pair that earns the tool, and they are the answer to the drift the SDK cannot reach. The SDK centralizes what inheritance can centralize. What is left over is the set of files that have to exist separately in every repository and be identical in every repository, and that is a diff-and-propagate problem rather than an inheritance one.
It drives the git command line directly rather than going through a library, which is deliberate. Shelling out means Git LFS, the platform credential helper, and whatever is in the local git config keep working exactly as they do in a terminal. The cost is parsing output instead of reading objects.
What it does not do matters too, because a tool with a repository list in it attracts assumptions. There is no build status and no pull request status. There is no dependency graph. It does not trigger builds and it does not update package versions. It is a fleet manager for repositories, and the building and versioning of that fleet live in the SDK and in the build tool instead.
What I've Learned
What works well:
- The custom SDK eliminates configuration drift entirely. Every project builds with identical settings unless it explicitly opts out.
- Git-based versioning produces deterministic, reproducible versions. Given the same git history, the same version is always computed.
- Topological build ordering handles arbitrarily complex dependency chains without manual intervention.
- Markdown-based metadata is easy to read, easy to edit, and impossible to accidentally malform in a way that breaks the project file.
What I still find difficult:
- MSBuild's incremental build caching can be aggressive. Changes to the SDK itself sometimes require
--no-incrementalbuilds to take effect. - Circular dependencies would break the topological sort, so they must be structurally prevented through careful API design.
- Multi-targeting across eight frameworks means build times are significant, particularly for CI runs.
- NuGet package propagation introduces latency. A change in a foundational package like
Abstractionscan trigger a cascade through 20+ dependent packages, each requiring its own build-test-publish cycle.
The main thing I've taken from this: at this scale, the build system is the product. The individual libraries are straightforward. The hard part is keeping 79+ of them consistent, correctly versioned, and building in the right order. Every hour I put into build infrastructure comes back across every later change to every project in the ecosystem.