Building Projects
Overview
During .NET modernization, the choice of build tool is not trivial. dotnet build and msbuild.exe
are not interchangeable — they have different capabilities, different SDK resolution paths, and
different behavior with legacy project features. This skill encodes the expert decision logic so
the agent picks the right tool on the first attempt and recovers intelligently when a build fails.
Related Skills
| Action | Skill |
|---|---|
| Convert legacy project files to SDK-style format | converting-to-sdk-style |
| Add/update/remove target frameworks | managing-target-frameworks |
| Add/update package references or resolve NuGet version conflicts | managing-package-references |
| Modify project properties | modifying-project-properties |
Defer to these skills for project format conversion, TFM changes, package management, and property edits.
Repo-Level Build Skills Take Priority
If the repository contains a custom build skill (e.g., in .github/skills/, .copilot/skills/,
or a project-level BUILD.md with build instructions), defer to that skill for project-specific
build decisions. This skill provides general .NET modernization build logic and should serve as
a fallback when no repo-specific guidance exists.
IDE Build Tools Take Preference When Available
If the agent is running inside an IDE (Visual Studio, VS Code, Rider) that exposes build-related
tools — such as build_project, build_solution or similar MCP/Copilot tools —
prefer those over shelling out to the command line. IDE build tools respect the user's local
configuration and surface diagnostics where the user can click-to-navigate.
Fall back to this skill's CLI patterns when the IDE doesn't expose a build tool, the IDE build
tool fails with insufficient error output, the agent needs fine-grained control (targeting a
specific TFM, adjusting verbosity, choosing between dotnet build and msbuild.exe), or the
agent needs to capture structured build output for automated validation.
Workflow
Track progress using this checklist:
Task Progress:
- [ ] Step 1: Determine the right build tool
- [ ] Step 2: Execute the build
- [ ] Step 3: Diagnose and recover from failures (if any)
- [ ] Step 4: Validate the build output
Step 1: Determine the Right Build Tool
Default: Start with dotnet build
For SDK-style projects targeting modern .NET only, dotnet build is the correct default. It
ships with the .NET SDK, handles NuGet restore implicitly, and works cross-platform.
When to switch to msbuild.exe
Escalate to the full Visual Studio MSBuild (msbuild.exe) when ANY of these conditions apply:
The project has
.resxfiles containing images, icons, or binary resources.dotnet builduses a portable resource generator that cannot process embedded images. Symptom:MSB3086,MSB3552, orLC.exeerrors.The project is WPF or targets
net*-windowswith XAML. WPF's markup compiler requires the full Windows SDK targets that only ship with VS MSBuild. Symptom:MC1000,XDG0008, or missingMicrosoft.WinFX.targets.The project is WinForms with designer-generated resources. The
ResGen.exethat ships with the .NET SDK may lack support for System.Drawing types referenced in.resxfiles.The project multi-targets and includes a
net472(or anynetXXX<=net48) TFM. Building classic .NET Framework TFMs requires reference assemblies and the full MSBuild toolset.dotnet buildcan handle this IFMicrosoft.NETFramework.ReferenceAssembliesis in the project orDirectory.Build.props, but frequently fails withNETSDK1005.The project uses COM references, VSIX packaging, or T4 templates. These rely on VS-specific targets/tasks that the .NET SDK MSBuild doesn't include.
The solution contains C++/CLI (
vcxproj) projects. The .NET CLI cannot build these at all.Legacy (non-SDK-style) project file that hasn't been converted yet. If the project still uses
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets">rather than<Project Sdk="Microsoft.NET.Sdk">, onlymsbuild.exewill work.
Build tool decision guide
- Is the project file SDK-style?
- No → use
msbuild.exe(full VS). Stop. - Yes → does it target any net4xx TFM?
- Yes → is
Microsoft.NETFramework.ReferenceAssembliespresent?- Yes → try
dotnet buildfirst, fall back tomsbuild.exeon failure. - No → use
msbuild.exe(or add the ReferenceAssemblies package first).
- Yes → try
- No → use
msbuild.exeif ANY of the following apply, otherwise usedotnet build:.resxwith embedded images/icons- WPF / has XAML pages
- COM references / VSIX / T4 / vcxproj
- Yes → is
- No → use
Step 2: Execute the Build
Always Restore During Migration
During modernization, NuGet packages change constantly — versions get bumped, packages get
swapped, new ones added, old ones removed. A stale obj/project.assets.json is one of the
most common causes of phantom build failures.
Default behavior: always let restore run.
- For
dotnet build: restore runs by default — do not add--no-restoreduring migration. Package references change frequently, and skipping restore causes phantom build failures. - For
msbuild.exe: usemsbuild /restore(or/r) to run NuGet restore before building. This is especially important when already usingmsbuild.exefor the build — mixingdotnet restoreas a separate step withmsbuild /buildcan cause resolver mismatches.
When restore can be skipped: Only when the current migration step modified nothing but
.cs files — no *proj, Directory.Build.props, Directory.Packages.props, or
nuget.config was touched. If in doubt, restore. The cost is a few seconds; the cost of
a stale restore is a misleading build result.
Build Scope: Targeted vs Full Solution
Building the entire solution on every change is too slow for iterative migration work. Use two tiers:
Targeted project build (default during iterative work): When the agent modifies a project or its files, build only that specific project:
dotnet build <specific-project.csproj> -r
msbuild.exe <specific-project.csproj> /restore /t:Build /p:Configuration=Release
This gives fast feedback (seconds, not minutes) on whether the migration change compiled. The agent is iterating — it needs signal on its change, not the entire dependency graph.
Full solution build (final validation only): Build the entire solution when:
- The agent is about to mark a task as complete
- A batch of related migration tasks has finished
- The migration step changed something that could ripple across projects (shared interfaces, public API changes, package updates consumed by multiple projects)
dotnet build <solution.sln> -r
msbuild.exe <solution.sln> /restore /t:Build /p:Configuration=Release
Do NOT default to solution builds for every change. Reserve them as a gate check.
Build Patterns
Simple SDK build (happy path):
dotnet build <project.csproj> -r
MSBuild build:
msbuild.exe <project.csproj> /restore /t:Build /p:Configuration=Release /v:minimal
Clean build (when incremental build is suspect):
dotnet build <project.csproj> --no-incremental
Or for MSBuild:
msbuild.exe <project.csproj> /restore /t:Clean;Build /p:Configuration=Release
Debugging restore failures in isolation: When restore itself needs diagnosing, run it separately:
dotnet restore <project.csproj> --verbosity detailed
Then build without restore to isolate the compilation phase:
dotnet build <project.csproj> --no-restore
This is a diagnostic pattern only — not the default workflow.
Caching Build Tool Decisions
After determining the right build tool for a project, save the decision to
scenario-instructions.md so it doesn't need to be re-derived on every build.
Add or update a ## Build Tool Decisions section:
## Build Tool Decisions
- **MyWebApp.csproj**: msbuild.exe (non-SDK-style, System.Web references)
- **Common.csproj**: dotnet build (SDK-style, no special requirements)
- **Tests.csproj**: dotnet build (SDK-style after conversion)
On subsequent builds, check this section first:
- If the project is listed → use the cached decision
- If not listed or the project changed → re-read this skill and determine the right tool
Step 3: Diagnose and Recover from Failures
When a build fails, analyze the error codes and take targeted action rather than blindly retrying.
Load ref/error-codes.md for the full error catalog with specific recovery actions per error code.
Key recovery principles:
- Capture full build output (use
-v:detailedor/v:diagon retry for more info) - Parse for specific error codes, not just "build failed"
- Distinguish between compilation errors (CS*), MSBuild errors (MSB*), SDK errors (NETSDK*), and NuGet errors (NU*)
- Report which project within a solution failed (not just "the solution failed")
Do NOT:
- Retry the same command more than once without changing something
- Switch tools without understanding why the first tool failed
- Suppress warnings with
/nowarnwithout the user's explicit approval - Downgrade
TreatWarningsAsErrorswithout flagging it
Step 4: Validate the Build Output
After a successful build during migration, verify:
- All warnings fixed — fix all warnings in the projects being built, not just new ones introduced by this step
- Output assembly exists in the expected
bin/path - Target framework is correct — the output folder should match the target TFM (e.g.,
net10.0/), not the old TFM - No implicit fallbacks — if the project was supposed to drop a TFM, confirm it's gone
Locating MSBuild
When the decision is msbuild.exe, the agent must locate it reliably.
On Windows
Write every command on one line, and never assume a shell. The commands below run in the user's shell, which is frequently Git Bash or WSL rather than PowerShell or cmd.
%VAR%stays a literal string under bash, a trailing^is not a line continuation, and a trailing backtick opens command substitution — see Shell portability below before adapting any of these.
Priority 1 — VSINSTALLDIR environment variable:
If the agent is running inside Visual Studio or was launched from a VS Developer Command
Prompt, the VSINSTALLDIR environment variable is already set and points to the correct
VS installation. Read it in a way the active shell understands:
| Shell | How to read it |
|---|---|
| cmd | "%VSINSTALLDIR%\MSBuild\Current\Bin\MSBuild.exe" |
| PowerShell | & "$env:VSINSTALLDIR\MSBuild\Current\Bin\MSBuild.exe" |
| bash | "$VSINSTALLDIR/MSBuild/Current/Bin/MSBuild.exe" |
This is the preferred approach — it matches the user's active VS context and avoids picking a different installation than the one they're working with. If the variable expands to nothing (it is unset outside a VS context), fall through to Priority 2 rather than building a path from an empty value.
Priority 2 — vswhere.exe:
If VSINSTALLDIR is not set (agent running outside VS), use vswhere.exe. Keep it on one
line — no ^, no backtick, no backslash continuations — and pick the form for your shell.
A quoted path is not a command in PowerShell: it is just a string expression, so the next
token fails with Unexpected token '-latest'. PowerShell needs the call operator &:
| Shell | Command |
|---|---|
| PowerShell | & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe |
| cmd | "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe |
| bash | "/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild/**/Bin/MSBuild.exe |
The same & rule applies to any quoted executable path you invoke from PowerShell, including
the powershell -File form used elsewhere in these skills.
Priority 3 — well-known paths:
If neither is available, fall back to well-known paths under Program Files.
Shell portability (read before writing any command)
The execute tool runs in the shell the user configured, and nothing detects which one
it is. A command written for one shell fails — sometimes silently — in another.
| Construct | cmd | PowerShell | bash (Git Bash / WSL) |
|---|---|---|---|
%VAR% |
expands | literal | literal |
$env:VAR |
literal | expands | literal |
$VAR |
literal | expands to a PowerShell variable — empty unless you set one (env vars need $env:VAR) |
expands |
^ at end of line |
continuation | literal | not a continuation — command fragments |
` at end of line |
literal | continuation | opens command substitution |
Note the $VAR row: in PowerShell it does not stay literal, and it does not read the
environment. It resolves to an unset PowerShell variable and expands to nothing, so
$VSINSTALLDIR/MSBuild/... silently becomes /MSBuild/... — a broken path with no error,
which is the failure this section exists to prevent.
The backtick case is the dangerous one and it is parity-dependent:
- An odd number of trailing backticks fails loudly —
unexpected EOF while looking for matching, exit 2. - An even number pairs up, the arguments run as commands, the intended program never runs, and the shell exits 0. A build/test verdict that trusts the exit code will report success for work that never happened.
Rules:
- Write every command on a single line. If it is too long to read, keep it long — a wrapped command that breaks in the user's shell is worse than an unwrapped one.
- To run a shipped
.ps1, invoke PowerShell explicitly rather than relying on the shell to execute it by path — this works from any shell:powershell -NoProfile -ExecutionPolicy Bypass -File <script.ps1> -Arg value(usepwshinstead ofpowershellon non-Windows hosts). - Never leave a command running in the background or without captured output. A child that
inherits stdin (for example
powershell -Command -) blocks forever emitting nothing, and there is no timeout that will rescue it.
On CI / Linux / macOS
Full msbuild.exe is generally not available outside Windows+VS. If the project requires it,
flag it as a Windows-only build requirement or suggest restructuring to remove the dependency
(e.g., convert .resx to use LogicalName with precompiled resources, or extract the WPF layer).
Multi-Targeting Considerations
Projects that multi-target (e.g., <TargetFrameworks>net472;net10.0</TargetFrameworks>) during
a transitional migration phase need special handling:
dotnet buildbuilds ALL TFMs by default. To build just one:dotnet build -f net10.0Build the NEW target first to validate migration changes, then optionally build the old target to confirm backward compatibility.
If the old TFM build is no longer needed (migration complete), recommend removing it from
<TargetFrameworks>— defer to the managing-target-frameworks skill for the actual edit.
Anti-Patterns
- ❌ Retrying the same build command without changing anything — wastes time and produces identical errors
- ❌ Switching from
dotnet buildtomsbuild.exe(or vice versa) without understanding the root cause of the failure - ❌ Suppressing warnings with
/nowarnor downgradingTreatWarningsAsErrorswithout explicit user approval - ❌ Batching all build validation at the end of a migration — invoke this skill after EVERY discrete change that needs build validation; catching failures early keeps the error surface small
- ❌ Running a full solution build when only one project changed — use targeted project builds for faster feedback
- ❌ Ignoring which project within a solution failed — always report the specific project name
Success Criteria
- Correct build tool selected based on project characteristics (SDK-style, TFMs, resource types)
- Build executed with appropriate configuration and verbosity
- Build errors diagnosed by error code category (CS*, MSB*, NETSDK*, NU*)
- Recovery actions targeted to root cause, not blind retries
- Output assembly exists in the correct TFM-specific
bin/path - All warnings fixed in touched projects — not just new ones, all of them
- Multi-target builds validated per-TFM when applicable