Skip to content

feat(dotnet): add .NET toolchain plugin - #169

Open
Wtiben wants to merge 2 commits into
moonrepo:masterfrom
Wtiben:feat/dotnet-toolchain
Open

feat(dotnet): add .NET toolchain plugin#169
Wtiben wants to merge 2 commits into
moonrepo:masterfrom
Wtiben:feat/dotnet-toolchain

Conversation

@Wtiben

@Wtiben Wtiben commented Jul 26, 2026

Copy link
Copy Markdown

I saw moonrepo/moon#2447 and your note there that someone could contribute a .NET toolchain to this repo, so I put one together. Before the details: is this something you'd want in-tree? Happy to rework whatever you'd like, and fine either way if you'd rather it stayed outside.

I needed this for a .NET monorepo at work that shares a moon workspace with a pnpm frontend. I've tested it there and against the six public repos further down.

Some .NET background

You mentioned in #2447 that you're not a C#/.NET dev, so here's the bit the design decisions hang off. Skip if you already know it.

  • A project is a .csproj file (or .fsproj, .vbproj). The filename is arbitrary and it usually sits one or two directories below anything you'd glob for.
  • Directory.Build.props and Directory.Build.targets are implicitly imported into every project underneath the directory they live in. So a project file on its own doesn't tell you what that project references; the imports do too.
  • Directory.Packages.props is Central Package Management: package versions are declared centrally and project files then reference packages without a version. Similar idea to a pnpm catalog.
  • global.json pins which SDK version a directory tree must build with, a bit like .prototools but for the SDK, and it can also select the test runner.
  • A single project can target several framework versions at once (<TargetFrameworks>net8.0;net9.0</TargetFrameworks>).
  • References between projects can be written with MSBuild properties, for example $(SolutionDir)Common\Foo\Foo.csproj. The literal text in the file isn't a path you can resolve without evaluating it.

The repo I built this for has all six at once, which is where most of the design pressure came from.

Why MSBuild evaluation instead of parsing XML

So reading the project XML gets you a partial and sometimes wrong answer. You miss references that came from an import, you miss which packages a project actually has under Central Package Management, and property-based references come out as literal $(SolutionDir)... strings that match nothing.

The plugin asks MSBuild instead, through -getProperty and -getItem, which emit a project's evaluated properties and items as JSON. Everything resolves the way the SDK resolves it, and there's no XML parser in here to keep in sync with MSBuild.

The downside is that every evaluation is a process start, which isn't cheap. So the whole workspace is evaluated in one batched invocation instead, a generated traversal project that fans out to every project with parallel in-process MSBuild nodes. 238 projects takes 18 seconds cold. The results are cached under .moon/cache so hash_task_contents reuses them rather than evaluating again. Anything missing from the batch falls back to evaluating on its own, so one unloadable csproj can't sink the graph.

What it does

Tiers 1 through 3, for SDK-style projects.

  • tier1: register_toolchain (the file types above), define_toolchain_config, initialize_toolchain, define_docker_metadata, prune_docker.
  • tier2: locate_dependencies_root, install_dependencies (dotnet restore, with --locked-mode when a NuGet lock file exists), setup_environment (dotnet tool restore for repo-local CLI tools), extend_task_command (DOTNET_ROOT and PATH), extend_project_graph (dependency and task inference, plus AssemblyName as a project alias), parse_lock, parse_manifest, hash_task_contents.
  • tier3: setup_toolchain, installing the SDK via the official dotnet-install scripts when version is set.

Task inference gives every project a build, test projects a test, and executables run and publish. build passes --no-dependencies and depends on ^:build, so moon owns the graph instead of MSBuild. Anything you define wins: a task with the same id in a project's moon.yml replaces the inferred one, and ids coming from an applicable inherited task file are skipped entirely.

Repos I tested against

All large public .NET codebases, picked to spread across sizes and configuration styles: serilog is the de facto .NET logging library, Ocelot an API gateway, jellyfin a media server, OrchardCore a CMS, abp an application framework, and dotnet/eShop is Microsoft's own reference application. I cloned each one unmodified and generated a project map for it. The graph built clean on all six, with the moon project count matching the project files on disk.

Repository Projects Inferred edges Tasks inferred Cold graph Warm graph
serilog/serilog 6 6 6 build, 3 test, 1 run, 1 publish 8s 1s
ThreeMammals/Ocelot 21 30 21 build, 3 test, 15 run 6s 1s
dotnet/eShop 24 46 24 build, 5 test, 12 run, 10 publish 7s <1s
jellyfin/jellyfin 42 134 42 build, 16 test, 3 run, 3 publish 7s 1s
OrchardCMS/OrchardCore 238 1485 238 build, 4 test, 7 run, 3 publish 18s 1s
abpframework/abp 671 2374 671 build, 160 test, 65 run, 64 publish 43s 1s

Between them they cover Central Package Management, both test runners in use today (Microsoft.Testing.Platform and classic VSTest, whose dotnet test command lines are mutually incompatible), multi-targeted projects, custom project SDKs, and global.json pins across every roll-forward mode.

moon run <project>:build ran for real in serilog, eShop, jellyfin and OrchardCore, and hit the cache on a second run. In serilog, touching one file under src/Serilog marks all 6 projects affected through --downstream deep off inferred edges alone. That repo has no moon.yml and no dependsOn anywhere.

Tests

109, one ignored because it downloads a full SDK over the network. The command to run it is in a comment above it.

The integration tests evaluate their fixtures with a real dotnet msbuild, since exec_command isn't mocked in the sandbox. That's the one new CI requirement, so this adds actions/setup-dotnet to ci.yml. It needs SDK 8 or newer, since that's where MSBuild gained the JSON output this relies on.

Caveats

Project discovery is the real one. moon only creates projects declared in workspace.yml, plugins can't contribute projects, and projects.globs won't match .csproj files, so every repo above needed a generated projects.sources map, 671 entries for abp. Opened moonrepo/moon#2640 for that.

Multi-targeted projects get evaluated once, as the outer build across all their frameworks. MSBuild leaves the current framework empty there, so anything gated on one specific framework is invisible to inference. Unconditional references and packages resolve fine. It's also SDK-style projects only, not the pre-2017 format. Fuller notes and the remaining .NET specifics are in the README of the repo I developed it in: https://github.com/Wtiben/moon-dotnet-plugin

Notes

tier3 shells out to dotnet-install rather than going through a proto tool plugin. SDKs install side by side under one root that DOTNET_ROOT points at, and proto's per-version inventory doesn't model that. Can change it if you'd rather.

Tagged 0.1.0. I can send the unstable_dotnet locator entry for toolchains_config_ext.rs as a separate PR.

New `dotnet_toolchain` WASM plugin covering tiers 1 through 3 for SDK-style
C#, F# and VB projects.

- tier1: register_toolchain (csproj/fsproj/vbproj, sln/slnx, global.json,
  Directory.Build.*, Directory.Packages.props, nuget.config, packages.*.lock.json),
  define_toolchain_config, initialize_toolchain, define_docker_metadata with
  restore-layer scaffold globs, prune_docker.
- tier2: locate_dependencies_root (nearest solution, then lock file, then project
  file), install_dependencies (dotnet restore, with --locked-mode when a lock file
  is present), setup_environment (dotnet tool restore for local tool manifests),
  extend_task_command (DOTNET_ROOT and PATH), extend_project_graph (dependency and
  task inference, AssemblyName aliases), parse_lock, parse_manifest,
  hash_task_contents.
- tier3: setup_toolchain, installing the SDK via the official dotnet-install
  scripts when `version` is configured.

Dependencies and tasks come from a real MSBuild evaluation rather than from
parsing project XML, so Directory.Build.targets imports, MSBuild properties such
as $(SolutionDir), conditional references and Central Package Management resolve
the way the SDK resolves them. There is no parser to maintain. Every project in
the workspace is evaluated in one batched traversal invocation rather than one
process per project, and the evaluated package sets are cached on disk so task
hashing reuses them instead of re-evaluating.

Registers dotnet-toolchain in .moon/workspace.yml, and adds actions/setup-dotnet
to CI because the integration tests evaluate their fixtures with a real dotnet
msbuild.
Evaluation runs with the SDK's default property values, so a reference or
package behind a condition lands in the graph even in a workspace whose real
builds never enable it. `msbuildProperties` sets MSBuild global properties for
evaluation only, applied to both the batched traversal and the per-project
fallback.

The properties form part of the evaluation cache digest, because a conditional
PackageReference resolves differently under different values and a cached
package set must not be served across configurations. Inferred task commands
and `dotnet restore` do not receive them, so `moon run` builds stay exactly
what the project defines.
@milesj

milesj commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

@Wtiben Before I review this in the context of moon, is it possible to create a separate proto tool so that .NET can be installed within proto as well?

@Wtiben

Wtiben commented Aug 4, 2026

Copy link
Copy Markdown
Author

@milesj I'll look into it when i have time

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants