feat(dotnet): add .NET toolchain plugin - #169
Open
Wtiben wants to merge 2 commits into
Open
Conversation
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.
This was referenced Jul 26, 2026
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.
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? |
Author
|
@milesj I'll look into it when i have time |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
.csprojfile (or.fsproj,.vbproj). The filename is arbitrary and it usually sits one or two directories below anything you'd glob for.Directory.Build.propsandDirectory.Build.targetsare 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.propsis Central Package Management: package versions are declared centrally and project files then reference packages without a version. Similar idea to a pnpm catalog.global.jsonpins which SDK version a directory tree must build with, a bit like.prototoolsbut for the SDK, and it can also select the test runner.<TargetFrameworks>net8.0;net9.0</TargetFrameworks>).$(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
-getPropertyand-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/cachesohash_task_contentsreuses 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.
register_toolchain(the file types above),define_toolchain_config,initialize_toolchain,define_docker_metadata,prune_docker.locate_dependencies_root,install_dependencies(dotnet restore, with--locked-modewhen a NuGet lock file exists),setup_environment(dotnet tool restorefor repo-local CLI tools),extend_task_command(DOTNET_ROOT and PATH),extend_project_graph(dependency and task inference, plusAssemblyNameas a project alias),parse_lock,parse_manifest,hash_task_contents.setup_toolchain, installing the SDK via the official dotnet-install scripts whenversionis set.Task inference gives every project a
build, test projects atest, and executablesrunandpublish.buildpasses--no-dependenciesand depends on^:build, so moon owns the graph instead of MSBuild. Anything you define wins: a task with the same id in a project'smoon.ymlreplaces 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.
Between them they cover Central Package Management, both test runners in use today (Microsoft.Testing.Platform and classic VSTest, whose
dotnet testcommand lines are mutually incompatible), multi-targeted projects, custom project SDKs, andglobal.jsonpins across every roll-forward mode.moon run <project>:buildran for real in serilog, eShop, jellyfin and OrchardCore, and hit the cache on a second run. In serilog, touching one file undersrc/Serilogmarks all 6 projects affected through--downstream deepoff inferred edges alone. That repo has nomoon.ymland nodependsOnanywhere.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, sinceexec_commandisn't mocked in the sandbox. That's the one new CI requirement, so this addsactions/setup-dotnettoci.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, andprojects.globswon't match.csprojfiles, so every repo above needed a generatedprojects.sourcesmap, 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_ROOTpoints 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_dotnetlocator entry fortoolchains_config_ext.rsas a separate PR.