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:

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:

  1. Identify all solutions whose dependencies are already available (either published previously or not produced by any project in the set).
  2. Those solutions can build in parallel because their dependencies are satisfied.
  3. Remove them from the unbuilt set.
  4. 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:

  1. Find the last git tag matching vX.Y.Z or vX.Y.Z-pre.N.

  2. 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
  3. 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 .ps1 implies 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 .json or .png counts as a minor. That is the right default for a library, where an embedded resource can be part of the surface.

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:

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:

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:

What I still find difficult:

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.

References