A moon 2.x toolchain WASM plugin for the .NET ecosystem — SDK-style C#, F#, and VB projects.
It gives a moon workspace a real understanding of your .NET projects: the project
graph is derived from actual MSBuild evaluation rather than XML parsing, so
Directory.Build.props chains, Central Package Management, SDK defaults, and
Conditions all resolve exactly as they do in a normal build.
- Project graph — moon dependencies inferred from
ProjectReferenceitems, project aliases fromAssemblyName. - Tasks —
build,test,run, andpublishcontributed automatically, so a zero-config workspace has working tasks (see Task inference). - Dependencies —
dotnet restoreas moon's install-dependencies action, with automatic--locked-modewhen a lock file is present, plus local tool restore. - Caching — task hashing from lock files or the evaluated package set, together
with every relevant
Directory.Build.*/Directory.Packages.props/nuget.config/global.jsonabove the project. - SDK installation — optional; installs the .NET SDK from
version:via the officialdotnet-installscripts. - Docker — restore-layer scaffold globs and
bin/objpruning.
All projects are evaluated in one batched MSBuild invocation — a generated traversal project fans out to every project with parallel in-process worker nodes — so MSBuild's startup cost is paid once per graph build instead of once per project (~11s vs ~3min for a 60-project workspace in local measurements). Any project missing from the batch output (a broken csproj, for example) falls back to individual evaluation automatically.
- moon 2.0 or newer.
- .NET SDK 8 or newer — dependency inference relies on MSBuild 17.8+
-getProperty/-getItemJSON output. The plugin can install the SDK for you; see SDK installation.
Add the toolchain to .moon/toolchains.yml:
dotnet:
plugin: 'github://Wtiben/moon-dotnet-plugin@v0.2.0'moon downloads the wasm from the GitHub release and caches it — there is nothing to install locally.
Projects need no moon.yml configuration when task inference is enabled (the
default) — language, toolchain, tasks, and dependencies are all inferred. Add
configuration only to override or extend what the plugin contributes:
language: 'csharp' # moon rejects 'c#'
toolchains:
default: 'dotnet'
tasks:
build: # a task with this id fully replaces the inferred one
command: 'dotnet build --no-restore -c Release'
inputs:
- '**/*.cs'
- '*.csproj'Side note — discovery is still moon's job, and moon has no plugin hook for it. moon only creates projects that
.moon/workspace.ymldeclares; a toolchain plugin cannot contribute projects, andprojects.globsonly match directories ormoon.ymlfiles — a glob like'src/**/*.csproj'is rejected (verified through moon 2.4.5: "Received a file path for a project root, must be a directory"). So for a repo with many projects you still need one of:
- Explicit entries or directory globs in
workspace.ymlcovering every project directory — then there are truly zeromoon.ymlfiles; or- One empty
moon.ymlstub per project directory plus a single glob like'src/**/moon.yml'— the stub only marks the directory as a project, and every piece of actual configuration is still inferred.Each moon project should be the directory that directly contains one
.csproj— the plugin deliberately does not search subdirectories, so mapping a whole multi-project "service" folder as one moon project yields no inference.
Solution files are never parsed — .sln/.slnx only act as dependency-root
markers.
All settings live under dotnet: in .moon/toolchains.yml.
| Setting | Type | Default | Description |
|---|---|---|---|
version |
string | — | .NET SDK version/channel to install during toolchain setup. Omit to use an existing SDK. |
inferDependencies |
bool | true |
Infer moon project dependencies from MSBuild ProjectReference items. |
inferTasks |
bool | list | true |
Infer build/test/run/publish. A list infers only the named tasks. |
restoreArgs |
list | [] |
Extra arguments appended to dotnet restore. |
dotnetRoot |
string | — | Explicit DOTNET_ROOT for task environments. Falls back to an existing DOTNET_ROOT, then ~/.dotnet when it holds a dotnet executable. |
msbuildProperties |
map | {} |
MSBuild properties passed as -p:NAME=VALUE to every evaluation behind inference. See Evaluation properties. |
inheritAliases |
bool | true |
moon-level setting; set to false to stop AssemblyName aliases from being registered. |
dotnet:
plugin: 'github://Wtiben/moon-dotnet-plugin@v0.2.0'
version: '8.0'
inferTasks: ['build', 'test']
restoreArgs: ['--no-cache']Inference evaluates each project with the SDK's default property values. When a reference or package is behind a condition, that default decides whether it lands in the graph:
<ProjectReference Include="..\Client\Client.csproj"
ReferenceOutputAssembly="false"
Condition="'$(SkipApiClientGen)' != 'true'" />Nothing sets SkipApiClientGen, so the condition is true and the edge is inferred
— even in a workspace whose real builds always set it. msbuildProperties lets
you evaluate the graph the way the code is actually built:
dotnet:
msbuildProperties:
SkipApiClientGen: 'true'These are command-line global properties, so they override values a project sets itself, and they apply to both batched and per-project evaluation. Changing them invalidates the evaluation cache.
Two things to keep in mind:
- Keep them consistent with how you build. Inferred
buildtasks pass--no-dependencies, so moon is the only thing ordering dependencies. Dropping an edge here drops that ordering, which is correct only if your real build also skips the work behind it. - They apply to evaluation, not to
dotnet restoreor task commands. APackageReferencegated on one of these properties is resolved for hashing but not for restore, so the two can disagree. Avoid gating packages on them.
On by default. Every dotnet project gets standard tasks derived from its real MSBuild evaluation — no per-project configuration needed.
This is deliberately more proactive than moon's built-in toolchains (the JavaScript
toolchain only mirrors user-declared package.json scripts, and opt-in at that).
.NET has no equivalent script layer to mirror: without inference a zero-config dotnet
workspace has no tasks at all, and build/test/run/publish are the toolchain's
own universal verbs rather than per-repo conventions. It stays safe to leave on
because inference never overrides anything you wrote yourself.
inferTasks is a workspace-level setting, so one line controls the whole
workspace and turning inference off never requires per-project overrides:
dotnet:
inferTasks: true # default: infer all four tasks
# inferTasks: false # infer nothing
# inferTasks: ['build', 'test'] # infer only these| Task | Inferred for | Command | Cached |
|---|---|---|---|
build |
every project | dotnet build --no-restore --no-dependencies -c <cfg> + deps: ['^:build'] |
✅ outputs from evaluated BaseOutputPath |
test |
a test project (see below) | dotnet test --no-build --no-restore -c <cfg> + deps: ['~:build'] — VSTest and Microsoft.Testing.Platform both supported |
✅ (pass/fail state) |
run |
Exe/WinExe, non-test |
dotnet run |
never cached, excluded from CI |
publish |
Exe/WinExe, non-test, single-TFM |
dotnet publish --no-build --no-restore -c <cfg> + deps: ['~:build'] |
✅ outputs from evaluated PublishDir |
A project counts as a test project when any of these hold, because no single signal covers the ecosystem:
IsTestProjectistrue. Set byMicrosoft.NET.Test.Sdk's build props, so it only appears once that package has been restored.IsTestingPlatformApplicationistrue. Set by test-oriented project SDKs such as<Project Sdk="MSTest.Sdk">without needing a restore.- A test package is referenced:
Microsoft.NET.Test.Sdk, thexunit.v3family,Microsoft.Testing.Platform*,MSTest,NUnit3TestAdapterorTUnit. Package references are visible without a restore, which is what a cold graph build sees.
Matching is exact or by prefix, never a substring, so Microsoft.AspNetCore.Mvc.Testing
and Microsoft.AspNetCore.TestHost do not qualify a project on their own. A project
that sets the properties to false (a BenchmarkDotNet host, typically) is excluded.
Design notes:
- moon orchestrates the graph, not MSBuild.
builduses--no-dependenciesand depends on^:build, so each project builds and caches independently. MSBuild resolvesProjectReferences from the upstreambinwithout rebuilding it, and moon re-runs downstream builds when an upstream project changes. - The configuration is pinned (
-c) to whatever the evaluation saw (Debug unless your props say otherwise). This is necessary becausedotnet publishdefaults to Release on .NET 8+ whilebuilddefaults to Debug, which would break--no-build. A repo that setsConfigurationinDirectory.Build.propsgets that configuration everywhere. For a one-off Release publish, define your own task. - Outputs come from evaluated paths, so redirected output locations (custom
BaseOutputPath, .NET 8UseArtifactsOutputunder the workspace root) cache correctly. If an output path resolves outside the workspace, the task runs uncached rather than caching the wrong directory. - Inputs exclude the evaluated output/intermediate dirs (
bin/objby default) — MSBuild mutatesobjon every build, so including it would make hashes unstable. restoreis deliberately not a task: moon models it as the install-dependencies action (with--locked-mode), which runs before tasks — hence--no-restoreeverywhere.- Your tasks always win. A task with the same id in a project's
moon.ymlfully replaces the inferred one, and ids defined in inherited task files (.moon/tasks.yml,.moon/tasks/**/*.yml) that can apply to dotnet projects are never inferred at all — moon would otherwise merge the two into a broken command. Files explicitly scoped to other toolchains/languages viainheritedBydon't suppress anything. Whenever an inherited file does suppress a task, the plugin logs which id and which file, so missing tasks are never a mystery. - Directories with several project files get the file passed explicitly
(
dotnet build App.csproj ...). Fortestthe flavour follows the runner: Microsoft.Testing.Platform takes the project through--projectand rejects a positional path, while classic VSTest mode rejects--project. MTP is detected from{"test": {"runner": "Microsoft.Testing.Platform"}}in the governingglobal.jsonor from a project's ownTestingPlatformDotnetTestSupport.
Not inferred: pack, watch, and clean; and multi-TFM projects get no publish
task, since dotnet publish needs an explicit -f there.
Because MSBuild evaluation is language-agnostic, .fsproj and .vbproj projects
are fully supported, including cross-language ProjectReferences. Central Package
Management (Directory.Packages.props + versionless PackageReference) is
supported too: pinned versions reach the task hash through the
Directory.Packages.props content hash.
Task hashes always include the contents of every Directory.Build.props,
Directory.Build.targets, Directory.Build.rsp, Directory.Packages.props,
nuget.config (any casing), and global.json from the project directory up to the
workspace root — changing any of them invalidates affected task caches even when the
package set is pinned by a lock file.
Important
Without a lock file, hashing is approximate. The package part of the hash is
computed from the declared/evaluated PackageReference set, so floating versions
(1.*) and unpinned transitive upgrades will not invalidate caches.
Commit packages.lock.json (generate it with dotnet restore --use-lock-file)
for exact hashing. The plugin then hashes the raw lock file content — which pins the
full resolved set including content hashes — and automatically passes --locked-mode
to dotnet restore, failing fast (NU1004) when the lock file drifts from the declared
dependencies. Renamed lock files following the packages.<project>.lock.json
convention (via NuGetLockFilePath) are recognized too.
Note that moon's install-dependencies action fingerprints the lock file plus the one
fixed-name .NET manifest, Directory.Packages.props (project files have variable
names, which moon's literal-name manifest matching cannot express). So a Central
Package Management version bump re-triggers installs, but editing a .csproj alone
does not until the lock file changes too — another reason to keep lock files committed
and current.
Setting version: under dotnet: makes moon install the SDK during toolchain setup,
using the official
dotnet-install scripts:
dotnet:
plugin: 'github://Wtiben/moon-dotnet-plugin@v0.2.0'
version: '8.0' # channel; or '8.0.404' (exact), 'lts', 'sts', 'preview'- Installs into
~/.dotnetby default (SDK versions lay out side-by-side), or intodotnetRootwhen configured — the same root the plugin injects asDOTNET_ROOT/PATH, so tasks find the installed SDK with no further wiring. Your shell profile is never touched (--no-path). - Version semantics pass through to the script:
X.Yinstalls the latest patch of that channel, fully-qualified versions install pinned (and short-circuit when that exact SDK is already present),lts/sts/previewmap to the named channels. global.jsonstays a runtime concern: the dotnet host picks the matching SDK out ofDOTNET_ROOTat execution time; setup does not parse it. Ifglobal.jsondemands a version that isn't installed,dotnetitself reports the error.
Without a version: setting, moon skips toolchain setup ("use globals") and the SDK is
expected from either an existing install in ~/.dotnet or a system dotnet on PATH.
When no DOTNET_ROOT candidate exists, no environment injection happens. Because
~/.dotnet doubles as the dotnet CLI's user-level cache directory, it only counts as a
DOTNET_ROOT when the dotnet executable actually exists at its root.
Each project gains its evaluated AssemblyName as a moon alias, so tasks and
commands can address it by its .NET name as well as its moon id — e.g.
moon run MyCompany.App:build for a project whose moon.yml id is app. Aliases also
drive moon docker prune's per-toolchain package targeting. moon silently ignores an
alias that collides with another project's id or alias (and an alias equal to its own id
is a no-op), so mixed workspaces cannot break on collisions. Set inheritAliases: false
to opt out.
When a tool manifest (.config/dotnet-tools.json) is found, dotnet tool restore runs
during moon's setup-environment action, before dependency installs. The manifest is
searched for from the dependencies root upward to the workspace root, matching how
the dotnet CLI resolves it — tool manifests conventionally sit at the repository root,
which is not necessarily a dependencies root (any project directory holding a lock file
becomes one). The restore is keyed on the manifest's content, so editing it re-runs the
restore and repeat runs skip it. Global tools are out of scope.
moon docker scaffold <project>: the configs phase copies exactly the restore-relevant files (*.{csproj,fsproj,vbproj}/*.{sln,slnx}/*.props/*.targets/Directory.Build.rsp/nuget.config/lock files/global.json), withbin/objexplicitly excluded (generatedobj/*.nuget.g.propsandobj/*.nuget.g.targetswould otherwise match). The sources phase copies full project sources by moon design.moon docker pruneremovesbin/objdirectories in the dependencies root and each focused project. NuGet's user-level cache is deliberately not touched: moon runs a production install after pruning, so clearing it would force a full re-download. Usedotnet nuget locals all --clearin your Dockerfile if you want that.- Add
.moon/cache(and ideally.moon/docker) to your.dockerignore.
Each of these was cloned unmodified, given a generated project map, and run with the released plugin. In every case the graph built with no errors and the number of moon projects matched the number of project files on disk exactly.
| 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 these cover Central Package Management, Microsoft.Testing.Platform and
classic VSTest, multi-targeted projects, custom MSBuild project SDKs (MSTest.Sdk,
Aspire.AppHost.Sdk), and global.json SDK pins using every rollForward mode.
Beyond building the graph:
moon run <project>:buildwas run for real in serilog, eShop, jellyfin and OrchardCore. All succeeded, and a second run was served from moon's cache.- Affected detection was checked in serilog: touching one file in
src/Serilogmarks all 6 projects affected through--downstream deep, using only inferred edges. That repository has nomoon.ymland no hand-writtendependsOnat all. - The warm timings are the evaluated-package-set cache doing its job. Nothing else changed between the two runs.
Two things worth knowing if you reproduce this. Every repository needed a generated
projects.sources map, because moon cannot glob project files (see the note under
Task inference). And moon disables affected checks entirely on a
shallow clone, so git clone --depth 1 will report nothing as affected.
- SDK-style projects only — no legacy csproj.
dotnetCLI only; no NuGet workloads and no global tools (local tool manifests are restored). - Multi-targeted projects (
<TargetFrameworks>) are evaluated as the outer (cross-targeting) build, where$(TargetFramework)is empty. References and packages gated on a specific TFM are therefore invisible to dependency inference and hashing; unconditional ones resolve normally. - Custom
<Import>s outside theDirectory.Build.*conventions affect the evaluated package set (captured in hashes), but their file contents are not themselves hashed — build behavior changes in such files won't invalidate caches. - No
sync_project— the plugin never writes<ProjectReference>entries into project files from moon's graph. The project files are the source of truth here and inference flows one way, out of MSBuild.
Issues and pull requests are welcome. To build and test locally you need a Rust
toolchain with the wasm32-wasip1 target and a .NET SDK 8+ on PATH (parts of the test
suite shell out to dotnet):
cargo build --target wasm32-wasip1 # build the wasm
cargo test --workspace --no-default-features # test (requires the wasm to be built first)
bash scripts/build-and-test.sh # or both at onceTo try a local build in a moon workspace, point the plugin locator at the built
artifact (relative to the .moon directory):
dotnet:
plugin: 'file://../../moon-dotnet-plugin/target/wasm32-wasip1/debug/dotnet_toolchain.wasm'