
Dotnet Tooling
- 94 installs
- 228 repo stars
- Updated August 3, 2026
- novotnyllc/dotnet-artisan
Helps with ai & agent building tasks.
About
dotnet-tooling is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- dotnet-tooling
- AI & Agent Building
- AI-coding skill
Dotnet Tooling by the numbers
- 94 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,644 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/novotnyllc/dotnet-artisan --skill dotnet-toolingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 94 |
|---|---|
| repo stars | ★ 228 |
| Last updated | August 3, 2026 |
| Repository | novotnyllc/dotnet-artisan ↗ |
What it does
Helps with ai & agent building tasks.
Files
dotnet-tooling
Overview
.NET project setup, build systems, performance, CLI apps, and developer tooling. This consolidated skill spans 34 topic areas. Load the appropriate companion file from references/ based on the routing table below.
Routing Table
| Topic | Keywords | Description | Companion File |
|---|---|---|---|
| Project structure | solution, .slnx, CPM, analyzers | .slnx, Directory.Build.props, CPM, analyzers | references/project-structure.md |
| Scaffold project | dotnet new, CPM, SourceLink, editorconfig | dotnet new with CPM, analyzers, editorconfig, SourceLink | references/scaffold-project.md |
| Csproj reading | PropertyGroup, ItemGroup, CPM, props | SDK-style .csproj, PropertyGroup, ItemGroup, CPM | references/csproj-reading.md |
| MSBuild authoring | targets, props, conditions, Directory.Build | Targets, props, conditions, Directory.Build patterns | references/msbuild-authoring.md |
| MSBuild tasks | ITask, ToolTask, inline tasks, UsingTask | ITask, ToolTask, IIncrementalTask, inline tasks | references/msbuild-tasks.md |
| Build analysis | MSBuild output, NuGet errors, analyzer warnings | MSBuild output, NuGet errors, analyzer warnings | references/build-analysis.md |
| Build optimization | slow builds, binary logs, parallel, restore | Slow builds, binary logs, parallel, restore | references/build-optimization.md |
| Artifacts output | UseArtifactsOutput, ArtifactsPath, CI/Docker | UseArtifactsOutput, ArtifactsPath, CI/Docker impact | references/artifacts-output.md |
| Multi-targeting | multiple TFMs, polyfills, conditional compilation | Multiple TFMs, PolySharp, conditional compilation | references/multi-targeting.md |
| Performance patterns | Span, ArrayPool, ref struct, sealed, stackalloc | Span, ArrayPool, ref struct, sealed, stackalloc | references/performance-patterns.md |
| Profiling | dotnet-counters, dotnet-trace, flame graphs | dotnet-counters, dotnet-trace, dotnet-dump, flame graphs | references/profiling.md |
| Native AOT | PublishAot, ILLink, P/Invoke, size optimization | PublishAot, ILLink descriptors, P/Invoke, size optimization | references/native-aot.md |
| AOT architecture | source gen, AOT-safe DI, serialization | Source gen over reflection, AOT-safe DI, factories | references/aot-architecture.md |
| Trimming | annotations, ILLink, IL2xxx warnings, IsTrimmable | Annotations, ILLink, IL2xxx warnings, IsTrimmable | references/trimming.md |
| GC/memory | GC modes, LOH/POH, Span/Memory, ArrayPool | GC modes, LOH/POH, Gen0/1/2, Span/Memory, ArrayPool | references/gc-memory.md |
| CLI architecture | command/handler/service, clig.dev, exit codes | Command/handler/service, clig.dev, exit codes | references/cli-architecture.md |
| System.CommandLine | RootCommand, Option<T>, SetAction, parsing | System.CommandLine 2.0, RootCommand, Option<T> | references/system-commandline.md |
| Spectre.Console | tables, trees, progress, prompts, live displays | Tables, trees, progress, prompts, live displays | references/spectre-console.md |
| Terminal.Gui | views, layout, menus, dialogs, bindings, themes | Terminal.Gui v2, views, layout, menus, dialogs | references/terminal-gui.md |
| CLI distribution | AOT vs framework-dependent, RID matrix, Homebrew, winget, Scoop, dotnet tool | Distribution strategy, single-file publish, per-platform packaging | references/cli-distribution.md |
| CLI release pipeline | GHA build matrix, artifact staging, checksums | GHA build matrix, artifact staging, checksums | references/cli-release-pipeline.md |
| Documentation strategy | Starlight, Docusaurus, DocFX decision tree | Starlight, Docusaurus, DocFX decision tree | references/documentation-strategy.md |
| Tool management | global, local, manifests, restore, pinning | Global/local tools, manifests, restore, pinning | references/tool-management.md |
| Version detection | TFM/SDK from .csproj, global.json | TFM/SDK from .csproj, global.json, Directory.Build | references/version-detection.md |
| Version upgrade | LTS-to-LTS, staged, preview, upgrade paths | LTS-to-LTS, staged through STS, preview paths | references/version-upgrade.md |
| Solution navigation | entry points, .sln/.slnx, dependency graphs | Entry points, .sln/.slnx, dependency graphs | references/solution-navigation.md |
| Project analysis | solution layout, build config analysis | Solution layout, build config, .csproj analysis | references/project-analysis.md |
| Modernize | outdated TFMs, deprecated packages, patterns | Outdated TFMs, deprecated packages, superseded patterns | references/modernize.md |
| Add analyzers | nullable, trimming, AOT compat, severity config | Nullable, trimming, AOT compat analyzers, severity | references/add-analyzers.md |
| SDK installation | install .NET, dotnet-install, workloads, missing SDK | .NET SDK install script, workloads, env vars, side-by-side | references/dotnet-sdk-install.md |
| ILSpy decompile | ilspycmd, decompile, assembly, disassemble, IL | ILSpy/ilspycmd decompilation, type listing, IL view | references/ilspy-decompile.md |
| Mermaid diagrams | architecture, sequence, class, ER, flowcharts | Architecture, sequence, class, deployment, ER diagrams | references/mermaid-diagrams.md |
| VS Code debugging | launch.json, tasks.json, coreclr, attach, debug | VS Code launch/attach configs, tasks, multi-project, hot reload | references/vscode-debug.md |
| C# LSP | language server, csharp-ls, OmniSharp, go to definition | C# LSP servers, code navigation, agent usage patterns | references/csharp-lsp.md |
Scope
- Solution structure and project scaffolding
- MSBuild authoring and build optimization
- Performance patterns and profiling
- Native AOT, trimming, and GC tuning
- CLI app development (System.CommandLine, Spectre.Console, Terminal.Gui)
- Documentation generation (DocFX)
- Tool management and version detection/upgrade
- Solution navigation and project analysis
- Code modernization and analyzer configuration
- Mermaid diagram generation
- VS Code debug configuration (launch.json, tasks.json, coreclr)
- C# LSP servers (csharp-ls, OmniSharp) for agent code navigation
Out of scope
- Crash dump analysis, hang/deadlock triage, live debugger attach -> [skill:dotnet-debugging]
- Web API patterns -> [skill:dotnet-api]
- Test authoring -> [skill:dotnet-testing]
- CI/CD pipelines -> [skill:dotnet-devops]
- C# language patterns -> [skill:dotnet-csharp]
- UI framework development -> [skill:dotnet-ui]
Scripts
scripts/scan-dotnet-targets.py-- Scan repository for .NET TFM and SDK version signals
interface:
display_name: "dotnet-tooling"
short_description: "MSBuild, project setup, AOT, and diagnostics"
default_prompt: "Use $dotnet-advisor to route this build/tooling task, then load $dotnet-tooling for version and MSBuild guidance."
policy:
allow_implicit_invocation: true
Add Analyzers
Add and configure .NET code analyzers to an existing project. Covers built-in Roslyn CA rules, nullable reference types enforcement, trimming/AOT compatibility analyzers, and third-party analyzer packages.
Prerequisites: Run references/version-detection.md first — analyzer features vary by SDK version. Run references/project-analysis.md to understand the current project layout.
Built-in Roslyn Analyzers
.NET SDK ships built-in analyzers controlled by AnalysisLevel. Configure in Directory.Build.props:
<PropertyGroup>
<AnalysisLevel>latest-all</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>AnalysisLevel Values
| Value | Behavior |
|---|---|
latest | Default rules only — covers correctness, not style |
latest-minimum | Fewer rules than default |
latest-recommended | Default + additional recommended rules |
latest-all | All rules enabled — most comprehensive |
9-all, 10-all | Pin to a specific SDK version's full rule set |
latest-all is recommended for new projects. For existing projects with many warnings, start with latest-recommended and tighten over time.
Rule Categories
| Category | Prefix | Examples |
|---|---|---|
| Design | CA1xxx | CA1002 (don't expose generic lists), CA1062 (validate arguments) |
| Globalization | CA1300–CA1399 | CA1304 (specify CultureInfo) |
| Performance | CA1800–CA1899 | CA1822 (mark members static), CA1848 (use LoggerMessage) |
| Reliability | CA2000–CA2099 | CA2000 (dispose objects), CA2007 (ConfigureAwait) |
| Security | CA2100–CA2199, CA3xxx, CA5xxx | CA2100 (SQL injection), CA3075 (XML processing) |
| Usage | CA2200–CA2299 | CA2211 (non-constant static fields), CA2245 (don't assign to self) |
| Naming | CA1700–CA1799 | CA1707 (no underscores in identifiers) |
| Style | IDE0xxx | IDE0003 (this qualification), IDE0063 (using declaration) |
EditorConfig Severity Overrides
Fine-tune analyzer severity per-rule in .editorconfig:
[*.cs]
# Suppress specific rules
dotnet_diagnostic.CA1062.severity = none # Nullable handles this
dotnet_diagnostic.CA2007.severity = none # Not needed in ASP.NET Core apps
# Escalate to error
dotnet_diagnostic.CA1822.severity = error # Mark members as static
dotnet_diagnostic.CA1848.severity = warning # Use LoggerMessage delegates
# Style enforcement
dotnet_diagnostic.IDE0005.severity = warning # Remove unnecessary usings
dotnet_diagnostic.IDE0063.severity = warning # Use simple using statement
dotnet_diagnostic.IDE0090.severity = warning # Simplify new expressionCommon Suppressions by Project Type
ASP.NET Core apps — suppress ConfigureAwait warnings:
dotnet_diagnostic.CA2007.severity = noneLibraries — keep CA2007 as warning (callers may not have a SynchronizationContext):
dotnet_diagnostic.CA2007.severity = warningTest projects — relax certain rules:
dotnet_diagnostic.CA1707.severity = none # Allow underscores in test names
dotnet_diagnostic.CA1062.severity = none # Parameters validated by test framework
dotnet_diagnostic.CA2007.severity = none # ConfigureAwait not relevantNullable Reference Types
Enable globally in Directory.Build.props:
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>Nullable analysis produces warnings (CS86xx) not CA rules. Related settings:
<PropertyGroup>
<!-- Treat nullable warnings as errors -->
<WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>
</PropertyGroup>For gradual adoption in existing codebases, enable per-file:
#nullable enableSee [skill:dotnet-csharp] for annotation strategies and patterns.
Trimming and AOT Compatibility Analyzers
Applications
For apps published with trimming or Native AOT, enable the analyzers alongside the publish properties:
<PropertyGroup>
<!-- Enable trimmed publishing + analysis -->
<PublishTrimmed>true</PublishTrimmed>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<!-- Enable AOT publishing + analysis -->
<PublishAot>true</PublishAot>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<!-- Single-file analysis (subset of trim analysis) -->
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
</PropertyGroup>Enable the analyzers early (even before publishing trimmed) to catch issues during development. EnableTrimAnalyzer and EnableAotAnalyzer can be set independently of PublishTrimmed/PublishAot.
Libraries
Libraries use IsTrimmable and IsAotCompatible to declare compatibility to consumers. Enable these even if consumers don't trim yet:
<PropertyGroup>
<IsTrimmable>true</IsTrimmable>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>Setting IsTrimmable/IsAotCompatible automatically enables the corresponding analyzers. This ensures the library works correctly when consumers eventually enable trimming/AOT.
What the Analyzers Flag
These analyzers flag:
- Reflection usage that breaks trimming (IL2xxx warnings)
- P/Invoke patterns incompatible with AOT
- Dynamic code generation (
Reflection.Emit,System.Linq.Expressionscompilation) - Types not annotated with
[DynamicallyAccessedMembers]
Third-Party Analyzers
Add via Directory.Build.targets so they apply to all projects:
<!-- Directory.Build.targets -->
<Project>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" />
</ItemGroup>
</Project>With CPM, add version entries in Directory.Packages.props:
<PackageVersion Include="Meziantou.Analyzer" Version="2.0.187" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.11.0-beta1.25058.1" />Recommended Analyzer Packages
| Package | Focus |
|---|---|
Meziantou.Analyzer | Security, performance, best practices (broad coverage) |
Microsoft.CodeAnalysis.BannedApiAnalyzers | Ban specific APIs via BannedSymbols.txt |
Microsoft.CodeAnalysis.PublicApiAnalyzers | Track public API surface (library authors) |
SonarAnalyzer.CSharp | Security, reliability, maintainability |
BannedSymbols.txt
When using BannedApiAnalyzers, create BannedSymbols.txt at the repo root and include it:
<!-- Directory.Build.targets -->
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt"
Condition="Exists('$(MSBuildThisFileDirectory)BannedSymbols.txt')" />
</ItemGroup>Example BannedSymbols.txt:
T:System.DateTime;Use DateTimeOffset instead
M:System.DateTime.Now;Use DateTimeOffset.UtcNow instead
T:System.GC;Do not call GC methods directlyAdding Analyzers to an Existing Project
1. Enable built-in analyzers — set AnalysisLevel and EnforceCodeStyleInBuild in Directory.Build.props 2. Start at recommended level — use latest-recommended if latest-all produces too many warnings 3. Add EditorConfig overrides — suppress rules that don't apply to your project type 4. Add third-party analyzers — via Directory.Build.targets with CPM versions 5. Fix incrementally — enable TreatWarningsAsErrors only after addressing existing warnings, or use <NoWarn> temporarily for categories being addressed
Incremental Adoption Pattern
For large codebases, avoid fixing all warnings at once:
<!-- Directory.Build.props — temporary during migration -->
<PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<!-- Fix these categories first, then remove NoWarn entries -->
<NoWarn>$(NoWarn);CA1822;CA1848</NoWarn>
</PropertyGroup>Remove NoWarn entries as each category is addressed. Track progress with:
dotnet build 2>&1 | grep -oE 'CA[0-9]+' | sort | uniq -c | sort -rnReferences
AOT Architecture
AOT-first application design patterns for .NET 8+: preferring source generators over reflection, explicit DI registration over assembly scanning, AOT-safe serialization choices, library compatibility assessment, and factory patterns replacing Activator.CreateInstance.
Version assumptions: .NET 8.0+ baseline. Patterns apply to all AOT-capable project types (console, ASP.NET Core Minimal APIs, worker services).
Source Generators Over Reflection
The primary AOT enabler is replacing runtime reflection with compile-time source generation. Source generators produce code at build time that the AOT compiler can analyze and include.
Key Source Generator Replacements
| Reflection Pattern | Source Generator / AOT-Safe Alternative | Library |
|---|---|---|
JsonSerializer.Deserialize<T>() | [JsonSerializable] context | System.Text.Json (built-in) |
Activator.CreateInstance<T>() | Factory pattern with explicit new | Manual |
Type.GetProperties() for mapping | [Mapper] attribute | Mapperly |
Regex pattern compilation | [GeneratedRegex] attribute | Built-in (.NET 7+) |
ILogger.Log(...) with string interpolation | [LoggerMessage] attribute | Microsoft.Extensions.Logging |
| Assembly scanning for DI | Explicit services.Add*() | Manual |
[DllImport] P/Invoke | [LibraryImport] | Built-in (.NET 7+) |
AutoMapper CreateMap<>() | [Mapper] source gen | Mapperly |
Example: Migrating to Source Gen
// BEFORE: Reflection-based (breaks under AOT)
var logger = loggerFactory.CreateLogger<OrderService>();
logger.LogInformation("Order {OrderId} created for {Customer}", order.Id, order.CustomerId);
// AFTER: Source-generated (AOT-safe, zero-alloc)
public partial class OrderService
{
[LoggerMessage(Level = LogLevel.Information,
Message = "Order {OrderId} created for {Customer}")]
private static partial void LogOrderCreated(
ILogger logger, int orderId, string customer);
}
// Usage:
LogOrderCreated(_logger, order.Id, order.CustomerId);See [skill:dotnet-csharp] for source generator mechanics and authoring patterns.
AOT-Safe DI Patterns
Dependency injection in AOT requires explicit service registration. Assembly scanning (AddServicesFromAssembly) and open-generic resolution may require reflection that AOT cannot satisfy.
Explicit Registration (Preferred)
var builder = WebApplication.CreateSlimBuilder(args);
// Explicit registrations -- AOT-safe
builder.Services.AddSingleton<IOrderRepository, PostgresOrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton(TimeProvider.System);Avoid Assembly Scanning
// BAD: Assembly scanning uses reflection -- breaks under AOT
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(classes => classes.AssignableTo<IService>())
.AsImplementedInterfaces()
.WithScopedLifetime());
// GOOD: Explicit registrations grouped by concern
builder.Services.AddOrderServices();
builder.Services.AddInventoryServices();
// Extension method groups related registrations
public static class OrderServiceExtensions
{
public static IServiceCollection AddOrderServices(
this IServiceCollection services)
{
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IOrderRepository, PostgresOrderRepository>();
services.AddScoped<IOrderValidator, OrderValidator>();
return services;
}
}Keyed Services (.NET 8+)
// AOT-safe keyed service registration
builder.Services.AddKeyedSingleton<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedSingleton<INotificationSender, SmsSender>("sms");
// Resolve by key
app.MapPost("/notify", ([FromKeyedServices("email")] INotificationSender sender) =>
sender.SendAsync("Hello"));See [skill:dotnet-csharp] for full DI patterns.
Serialization Choices for AOT
Decision Matrix
| Serializer | AOT-Safe | Setup Required | Best For |
|---|---|---|---|
| System.Text.Json + source gen | Yes | [JsonSerializable] context | APIs, config, JSON interop |
| Protobuf (Google.Protobuf) | Yes | .proto schema files | gRPC, service-to-service |
| MessagePack + source gen | Yes | [MessagePackObject] + source gen resolver | Caching, real-time |
| Newtonsoft.Json | No | N/A | Do not use for AOT |
| STJ without source gen | No | N/A | Falls back to reflection |
STJ Source Gen Setup
// Define serializable types
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class AppJsonContext : JsonSerializerContext { }
// Register in ASP.NET Core
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0,
AppJsonContext.Default);
});See [skill:dotnet-csharp] for comprehensive serialization patterns.
Factory Patterns Replacing Activator.CreateInstance
Activator.CreateInstance uses runtime reflection to create instances and is incompatible with AOT. Replace with factory patterns that use explicit construction.
Simple Factory
// BAD: Reflection-based creation -- breaks under AOT
public T CreateHandler<T>() where T : class
=> (T)Activator.CreateInstance(typeof(T))!;
// GOOD: Factory with explicit registration
public class HandlerFactory
{
private readonly Dictionary<Type, Func<IHandler>> _factories = new();
public void Register<T>(Func<T> factory) where T : IHandler
=> _factories[typeof(T)] = () => factory();
public IHandler Create<T>() where T : IHandler
=> _factories[typeof(T)]();
}
// Registration
var factory = new HandlerFactory();
factory.Register<OrderHandler>(() => new OrderHandler(repository, logger));
factory.Register<PaymentHandler>(() => new PaymentHandler(gateway));Strategy Pattern via DI
// BAD: Dynamic type resolution
public IPaymentProcessor GetProcessor(string type)
{
var processorType = Type.GetType($"MyApp.Payments.{type}Processor");
return (IPaymentProcessor)Activator.CreateInstance(processorType!)!;
}
// GOOD: Keyed services (.NET 8+)
builder.Services.AddKeyedScoped<IPaymentProcessor, CreditCardProcessor>("CreditCard");
builder.Services.AddKeyedScoped<IPaymentProcessor, BankTransferProcessor>("BankTransfer");
builder.Services.AddKeyedScoped<IPaymentProcessor, WalletProcessor>("Wallet");
// Resolve at runtime without reflection
app.MapPost("/pay", (
[FromQuery] string type,
IServiceProvider sp) =>
{
var processor = sp.GetRequiredKeyedService<IPaymentProcessor>(type);
return processor.ProcessAsync();
});Enum-Based Factory
// For a fixed set of types, use a switch expression
public static IExporter CreateExporter(ExportFormat format) => format switch
{
ExportFormat.Csv => new CsvExporter(),
ExportFormat.Json => new JsonExporter(),
ExportFormat.Pdf => new PdfExporter(),
_ => throw new ArgumentOutOfRangeException(nameof(format))
};Library Compatibility Assessment
Assessment Checklist
Before adopting a NuGet package in an AOT project:
1. Check for `IsAotCompatible` in the package source -- packages that set this are validated against AOT analyzers 2. Check for `[RequiresDynamicCode]` / `[RequiresUnreferencedCode]` annotations -- these indicate AOT-incompatible APIs 3. Run AOT analyzers against your usage -- dotnet build /p:EnableAotAnalyzer=true 4. Check the package's GitHub issues for AOT/trimming reports -- search for "Native AOT", "trimming", "IL2026", "IL3050" 5. Look for source-generated alternatives -- many reflection-based libraries now have source-gen companions
Common Library Status
| Library | AOT Status | AOT-Safe Alternative |
|---|---|---|
| AutoMapper | Breaks | Mapperly |
| MediatR | Partial (explicit registration) | Direct method calls or factory |
| FluentValidation | Partial | Manual validation or source gen |
| Dapper | Compatible (.NET 8+ AOT support) | -- |
| Entity Framework Core | Partial (precompiled queries) | Dapper for AOT-heavy paths |
| Refit | Compatible (7+ with source gen) | -- |
| Polly | Compatible (v8+) | -- |
| Serilog | Partial | [LoggerMessage] source gen |
| Hangfire | Breaks | Custom IHostedService |
Testing Compatibility
# Build with all analyzers enabled
dotnet build /p:EnableAotAnalyzer=true /p:EnableTrimAnalyzer=true /p:TrimmerSingleWarn=false
# Warnings indicate AOT-incompatible usage
# IL3050 = RequiresDynamicCode (definitely breaks)
# IL2026 = RequiresUnreferencedCode (may break)AOT Application Architecture Template
src/
MyApp/
Program.cs # CreateSlimBuilder, explicit DI
MyApp.csproj # PublishAot=true, EnableAotAnalyzer=true
JsonContext.cs # [JsonSerializable] for all API types
Endpoints/
OrderEndpoints.cs # Minimal API route groups
ProductEndpoints.cs
Services/
OrderService.cs # Business logic (no reflection)
IOrderService.cs
Repositories/
OrderRepository.cs # Data access (Dapper or EF precompiled)
Extensions/
ServiceCollectionExtensions.cs # Grouped DI registrationsAgent Gotchas
1. Do not use `Activator.CreateInstance` in AOT projects. It requires runtime reflection that is not available. Use factory patterns, DI keyed services, or switch expressions instead. 2. Do not use assembly scanning for DI registration (Scan, RegisterAssemblyTypes, FromAssemblyOf). These use reflection to discover types at runtime. Register services explicitly. 3. Do not use `System.Text.Json` without a `[JsonSerializable]` context in AOT. Without a source-generated context, STJ falls back to reflection and fails at runtime. 4. Do not assume a library is AOT-compatible without testing. Run dotnet build /p:EnableAotAnalyzer=true and check for IL3050/IL2026 warnings against your specific usage. 5. Do not use `Type.GetType()` or `Assembly.GetTypes()` for runtime discovery. These rely on metadata that may be trimmed. Use compile-time known types.
References
Artifacts Output
Reference guide for the .NET SDK artifacts output layout, which centralizes build outputs (bin/, obj/, publish/, package/) into a single artifacts/ directory at the repo root. Available since .NET 8 as an opt-in feature. Recommended for new projects; evaluate tradeoffs before migrating existing projects.
Prerequisites: Run references/version-detection.md first to confirm .NET 8+ SDK -- artifacts output layout is not available in earlier SDK versions.
Why Use Artifacts Output
Traditional .NET build output scatters bin/ and obj/ directories throughout the source tree, one per project. The artifacts output layout consolidates all build outputs under a single artifacts/ directory next to Directory.Build.props.
Benefits:
- Simpler `.gitignore` -- one
artifacts/entry replaces per-projectbin/andobj/entries - Easier clean builds -- delete one directory instead of hunting for scattered
bin//obj/folders - Predictable output paths -- tooling can anticipate where to find build outputs without traversing the source tree
- Cleaner source tree -- no build artifacts mixed into project directories
Tradeoffs:
- Breaking path assumptions -- existing CI pipelines, Dockerfiles, and tooling that reference
bin/Debug/net10.0/paths must be updated - IDE/tool compatibility -- some older tools may not resolve the new output paths correctly
- Migration effort -- existing projects require updating all hardcoded output path references
Enabling Artifacts Output
Add UseArtifactsOutput to your Directory.Build.props at the repo root:
<Project>
<PropertyGroup>
<UseArtifactsOutput>true</UseArtifactsOutput>
</PropertyGroup>
</Project>Alternatively, generate a new Directory.Build.props with artifacts output pre-configured:
dotnet new buildprops --use-artifactsThis creates:
<Project>
<PropertyGroup>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
</PropertyGroup>
</Project>Setting ArtifactsPath directly is equivalent to UseArtifactsOutput=true and additionally lets you customize the root directory location.
Output Path Structure
All build outputs are organized under artifacts/ with three levels: output type, project name, and pivot (configuration/TFM/RID).
artifacts/
bin/
MyApp/
debug/ # Single-targeted project
debug_net10.0/ # Multi-targeted project
release_linux-x64/ # RID-specific build
MyApp.Core/
debug/
obj/
MyApp/
debug/
publish/
MyApp/
release/ # dotnet publish output
release_linux-x64/ # RID-specific publish
package/
release/ # NuGet .nupkg files (no project subfolder)Output Type Directories
| Directory | Contents | Traditional equivalent |
|---|---|---|
artifacts/bin/ | Compiled assemblies and dependencies | <project>/bin/ |
artifacts/obj/ | Intermediate build files, generated code | <project>/obj/ |
artifacts/publish/ | Published application output | <project>/bin/<config>/<tfm>/publish/ |
artifacts/package/ | NuGet packages (.nupkg, .snupkg) | <project>/bin/<config>/ |
Pivot Naming
The pivot subfolder combines configuration, TFM, and RID joined by underscores. Components that are not present are omitted:
| Scenario | Pivot | Full path example |
|---|---|---|
| Single-targeted, debug | debug | artifacts/bin/MyApp/debug/ |
| Multi-targeted, debug | debug_net10.0 | artifacts/bin/MyApp/debug_net10.0/ |
| Release, RID-specific | release_linux-x64 | artifacts/bin/MyApp/release_linux-x64/ |
| Package output | release | artifacts/package/release/ |
Note: artifacts/package/ omits the project name subfolder. The pivot includes only the configuration.
Customizing the Artifacts Path
Custom Root Directory
Set ArtifactsPath to change the root location:
<PropertyGroup>
<ArtifactsPath>$(MSBuildThisFileDirectory).output</ArtifactsPath>
</PropertyGroup>This places all build outputs under .output/ instead of artifacts/.
Custom Pivot
Customize the pivot subfolder naming with ArtifactsPivots:
<PropertyGroup>
<ArtifactsPivots>$(ArtifactsPivots)_MyCustomPivot</ArtifactsPivots>
</PropertyGroup>Impact on .gitignore
With artifacts output enabled, simplify .gitignore:
# Artifacts output layout (replaces per-project bin/ and obj/ entries)
artifacts/This single entry replaces the traditional pattern:
# Traditional layout (no longer needed with artifacts output)
[Bb]in/
[Oo]bj/If using a custom ArtifactsPath, update the .gitignore entry to match.
Impact on Dockerfiles
Multi-stage Dockerfiles that copy build output must reference the new path structure. See [skill:dotnet-devops] for full Dockerfile patterns.
Traditional paths:
COPY --from=build /app/src/MyApp/bin/Release/net10.0/publish/ .Artifacts output paths:
COPY --from=build /app/artifacts/publish/MyApp/release/ .Key differences in Dockerfile paths:
- Output is under
artifacts/publish/notbin/Release/<tfm>/publish/ - Project name becomes a subdirectory under the output type
- Configuration pivot is lowercase (
releasenotRelease) - TFM is omitted from single-targeted project pivots
Impact on CI Pipelines
CI workflows that upload build artifacts or reference output paths must be updated. See [skill:dotnet-devops] for full CI workflow patterns.
GitHub Actions -- upload build output:
- name: Publish
run: dotnet publish src/MyApp/MyApp.csproj -c Release
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: app
path: artifacts/publish/MyApp/release/GitHub Actions -- upload NuGet packages:
- name: Pack
run: dotnet pack -c Release
- name: Upload packages
uses: actions/upload-artifact@v4
with:
name: packages
path: artifacts/package/release/*.nupkgAzure DevOps -- publish artifacts:
- script: dotnet publish src/MyApp/MyApp.csproj -c Release
displayName: 'Publish'
- task: PublishPipelineArtifact@1
inputs:
targetPath: 'artifacts/publish/MyApp/release/'
artifact: 'app'Migration Checklist
When enabling artifacts output on an existing project:
1. Add `UseArtifactsOutput` to Directory.Build.props 2. Update `.gitignore` -- replace [Bb]in/ and [Oo]bj/ with artifacts/ 3. Update Dockerfiles -- change all COPY --from=build paths to use artifacts/ structure 4. Update CI pipelines -- fix artifact upload paths, test result paths, and publish paths 5. Update local scripts -- fix any shell scripts that reference bin/ or obj/ paths 6. Clean old output -- delete existing bin/ and obj/ directories from all projects 7. Verify builds -- run dotnet build and dotnet publish to confirm output appears under artifacts/ 8. Verify tests -- run dotnet test to confirm test execution with new paths
Agent Gotchas
1. Do not hardcode TFM in artifacts output paths for single-targeted projects. The pivot for single-targeted projects is just the configuration (e.g., debug), not debug_net10.0. Multi-targeted projects include the TFM in the pivot. 2. Do not use capitalized configuration names in artifacts paths. The artifacts layout uses lowercase pivots (debug, release), not the traditional capitalized names (Debug, Release). 3. Do not assume `artifacts/package/` has a project name subfolder. Unlike bin/, obj/, and publish/, the package/ output type omits the project name level. Packages appear directly under artifacts/package/<config>/. 4. Do not enable artifacts output without updating Dockerfiles and CI pipelines first. Path changes will break COPY --from=build directives and artifact upload steps that reference traditional bin/ paths. 5. Do not present artifacts output as the default .NET layout. It is opt-in since .NET 8 and remains opt-in. Recommend it for new projects; for existing projects, evaluate the migration effort against the benefits.
References
Build Analysis
Help agents interpret and act on MSBuild build output. Covers error code prefixes, NuGet restore failures, analyzer warning interpretation, multi-targeting build differences, and "works locally, fails in CI" diagnosis patterns. Each subsection includes example output, diagnosis steps, and a fix pattern.
Prerequisites
.NET 8.0+ SDK. MSBuild (included with .NET SDK). Understanding of SDK-style project format.
Cross-references: [skill:dotnet-api] for common code mistakes that cause build errors, references/csproj-reading.md for project file structure and modification, references/project-structure.md for project organization and SDK selection.
Error Code Prefixes
MSBuild output uses standardized prefixes to indicate the error source. Understanding the prefix tells you which system produced the error and where to look for fixes.
CS -- C# Compiler Errors and Warnings
Produced by the Roslyn C# compiler. These are language-level issues in source code.
Example output:
src/MyApp.Api/Services/OrderService.cs(42,17): error CS0246: The type or namespace name 'OrderDto' could not be found (are you missing a using directive or an assembly reference?)
src/MyApp.Api/Models/User.cs(15,9): warning CS8618: Non-nullable property 'Name' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.Diagnosis: 1. Parse the file path and line number from the error -- src/MyApp.Api/Services/OrderService.cs line 42, column 17. 2. CS0246 means a type is missing. Check: is the type defined? Is the namespace imported? Is the project referencing the assembly that contains it? 3. CS8618 is a nullable reference type warning. The property needs a required modifier, nullable annotation (string?), or constructor initialization.
Fix pattern:
- CS0xxx (syntax/type errors): Fix source code at the indicated location. Add
usingdirectives, fix type names, add missing references. - CS8xxx (nullable warnings): Add null annotations, null checks, or
requiredmodifiers. Do NOT suppress with#pragmaor!operator.
MSB -- MSBuild Engine Errors
Produced by the MSBuild build engine itself. These indicate project file problems, target failures, or build system misconfiguration.
Example output:
error MSB4019: The imported project "C:\Program Files\dotnet\sdk\9.0.100\Microsoft\VisualStudio\v17.0\WebApplications\Microsoft.WebApplication.targets" was not found. Confirm that the expression in the Import declaration "..." is correct.
error MSB3644: The reference assemblies for .NETFramework,Version=v4.8 were not found. You might need to install the developer pack for this framework version.Diagnosis: 1. MSB4019: An MSBuild .targets file is missing. This usually means wrong SDK type, missing workload, or corrupt SDK installation. 2. MSB3644: Targeting a framework version whose targeting pack is not installed. Common when a project targets .NET Framework but only .NET (Core) SDK is installed.
Fix pattern:
- MSB4019: Verify
<Project Sdk="...">is correct (e.g.,Microsoft.NET.Sdk.Webfor ASP.NET Core). Rundotnet workload listand install missing workloads. - MSB3xxx: Check
<TargetFramework>value. Ensure the required SDK or targeting pack is installed.
NU -- NuGet Errors and Warnings
Produced by the NuGet package manager during restore or pack operations.
Example output:
error NU1101: Unable to find package Newtonsoft.Json.Extensions. No packages exist with this id in source(s): nuget.org
warning NU1603: Microsoft.EntityFrameworkCore 9.0.0 depends on Microsoft.Extensions.Caching.Memory (>= 9.0.0) but version Microsoft.Extensions.Caching.Memory 8.0.1 was resolved. Approve the package to suppress this warning.
error NU1605: Detected package downgrade: Microsoft.Extensions.Logging from 9.0.0 to 8.0.1. Reference the package directly from the project to select a different version.Diagnosis: 1. NU1101: Package ID does not exist. Check spelling, verify the package source is configured, check if the package was renamed or deprecated. 2. NU1603: Transitive dependency version conflict. A package wants a newer version than what is resolved. 3. NU1605: Explicit downgrade detected. Two packages require different versions of the same dependency.
Fix pattern:
- NU1101: Fix the package name. Search nuget.org for the correct ID.
- NU1603/NU1605: Add a direct
<PackageReference>for the conflicting package at a compatible version, or use central package management to pin versions.
IDE -- IDE/Roslyn Analyzer Code Style Diagnostics
Produced by Roslyn IDE analyzers for code style enforcement. These are usually warnings, not errors (unless .editorconfig escalates them).
Example output:
src/MyApp.Api/Program.cs(1,1): warning IDE0005: Using directive is unnecessary.
src/MyApp.Api/Models/Order.cs(8,12): warning IDE0044: Make field readonly
src/MyApp.Api/Services/Report.cs(22,5): warning IDE0058: Expression value is never usedDiagnosis: 1. IDE0005: Unused using directive. Safe to remove. 2. IDE0044: Field can be readonly because it is only assigned in the constructor. 3. IDE0058: A method return value is discarded. Either assign it or use _ = ... to explicitly discard.
Fix pattern:
- IDE analyzers enforce code style. Fix them by applying the suggested change. Configure severity in
.editorconfigto promote warnings to errors for CI enforcement.
CA -- .NET Code Analysis (FxCop/Microsoft.CodeAnalysis.NetAnalyzers)
Produced by the .NET code analysis SDK analyzers for API design, performance, reliability, and security rules.
Example output:
src/MyApp.Api/Services/CacheService.cs(34,9): warning CA1848: Use the LoggerMessage delegates instead of calling 'LoggerExtensions.LogInformation(ILogger, string?, params object?[])'. Using LoggerMessage delegates provides better performance.
src/MyApp.Api/Controllers/UserController.cs(12,5): warning CA2007: Consider calling ConfigureAwait on the awaited task
src/MyApp.Api/Crypto/HashService.cs(8,9): warning CA5351: Do Not Use Broken Cryptographic Algorithms (MD5)Diagnosis: 1. CA1848: High-performance logging. Use [LoggerMessage] source generator attributes instead of string interpolation in log calls. 2. CA2007: ConfigureAwait(false) guidance for library code. Not applicable to ASP.NET Core app code (no SynchronizationContext). 3. CA5351: Security-critical. MD5 is broken for cryptographic purposes. Switch to SHA-256 or SHA-512.
Fix pattern:
- CA1xxx (design): Apply suggested API changes. These improve API consistency.
- CA2xxx (reliability/performance): Fix per suggestion. CA2007 can be suppressed in ASP.NET Core apps via
.editorconfig. - CA5xxx (security): Always fix. These flag real security vulnerabilities.
NuGet Restore Failures
NuGet restore is the first build step. When it fails, no compilation occurs. These are the most common restore failure patterns.
Pattern: Package Not Found
Example output:
Determining projects to restore...
Writing assets file to disk. Path: /src/MyApp.Api/obj/project.assets.json
/src/MyApp.Api/MyApp.Api.csproj : error NU1101: Unable to find package MyCompany.Shared.Models. No packages exist with this id in source(s): nuget.org
Failed to restore /src/MyApp.Api/MyApp.Api.csproj (in 2.14 sec).Diagnosis: 1. Is the package ID spelled correctly? NuGet IDs are case-insensitive but must be exact. 2. Is the package from a private feed? Check nuget.config for feed configuration. NuGet searches feeds hierarchically upward from the project directory. 3. Is packageSourceMapping configured? If so, the package must be mapped to a source that contains it. MyCompany.* patterns take precedence over * wildcard.
Fix pattern:
# Check configured sources
dotnet nuget list source
# Check if nuget.config exists (searches upward from project dir)
ls nuget.config ../nuget.config ../../nuget.config 2>/dev/null<!-- Add private feed to nuget.config -->
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="MyCompany" value="https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="MyCompany">
<package pattern="MyCompany.*" />
</packageSource>
</packageSourceMapping>
</configuration>Pattern: Version Conflict
Example output:
error NU1107: Version conflict detected for Microsoft.Extensions.DependencyInjection.Abstractions.
MyApp.Api -> Microsoft.EntityFrameworkCore 9.0.0 -> Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
MyApp.Api -> Microsoft.Extensions.Hosting 8.0.1 -> Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.1)Diagnosis: 1. Two dependency chains require different major versions of the same package. 2. Trace each chain to find which top-level package is pinned at an older version. 3. The fix is usually upgrading the older top-level package.
Fix pattern:
<!-- Upgrade the older top-level package to match -->
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<!-- Or add a direct reference to force a specific version -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />Pattern: Authentication Failure on Private Feed
Example output:
Retrying 'FindPackagesByIdAsyncCore' for source 'https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json'.
Response status code does not indicate success: 401 (Unauthorized).
error NU1301: Unable to load the service index for source https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json.Diagnosis: 1. Credentials are missing or expired for the private feed. 2. In CI, check that the credential provider or PAT is configured. 3. Locally, run dotnet nuget update source with credentials or use Azure Artifacts Credential Provider.
Fix pattern:
# Install Azure Artifacts Credential Provider (see official docs for platform-specific steps):
# https://github.com/microsoft/artifacts-credprovider#setup
# Windows: iex "& { $(irm https://aka.ms/install-artifacts-credprovider.ps1) }"
# macOS/Linux: sh -c "$(curl -fsSL https://aka.ms/install-artifacts-credprovider.sh)"
# Or add credentials explicitly to a specific source
dotnet nuget update source MyCompany --username az --password $PAT --store-password-in-clear-textAnalyzer Warning Interpretation
Analyzer warnings are produced by Roslyn analyzers bundled with the SDK or added via NuGet. Understanding when to fix vs. when to configure severity is critical.
Example Output
src/MyApp.Api/Controllers/OrdersController.cs(27,5): warning CA2007: Consider calling ConfigureAwait on the awaited task [/src/MyApp.Api/MyApp.Api.csproj]
src/MyApp.Api/Services/OrderService.cs(15,16): warning CA1062: In externally visible method 'OrderService.Process(string)', validate parameter 'input' is non-null before using it [/src/MyApp.Api/MyApp.Api.csproj]
src/MyApp.Api/Models/UserDto.cs(8,12): warning IDE0032: Use auto-implemented property [/src/MyApp.Api/MyApp.Api.csproj]Diagnosis: 1. Identify the prefix: CA = Code Analysis (.NET analyzers), IDE = IDE code style analyzers. 2. Check severity: warnings don't break builds unless <TreatWarningsAsErrors>true</TreatWarningsAsErrors> is set. 3. Determine if the rule applies to your project type (e.g., CA2007 is irrelevant in ASP.NET Core — no SynchronizationContext). 4. Decide: fix the code, configure severity in .editorconfig, or suppress with documented justification.
Fix pattern:
- Fix the code when the analyzer identifies a real issue (CA1062 — add null validation or use
ArgumentNullException.ThrowIfNull). - Configure severity in
.editorconfigwhen the rule doesn't apply project-wide (see below). - Suppress inline only with documented justification (see "When Suppression Is Acceptable" below).
Severity Levels
| Severity | Build Impact | Action |
|---|---|---|
| Error | Build fails | Must fix before build succeeds |
| Warning | Build succeeds (unless <TreatWarningsAsErrors>true</TreatWarningsAsErrors>) | Fix or configure in .editorconfig |
| Suggestion | Build succeeds; shown in IDE | Fix when practical |
| Hidden | Not shown; available via code fix | Ignore unless actively refactoring |
Configuring Severity
Use .editorconfig to control analyzer behavior across the project:
# .editorconfig (place at solution root)
[*.cs]
# Promote nullable warnings to errors (recommended)
dotnet_diagnostic.CS8600.severity = error
dotnet_diagnostic.CS8602.severity = error
dotnet_diagnostic.CS8603.severity = error
# Suppress ConfigureAwait warning in ASP.NET Core apps (no SynchronizationContext)
dotnet_diagnostic.CA2007.severity = none
# Promote security warnings to errors
dotnet_diagnostic.CA5350.severity = error
dotnet_diagnostic.CA5351.severity = errorWhen Suppression Is Acceptable
Suppression is acceptable ONLY when: 1. The analyzer cannot understand the code's safety guarantee (e.g., a custom guard clause that ensures non-null). 2. The rule does not apply to the project type (e.g., CA2007 in ASP.NET Core apps). 3. A documented justification is provided.
// ACCEPTABLE: documented justification
[SuppressMessage("Reliability", "CA2007:ConfigureAwait",
Justification = "ASP.NET Core has no SynchronizationContext")]
public async Task<Order> GetOrderAsync(int id, CancellationToken ct)
{
return await _repo.GetByIdAsync(id, ct);
}
// NOT ACCEPTABLE: no justification, hides a real issue
#pragma warning disable CA1062
public void Process(string input) { } // input could be null
#pragma warning restore CA1062Multi-Targeting Build Output
When a project targets multiple frameworks, MSBuild builds each TFM separately. Errors may appear for only one target.
Example Output
MyApp.Shared -> /src/MyApp.Shared/bin/Debug/net8.0/MyApp.Shared.dll
src/MyApp.Shared/Services/FeatureService.cs(18,30): error CS1061: 'FrozenDictionary<string, int>' does not contain a definition for 'GetAlternateLookup' [/src/MyApp.Shared/MyApp.Shared.csproj -> net8.0]
MyApp.Shared -> /src/MyApp.Shared/bin/Debug/net9.0/MyApp.Shared.dll
Build succeeded for net9.0.
Build FAILED for net8.0.Diagnosis: 1. The error tag [...csproj -> net8.0] shows which TFM failed. net9.0 succeeded. 2. GetAlternateLookup was added in .NET 9. The code uses an API not available in .NET 8. 3. The fix requires conditional compilation or an alternative API for the older TFM.
Fix pattern:
// Use preprocessor directives for TFM-specific code
#if NET9_0_OR_GREATER
var lookup = frozenDict.GetAlternateLookup<ReadOnlySpan<char>>();
return lookup.TryGetValue(key, out var value) ? value : default;
#else
return frozenDict.TryGetValue(key.ToString(), out var value) ? value : default;
#endif<!-- Or constrain the feature to specific TFMs in the project file -->
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
</PropertyGroup>
<!-- TFM-conditional package reference -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
</ItemGroup>Reading Multi-Target Output
Key patterns for identifying TFM-specific issues:
[ProjectPath -> TFM]suffix on every diagnostic line identifies the target.Build succeeded for netX.0/Build FAILED for netX.0summary at the end.- Restore output shows all TFMs:
Restored ... (net8.0, net9.0). - Build output paths include the TFM:
bin/Debug/net8.0/vsbin/Debug/net9.0/.
CI Drift: Works Locally, Fails in CI
The most frustrating build failures are ones that pass locally but fail in CI. These are almost always caused by environmental differences.
Pattern: Different SDK Version
Example scenario:
Local: dotnet --version -> 9.0.200
CI: dotnet --version -> 9.0.100
Build error in CI:
error CS8652: The feature 'field keyword' is currently in Preview and *unsupported*.Diagnosis: 1. The local SDK (9.0.200) includes a language preview feature that the CI SDK (9.0.100) does not. 2. A global.json file is either missing or not pinning the SDK version.
Fix pattern:
// global.json -- pin SDK version for consistent builds
{
"sdk": {
"version": "9.0.200",
"rollForward": "latestPatch"
}
}Pattern: Missing Workload in CI
Example scenario:
CI error:
error NETSDK1147: To build this project, the following workloads must be installed: maui-androidDiagnosis: 1. MAUI/Aspire/WASM workloads installed locally but not in the CI image. 2. CI pipeline needs explicit workload install step.
Fix pattern:
# GitHub Actions example
- name: Install .NET workloads
run: dotnet workload install maui-android maui-iosPattern: Implicit NuGet Sources
Example scenario:
Local restore succeeds (using cached packages).
CI error:
error NU1101: Unable to find package MyCompany.Internal.Lib.Diagnosis: 1. Local machine has the package in the global NuGet cache from a previous restore. 2. CI starts with a clean cache and cannot find the package because the private feed is not configured. 3. A nuget.config file is missing from the repository, or CI lacks feed credentials.
Fix pattern: 1. Add nuget.config to the repository root with all required package sources. 2. Configure CI to authenticate to private feeds (credential provider, PAT, or managed identity). 3. Do NOT rely on global NuGet cache for CI builds.
Pattern: OS-Specific Path Differences
Example scenario:
Local (Windows): Build succeeds
CI (Linux): error MSB4018: The "ResolveAssemblyReference" task failed.
Could not find file '/src/MyApp/../Shared/MyLib.dll'Diagnosis: 1. Windows file system is case-insensitive; Linux is case-sensitive. 2. A file reference uses different casing than the actual file on disk. 3. Or backslash path separators in MSBuild properties that Linux cannot resolve.
Fix pattern:
- Ensure file and directory names match the case used in project references exactly.
- Use forward slashes (
/) in.csprojpaths -- MSBuild normalizes them on all platforms. - Test in a Linux container locally with
docker runbefore pushing.
Pattern: TreatWarningsAsErrors in CI Only
Example scenario:
Local: Build succeeds with 3 warnings
CI: error CS8602: Dereference of a possibly null reference.
(because CI sets TreatWarningsAsErrors=true)Diagnosis: 1. CI pipeline or Directory.Build.props enables <TreatWarningsAsErrors>true</TreatWarningsAsErrors> via a CI-only condition. 2. Developers see warnings locally but never fix them because the build succeeds.
Fix pattern:
- Enable
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>unconditionally inDirectory.Build.propsso local and CI builds behave identically. - Fix all warnings. Do NOT add CI-only MSBuild properties that diverge from local behavior.
Slopwatch Anti-Patterns
These patterns indicate an agent is hiding build problems rather than fixing them. Flag these during code review. See [skill:dotnet-testing] for the automated quality gate that detects these patterns.
Warning Suppressions
<!-- RED FLAG: blanket NoWarn in .csproj -->
<PropertyGroup>
<NoWarn>CS8600;CS8602;CS8603;CS8604;IL2026;IL2046</NoWarn>
</PropertyGroup>// RED FLAG: pragma disable without justification
#pragma warning disable CS8618
public class UserModel
{
public string Name { get; set; } // non-nullable not initialized
public string Email { get; set; }
}
#pragma warning restore CS8618Fix: Remove <NoWarn> entries and fix the underlying issues. If suppression is truly needed, use .editorconfig with per-rule severity and a comment explaining why.
Silenced Analyzers Without Justification
// RED FLAG: suppressing security analyzer with no explanation
[SuppressMessage("Security", "CA5351")]
public byte[] HashData(byte[] input)
{
using var md5 = MD5.Create(); // insecure algorithm
return md5.ComputeHash(input);
}# RED FLAG: disabling entire analyzer categories in .editorconfig
[*.cs]
dotnet_diagnostic.CA5350.severity = none
dotnet_diagnostic.CA5351.severity = none
dotnet_diagnostic.CA5358.severity = noneFix: Replace insecure algorithms (MD5 -> SHA-256). If suppression is unavoidable (e.g., interop with a system requiring MD5), add a Justification string explaining the constraint.
Cross-References
- [skill:dotnet-api] -- common agent coding mistakes that produce build errors
references/csproj-reading.md-- project file structure, PropertyGroup/ItemGroup conventionsreferences/project-structure.md-- SDK selection, project organization, multi-project solutions
References
Build Optimization
Guidance for diagnosing and fixing build performance problems: incremental build failure diagnosis workflows, binary log analysis with MSBuild Structured Log Viewer, parallel build configuration, build caching, and restore optimization. Covers the diagnostic workflow from symptom (full rebuild on every build) through root cause (missing Inputs/Outputs, timestamp corruption, generator side effects) to fix.
Version assumptions: .NET 8.0+ SDK (MSBuild 17.8+). All examples use SDK-style projects.
Incremental Build Failure Diagnosis
When a target runs on every build despite no source changes, the build is not incremental. This wastes time and masks real changes. The diagnosis workflow follows a repeatable pattern: detect the symptom, capture a binary log, identify the offending target, determine why incrementality failed, and apply the fix.
Diagnosis Workflow
1. Symptom: Build takes longer than expected, or output says
"Building target 'X' completely" on every build
2. Capture binary log: dotnet build /bl
3. Open the .binlog in MSBuild Structured Log Viewer
4. Search for targets that ran (not skipped)
5. Check: Does the target have Inputs/Outputs?
- No -> Add Inputs/Outputs (see fix patterns below)
- Yes -> Compare timestamps: are outputs older than inputs?
-> Check for volatile writers or missing output files
6. Apply fix, rebuild, verify target is skippedStep 1: Capture a Binary Log
# Produce msbuild.binlog in the project directory
dotnet build /bl
# Named log file
dotnet build /bl:build-debug.binlog
# Binary log for restore + build (captures full pipeline)
dotnet build /bl -restoreThe /bl switch records every MSBuild event -- property evaluations, item lists, target entry/exit, task execution, and timestamps -- into a compact binary format. Binary logs contain full source paths and environment variables; do not commit them to version control or share publicly.
Step 2: Open in MSBuild Structured Log Viewer
Download from msbuildlog.com. Open the .binlog file. Key views:
| View | Use |
|---|---|
| Timeline | See which targets ran in parallel and how long each took |
| Target Results | Filter by "Built" (ran) vs "Skipped" (incremental hit) |
| Search | Find specific target names, property values, or file paths |
| Properties | Inspect evaluated property values at any point in the build |
| Items | Inspect item collections (Compile, Content, etc.) with metadata |
Step 3: Find the Non-Incremental Target
In the Structured Log Viewer, search for the target name and check its result. A target that should be incremental but ran fully will show "Building target 'X' completely" with a reason:
- "Output file does not exist" -- an expected output file is missing or was deleted
- "Input file is newer than output file" -- a source file changed, or a preceding step rewrote an output
- No Inputs/Outputs declared -- the target always runs because MSBuild has no way to check freshness
Common Incremental Build Failure Patterns
Missing Inputs/Outputs on Custom Targets
Symptom: Custom target runs on every build.
Root cause: The target has no Inputs/Outputs attributes. Without them, MSBuild runs the target unconditionally.
Fix: Add Inputs and Outputs that reflect the actual files read and written:
<!-- BEFORE: runs every build -->
<Target Name="GenerateVersionFile" BeforeTargets="CoreCompile">
<WriteLinesToFile File="$(IntermediateOutputPath)Version.g.cs"
Lines="[assembly: System.Reflection.AssemblyInformationalVersion("$(Version)")]"
Overwrite="true" />
</Target>
<!-- AFTER: only runs when Version property changes (via project file edit) -->
<Target Name="GenerateVersionFile"
BeforeTargets="CoreCompile"
Inputs="$(MSBuildProjectFullPath)"
Outputs="$(IntermediateOutputPath)Version.g.cs">
<WriteLinesToFile File="$(IntermediateOutputPath)Version.g.cs"
Lines="[assembly: System.Reflection.AssemblyInformationalVersion("$(Version)")]"
Overwrite="true" />
</Target>See references/msbuild-authoring.md for full Inputs/Outputs patterns and batching.
File Copy Timestamp Corruption
Symptom: Target re-runs because output file timestamps are always newer than inputs.
Root cause: A Copy task without SkipUnchangedFiles="true" updates the destination timestamp on every copy, even when content is identical.
Fix:
<!-- BEFORE: copies every build, resetting timestamps -->
<Copy SourceFiles="@(ConfigTemplate)"
DestinationFolder="$(OutputPath)" />
<!-- AFTER: skips unchanged files, preserving timestamps -->
<Copy SourceFiles="@(ConfigTemplate)"
DestinationFolder="$(OutputPath)"
SkipUnchangedFiles="true" />Generators Writing Unconditionally
Symptom: A code generator target runs every build even though inputs have not changed.
Root cause: The generator writes output files unconditionally, updating their timestamps even when content is identical. The next build sees "input newer than output" (because the generator itself is an input to downstream targets).
Fix: Write to a temp file first, then copy only if content differs:
<Target Name="GenerateCode"
BeforeTargets="CoreCompile"
Inputs="@(SchemaFile)"
Outputs="@(SchemaFile->'$(IntermediateOutputPath)%(Filename).g.cs')">
<!-- Write to temp file -->
<Exec Command="codegen %(SchemaFile.Identity) -o $(IntermediateOutputPath)%(SchemaFile.Filename).g.cs.tmp" />
<!-- Copy only if content changed (preserves timestamp when unchanged) -->
<Copy SourceFiles="$(IntermediateOutputPath)%(SchemaFile.Filename).g.cs.tmp"
DestinationFiles="$(IntermediateOutputPath)%(SchemaFile.Filename).g.cs"
SkipUnchangedFiles="true" />
</Target>Volatile Intermediate Files
Symptom: A target that depends on intermediate outputs re-runs because an earlier target always regenerates those files.
Root cause: An upstream target produces intermediate files (e.g., generated code, resource bundles) without proper Inputs/Outputs, causing those files to be rewritten every build. Downstream targets see them as "changed" and re-run.
Fix: Add Inputs/Outputs to the upstream target. If the upstream target is from the SDK or a NuGet package and cannot be modified, use Touch task to reset timestamps on its outputs to a stable value when content has not changed.
Binary Log Analysis
Capturing Binary Logs
# Basic binary log (outputs msbuild.binlog)
dotnet build /bl
# Named output file
dotnet build /bl:diagnostic.binlog
# Include restore phase
dotnet build /bl -restore
# Detailed verbosity in console + binary log
dotnet build /bl /v:minimalBinary logs capture everything regardless of the /v: verbosity level. The /v: switch only controls console output. Always use /bl for diagnosis; console verbosity is for quick scanning.
Preprocessed Project View
The -pp (preprocess) switch dumps the fully evaluated project file after all imports, conditions, and property substitutions:
# Dump the preprocessed project to stdout
dotnet msbuild MyApp.csproj -pp
# Redirect to a file for easier reading
dotnet msbuild MyApp.csproj -pp > preprocessed.xmlThe preprocessed output shows:
- All imported
.propsand.targetsfiles with their source paths - Final evaluated property values
- Complete item lists after all Include/Exclude/Update/Remove operations
- All target definitions with resolved conditions
Use -pp to answer "where does this property come from?" or "which .targets file defines this target?" without opening a binary log.
Key Diagnostic Searches in Binary Logs
| Search query | What it reveals |
|---|---|
Target name (e.g., CoreCompile) | Whether the target ran or was skipped, and why |
$property (e.g., $TargetFramework) | Evaluated value at each point in the build |
File path (e.g., Order.cs) | Which targets processed the file and when |
"Building target" | All targets that ran (not skipped) |
"Skipping target" | All targets that were skipped (incremental hit) |
| Warning/error text | Source location and build context for diagnostics |
Parallel Builds
Solution-Level Parallelism
MSBuild can build independent projects within a solution in parallel using multiple worker nodes:
# Use all available CPU cores (default behavior for dotnet build)
dotnet build
# Explicit: 4 worker nodes
dotnet build /m:4
# Single-threaded (useful for debugging build order issues)
dotnet build /m:1dotnet build enables /m (multi-process) by default. Each worker node is a separate MSBuild process that builds one project at a time. Projects with no dependency relationship build in parallel.
Graph Build Mode
Graph build (/graph) analyzes the project dependency graph before building and schedules projects for maximum parallelism:
# Graph-aware parallel build
dotnet build /graph
# Graph build with explicit parallelism
dotnet build /graph /m:8Graph mode advantages over default parallel build:
- Static scheduling: Determines the full dependency graph upfront instead of discovering dependencies during build
- Avoids redundant evaluations: Each project is evaluated once, not once per referencing project
- Better node utilization: Worker nodes receive projects as soon as dependencies are satisfied
Graph mode is particularly effective for large solutions (50+ projects) where the dependency graph has significant parallelism.
BuildInParallel Task Attribute
Individual MSBuild tasks (like MSBuild task) can declare whether they support parallel invocation:
<!-- Build referenced projects in parallel -->
<MSBuild Projects="@(ProjectReference)"
BuildInParallel="true"
Targets="Build" />BuildInParallel="true" allows the MSBuild task to distribute its project list across available worker nodes. This is the mechanism used by solution builds to parallelize project compilation.
Diagnosing Parallel Build Issues
Parallel builds can surface latent issues that serial builds mask:
1. Race conditions on shared files: Two projects writing to the same output directory simultaneously. Fix: use per-project output directories (the SDK default bin/Debug/$(TargetFramework)/). 2. Undeclared dependencies: Project A depends on Project B's output but does not declare a <ProjectReference>. Serial builds happen to build B first; parallel builds may build A first. Fix: add explicit <ProjectReference>. 3. Directory creation races: Multiple projects creating the same intermediate directory. Fix: use MakeDir task with ContinueOnError="true" or ensure each project uses its own $(IntermediateOutputPath).
Use /m:1 to confirm a build works serially, then /m to check for parallelism issues. Binary logs with timeline view show project scheduling and reveal race conditions.
Build Caching and Restore Optimization
NuGet Restore Optimization
NuGet restore is often the slowest build step, especially in CI. These patterns reduce restore time:
# Locked restore: skip resolution if lock file is current
dotnet restore --locked-mode
# Use lock files for deterministic restores
dotnet restore --use-lock-file<!-- Enable lock files project-wide in Directory.Build.props -->
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>Lock file restore (--locked-mode) skips the dependency resolution algorithm entirely, reading the exact versions from packages.lock.json. This is faster and ensures CI uses the same versions that were tested locally. For lock file and CPM configuration details, see references/project-structure.md.
SDK Build Caching
The .NET SDK caches several build artifacts to avoid redundant work:
| Cache | Location | Purpose |
|---|---|---|
| NuGet global packages | ~/.nuget/packages/ | Downloaded package contents |
| NuGet HTTP cache | ~/.local/share/NuGet/http-cache/ | HTTP response cache for feed queries |
| MSBuild project result cache | In-memory (per build session) | Skips re-evaluating already-built projects |
obj/ intermediate output | Per-project obj/ directory | Compiler state, generated files, timestamps |
CI Build Optimization
# GitHub Actions: cache NuGet packages between runs
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }}
restore-keys: |
nuget-${{ runner.os }}-
# Use locked restore for speed and determinism
- name: Restore
run: dotnet restore --locked-modeNoWarn and TreatWarningsAsErrors Strategy
Build-level warning configuration affects build time when analyzers are involved:
<!-- Directory.Build.props: set warning policy for all projects -->
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Suppress specific warnings globally (with justification) -->
<NoWarn>$(NoWarn);CA2007</NoWarn> <!-- ConfigureAwait: not needed in ASP.NET Core apps -->
</PropertyGroup>Rules for warning configuration:
- Enable
TreatWarningsAsErrorsinDirectory.Build.propsso local and CI builds behave identically - Use
NoWarnsparingly and always with inline justification comments - Prefer
.editorconfigseverity rules overNoWarnfor per-rule control - For detecting misuse of warning suppression, see
references/build-analysis.md
Agent Gotchas
1. Running `dotnet build` without `/bl` when diagnosing build issues. Console output at default verbosity omits critical information about why targets ran. Always capture a binary log (/bl) for diagnosis -- it records everything regardless of console verbosity level.
2. Assuming incremental build works without Inputs/Outputs. A target without Inputs/Outputs runs on every build unconditionally. There is no implicit incrementality in MSBuild -- you must declare what files the target reads and writes. See references/msbuild-authoring.md for the full pattern.
3. Forgetting `SkipUnchangedFiles="true"` on Copy tasks. Without this flag, Copy always updates the destination timestamp, which triggers downstream targets to re-run even when file content is identical.
4. Using `/v:diagnostic` instead of `/bl` for build investigation. Diagnostic verbosity floods the console with thousands of lines and is hard to search. Binary logs contain the same information in a structured, searchable format. Use /bl and the Structured Log Viewer instead.
5. Sharing the `.binlog` file without reviewing it first. Binary logs contain full file paths, environment variable values, and potentially secrets passed via MSBuild properties. Review or sanitize before sharing externally.
6. Assuming `/m` (parallel build) is always faster. For small solutions (fewer than 5 projects), the overhead of spawning worker nodes can exceed the parallelism benefit. Profile with and without /m to confirm. For large solutions, /graph mode provides better scheduling than default /m.
7. Committing `packages.lock.json` without using `--locked-mode` in CI. The lock file is only useful if CI restores in locked mode. Without --locked-mode, NuGet ignores the lock file and resolves normally, defeating the purpose of deterministic restores.
8. Modifying `.csproj` properties to fix build performance without checking the binary log first. Many "slow build" issues are caused by a single non-incremental target, not by global build configuration. Diagnose with /bl before making broad configuration changes.
References
CLI Architecture
Layered CLI application architecture for .NET: command/handler/service separation following clig.dev principles, configuration precedence (appsettings → environment variables → CLI arguments), structured logging in CLI context, exit code conventions, stdin/stdout/stderr patterns, and testing CLI applications via in-process invocation with output capture.
Version assumptions: .NET 8.0+ baseline. Patterns apply to CLI tools built with System.CommandLine 2.0 and generic host.
clig.dev Principles for .NET CLI Tools
The Command Line Interface Guidelines provide language-agnostic principles for well-behaved CLI tools. These translate directly to .NET patterns.
Core Principles
| Principle | Implementation |
|---|---|
| Human-first output by default | Use Console.Out for data, Console.Error for diagnostics |
Machine-readable output with --json | Add a --json global option that switches output format |
| Stderr for status/diagnostics | Logging, progress bars, and prompts go to stderr |
| Stdout for data only | Piped output (`mycli list \ |
| Non-zero exit on failure | Return specific exit codes (see conventions below) |
| Fail early, fail loudly | Validate inputs before doing work |
Respect NO_COLOR | Check Environment.GetEnvironmentVariable("NO_COLOR") |
Support --verbose and --quiet | Global options controlling output verbosity |
Stdout vs Stderr in .NET
// Data output -- goes to stdout (can be piped)
Console.Out.WriteLine(JsonSerializer.Serialize(result, jsonContext.Options));
// Status/diagnostic output -- goes to stderr (user sees it, pipe ignores it)
Console.Error.WriteLine("Processing 42 files...");
// With ILogger (when using hosting)
// ILogger writes to stderr via console provider by default
logger.LogInformation("Connected to {Endpoint}", endpoint);Layered Command → Handler → Service Architecture
Separate CLI concerns into three layers:
┌─────────────────────────────────────┐
│ Commands (System.CommandLine) │ Parse args, wire options
│ ─ RootCommand, Command, Option<T> │
├─────────────────────────────────────┤
│ Handlers (orchestration) │ Coordinate services, format output
│ ─ SetAction delegates / classes │
├─────────────────────────────────────┤
│ Services (business logic) │ Pure logic, no CLI concerns
│ ─ Interfaces + implementations │
└─────────────────────────────────────┘Why Three Layers
- Commands know about CLI syntax (options, arguments, subcommands) but not business logic
- Handlers bridge CLI inputs to service calls and format results for output
- Services contain domain logic and are reusable outside the CLI (tests, libraries, APIs)
Example Structure
src/
MyCli/
MyCli.csproj
Program.cs # RootCommand + SetAction wiring
Commands/
SyncCommandDefinition.cs # Command, options, arguments
Handlers/
SyncHandler.cs # Orchestrates services, formats output
Services/
ISyncService.cs # Business logic interface
SyncService.cs # Implementation (no CLI awareness)
Output/
ConsoleFormatter.cs # Table/JSON output formattingCommand Definition Layer
// Commands/SyncCommandDefinition.cs
public static class SyncCommandDefinition
{
public static readonly Option<Uri> SourceOption = new(
"--source", "Source endpoint URL") { Required = true };
public static readonly Option<bool> DryRunOption = new(
"--dry-run", "Preview changes without applying");
public static Command Create()
{
var command = new Command("sync", "Synchronize data from source");
command.Options.Add(SourceOption);
command.Options.Add(DryRunOption);
return command;
}
}Handler Layer (System.CommandLine 2.0 GA)
In System.CommandLine 2.0, handlers are registered via SetAction on the command. The action receives a ParseResult for accessing option values and a CancellationToken.
// Handlers/SyncHandler.cs
public class SyncHandler
{
private readonly ISyncService _syncService;
private readonly ILogger<SyncHandler> _logger;
public SyncHandler(ISyncService syncService, ILogger<SyncHandler> logger)
{
_syncService = syncService;
_logger = logger;
}
public async Task<int> HandleAsync(
Uri source, bool dryRun, CancellationToken ct)
{
_logger.LogInformation("Syncing from {Source}", source);
var result = await _syncService.SyncAsync(source, dryRun, ct);
if (result.HasErrors)
{
Console.Error.WriteLine($"Sync failed: {result.ErrorMessage}");
return ExitCodes.SyncFailed;
}
Console.Out.WriteLine($"Synced {result.ItemCount} items.");
return ExitCodes.Success;
}
}Wiring Commands to Handlers with SetAction
// Program.cs
var syncCommand = SyncCommandDefinition.Create();
syncCommand.SetAction(async (parseResult, ct) =>
{
var source = parseResult.GetValue(SyncCommandDefinition.SourceOption)!;
var dryRun = parseResult.GetValue(SyncCommandDefinition.DryRunOption);
// Build services (or use a DI container)
var handler = new SyncHandler(syncService, logger);
return await handler.HandleAsync(source, dryRun, ct);
});
var rootCommand = new RootCommand("MyCli tool");
rootCommand.Subcommands.Add(syncCommand);
await rootCommand.InvokeAsync(args);Service Layer
// Services/ISyncService.cs -- no CLI dependency
public interface ISyncService
{
Task<SyncResult> SyncAsync(Uri source, bool dryRun, CancellationToken ct);
}
// Services/SyncService.cs
public class SyncService : ISyncService
{
private readonly HttpClient _httpClient;
public SyncService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<SyncResult> SyncAsync(
Uri source, bool dryRun, CancellationToken ct)
{
// Pure business logic -- testable without CLI infrastructure
var data = await _httpClient.GetFromJsonAsync<SyncData>(source, ct);
// ...
return new SyncResult(ItemCount: data.Items.Length);
}
}Configuration Precedence
CLI tools use a specific configuration precedence (lowest to highest priority):
1. Compiled defaults -- hardcoded fallback values 2. appsettings.json -- shipped with the tool 3. appsettings.{Environment}.json -- environment-specific overrides 4. Environment variables -- set by shell or CI 5. CLI arguments -- explicit user input (highest priority)
Implementation with Configuration
// Build configuration with proper precedence
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true) // Layer 2
.AddJsonFile($"appsettings.{env}.json", optional: true) // Layer 3
.AddEnvironmentVariables() // Layer 4
.Build();
// Layer 4b: User-specific config file
var userConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".mycli", "config.json");
if (File.Exists(userConfigPath))
{
config = new ConfigurationBuilder()
.AddConfiguration(config)
.AddJsonFile(userConfigPath, optional: true)
.Build();
}
// Layer 5: CLI args override everything via ParseResult
syncCommand.SetAction(async (parseResult, ct) =>
{
var source = parseResult.GetValue(sourceOption)
?? new Uri(config["DefaultSource"] ?? "https://fallback.example.com");
// CLI arg > config > compiled default
});User-Level Configuration
Many CLI tools support user-level config (e.g., ~/.mycli/config.json, ~/.config/mycli/config.yaml). Follow platform conventions:
| Platform | Location |
|---|---|
| Linux/macOS | ~/.config/mycli/ or ~/.mycli/ |
| Windows | %APPDATA%\mycli\ |
| XDG-compliant | $XDG_CONFIG_HOME/mycli/ |
Structured Logging in CLI Context
Configuring Logging for CLI
CLI tools need different logging than web apps: logs go to stderr, and verbosity is controlled by flags.
using var loggerFactory = LoggerFactory.Create(logging =>
{
logging.ClearProviders();
logging.AddConsole(options =>
{
// Write to stderr, not stdout
options.LogToStandardErrorThreshold = LogLevel.Trace;
});
});
var logger = loggerFactory.CreateLogger<Program>();Verbosity Mapping
Map --verbose/--quiet flags to log levels:
public static class VerbosityMapping
{
public static LogLevel ToLogLevel(bool verbose, bool quiet) => (verbose, quiet) switch
{
(true, _) => LogLevel.Debug,
(_, true) => LogLevel.Warning,
_ => LogLevel.Information // default
};
}
// Apply verbosity from CLI flags
var verboseOption = new Option<bool>("--verbose", "Enable debug logging");
var quietOption = new Option<bool>("--quiet", "Suppress info logging");
syncCommand.SetAction(async (parseResult, ct) =>
{
var verbose = parseResult.GetValue(verboseOption);
var quiet = parseResult.GetValue(quietOption);
var level = VerbosityMapping.ToLogLevel(verbose, quiet);
using var loggerFactory = LoggerFactory.Create(logging =>
{
logging.SetMinimumLevel(level);
logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
});
// ...
});Exit Code Conventions
Standard Exit Codes
public static class ExitCodes
{
public const int Success = 0;
public const int GeneralError = 1;
public const int InvalidUsage = 2; // Bad arguments or options
public const int IoError = 3; // File not found, permission denied
public const int NetworkError = 4; // Connection failed, timeout
public const int AuthError = 5; // Authentication/authorization failure
// Tool-specific codes start at 10+
public const int SyncFailed = 10;
public const int ValidationFailed = 11;
}Guidelines
- 0 = success (always)
- 1 = general/unspecified error
- 2 = invalid usage (bad arguments) -- System.CommandLine returns this for parse errors automatically
- 3-9 = reserved for common categories
- 10+ = tool-specific error codes
- Never use exit codes > 125 (reserved by shells; 126 = not executable, 127 = not found, 128+N = killed by signal N)
Propagating Exit Codes
syncCommand.SetAction(async (parseResult, ct) =>
{
try
{
await service.ProcessAsync(ct);
return ExitCodes.Success;
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "Network error");
Console.Error.WriteLine($"Error: {ex.Message}");
return ExitCodes.NetworkError;
}
catch (UnauthorizedAccessException ex)
{
Console.Error.WriteLine($"Permission denied: {ex.Message}");
return ExitCodes.IoError;
}
});Stdin/Stdout/Stderr Patterns
Reading from Stdin
Support piped input as an alternative to file arguments:
var inputFileOption = new Option<FileInfo?>("--file", "Input file path");
processCommand.SetAction(async (parseResult, ct) =>
{
var inputFile = parseResult.GetValue(inputFileOption);
string input;
if (inputFile is not null)
{
input = await File.ReadAllTextAsync(inputFile.FullName, ct);
}
else if (Console.IsInputRedirected)
{
// Read from stdin: echo '{"data":1}' | mycli process
input = await Console.In.ReadToEndAsync();
}
else
{
Console.Error.WriteLine("Error: Provide input via --file or stdin.");
return ExitCodes.InvalidUsage;
}
var result = processor.Process(input);
Console.Out.WriteLine(JsonSerializer.Serialize(result));
return ExitCodes.Success;
});Machine-Readable Output
// Global --json option for machine-readable output
var jsonOption = new Option<bool>("--json", "Output as JSON");
rootCommand.Options.Add(jsonOption);
// In handler
if (useJson)
{
Console.Out.WriteLine(JsonSerializer.Serialize(result, jsonContext.Options));
}
else
{
// Human-friendly table format
ConsoleFormatter.WriteTable(result, Console.Out);
}Progress to Stderr
// Progress reporting goes to stderr (does not pollute piped stdout)
await foreach (var item in _service.StreamAsync(ct))
{
Console.Error.Write($"\rProcessing {item.Index}/{total}...");
Console.Out.WriteLine(item.ToJson());
}
Console.Error.WriteLine(); // Clear progress lineTesting CLI Applications
In-Process Invocation with Output Capture
Test the full CLI pipeline without spawning a child process. System.CommandLine 2.0 uses InvocationConfiguration for output capture:
public class CliTestHarness
{
private readonly RootCommand _rootCommand;
public CliTestHarness()
{
_rootCommand = Program.BuildRootCommand();
}
public async Task<(int ExitCode, string Stdout, string Stderr)> InvokeAsync(
string commandLine)
{
var stdoutWriter = new StringWriter();
var stderrWriter = new StringWriter();
var exitCode = await _rootCommand.InvokeAsync(
commandLine,
new InvocationConfiguration
{
Output = stdoutWriter,
Error = stderrWriter
});
return (exitCode, stdoutWriter.ToString(), stderrWriter.ToString());
}
}Testing with Service Mocks
Inject test doubles by building the root command with a factory that accepts service overrides:
[Fact]
public async Task Sync_WithValidSource_ReturnsZero()
{
var fakeSyncService = new FakeSyncService(
new SyncResult(ItemCount: 5));
// Build command with test service
var rootCommand = Program.BuildRootCommand(syncService: fakeSyncService);
var stdoutWriter = new StringWriter();
var exitCode = await rootCommand.InvokeAsync(
"sync --source https://api.example.com",
new InvocationConfiguration { Output = stdoutWriter });
Assert.Equal(0, exitCode);
Assert.Contains("Synced 5 items", stdoutWriter.ToString());
}
[Fact]
public async Task Sync_WithMissingSource_ReturnsNonZero()
{
var rootCommand = Program.BuildRootCommand();
var stderrWriter = new StringWriter();
var exitCode = await rootCommand.InvokeAsync(
"sync",
new InvocationConfiguration { Error = stderrWriter });
Assert.NotEqual(0, exitCode);
Assert.Contains("--source", stderrWriter.ToString());
}Exit Code Assertion
[Theory]
[InlineData("sync --source https://valid.example.com", 0)]
[InlineData("sync", 2)] // Missing required option
[InlineData("invalid-command", 1)]
public async Task ExitCode_MatchesExpected(string args, int expectedExitCode)
{
var rootCommand = Program.BuildRootCommand();
var exitCode = await rootCommand.InvokeAsync(args);
Assert.Equal(expectedExitCode, exitCode);
}Testing Output Format
[Fact]
public async Task List_WithJsonFlag_OutputsValidJson()
{
var fakeRepo = new FakeItemRepository([new Item(1, "Widget")]);
var rootCommand = Program.BuildRootCommand(itemRepository: fakeRepo);
var stdoutWriter = new StringWriter();
var exitCode = await rootCommand.InvokeAsync(
"list --json",
new InvocationConfiguration { Output = stdoutWriter });
Assert.Equal(0, exitCode);
var items = JsonSerializer.Deserialize<Item[]>(stdoutWriter.ToString());
Assert.NotNull(items);
Assert.Single(items);
}
[Fact]
public async Task List_StderrContainsLogs_StdoutContainsDataOnly()
{
var rootCommand = Program.BuildRootCommand();
var stdoutWriter = new StringWriter();
var stderrWriter = new StringWriter();
await rootCommand.InvokeAsync(
"list --json --verbose",
new InvocationConfiguration { Output = stdoutWriter, Error = stderrWriter });
// Stdout must be valid JSON (no log noise)
var doc = JsonDocument.Parse(stdoutWriter.ToString());
Assert.NotNull(doc);
// Stderr contains diagnostic output
Assert.Contains("Connected to", stderrWriter.ToString());
}Agent Gotchas
1. Do not write diagnostic output to stdout. Logs, progress, and errors go to stderr. Stdout is reserved for data output that can be piped. A CLI tool that mixes logs into stdout breaks shell pipelines. 2. Do not hardcode exit code 1 for all errors. Use distinct exit codes for different failure categories (I/O, network, auth, validation). Callers and scripts rely on exit codes to determine what went wrong. 3. Do not put business logic in command handlers. Handlers should orchestrate calls to injected services and format output. Business logic in handlers cannot be reused or unit-tested independently. 4. Do not test CLI tools only via process spawning. Use in-process invocation with RootCommand.InvokeAsync and InvocationConfiguration for fast, reliable tests. Reserve process-level tests for smoke testing the published binary. 5. Do not ignore `Console.IsInputRedirected` when accepting stdin. Without checking, the tool may hang waiting for input when invoked without piped data. 6. Do not use exit codes above 125. Codes 126-255 have special meanings in Unix shells (126 = not executable, 127 = not found, 128+N = killed by signal N). Tool-specific codes should be in the 1-125 range.
References
CLI Distribution and Packaging
CLI distribution strategy and multi-platform packaging for .NET tools: choosing between Native AOT single-file publish, framework-dependent deployment, and dotnet tool packaging. Runtime Identifier (RID) matrix planning for cross-platform targets, single-file publish configuration, binary size optimization, and packaging for Homebrew, apt/deb, winget, Scoop, Chocolatey, and dotnet tool.
Version assumptions: .NET 8.0+ baseline. Native AOT for console apps is fully supported since .NET 8. Package manager formats are stable across .NET versions.
Distribution Strategy Decision Matrix
Choose the distribution model based on target audience and deployment constraints.
| Strategy | Startup Time | Binary Size | Runtime Required | Best For |
|---|---|---|---|---|
| Native AOT single-file | ~10ms | 10-30 MB | None | Performance-critical CLI tools, broad distribution |
| Framework-dependent single-file | ~100ms | 1-5 MB | .NET runtime | Internal tools where runtime is guaranteed |
| Self-contained single-file | ~100ms | 60-80 MB | None | Simple distribution without AOT complexity |
dotnet tool (global/local) | ~200ms | < 1 MB (NuGet) | .NET SDK | Developer tools, .NET ecosystem users |
When to Choose Each Strategy
Native AOT single-file -- the gold standard for CLI distribution:
- Zero dependencies on target machine (no .NET runtime needed)
- Fastest startup (~10ms vs ~100ms+ for JIT)
- Smallest binary when combined with trimming
- Trade-off: longer build times, no reflection unless preserved
- See
references/native-aot.mdfor PublishAot MSBuild configuration
Framework-dependent deployment:
- Smallest artifact size (only app code, no runtime)
- Users must have .NET runtime installed
- Best for internal/enterprise tools where runtime is managed
- Can still use single-file publish for convenience
Self-contained (non-AOT):
- Includes .NET runtime in the artifact
- Larger binary than AOT but simpler build process
- Full reflection and dynamic code support
- Good compromise when AOT compat is difficult
`dotnet tool` packaging:
- Distributed via NuGet -- simplest publishing workflow
- Users install with
dotnet tool install -g mytool - Requires .NET SDK on target (not just runtime)
- Best for developer-facing tools in the .NET ecosystem
Runtime Identifier (RID) Matrix
Standard CLI RID Targets
Target the four primary RIDs for broad coverage:
| RID | Platform | Notes |
|---|---|---|
linux-x64 | Linux x86_64 | Most Linux servers, CI runners, WSL |
linux-arm64 | Linux ARM64 | AWS Graviton, Raspberry Pi 4+, Apple Silicon VMs |
osx-arm64 | macOS Apple Silicon | M1/M2/M3+ Macs (primary macOS target) |
win-x64 | Windows x86_64 | Windows 10+, Windows Server |
Optional Extended Targets
| RID | When to Include |
|---|---|
osx-x64 | Legacy Intel Mac support (declining market share) |
linux-musl-x64 | Alpine Linux / Docker scratch images |
linux-musl-arm64 | Alpine on ARM64 |
win-arm64 | Windows on ARM (Surface Pro X, Snapdragon laptops) |
RID Configuration in .csproj
<!-- Set per publish, not in csproj (avoids accidental RID lock-in) -->
<!-- Use dotnet publish -r <rid> instead -->
<!-- If you must set a default for local development -->
<PropertyGroup Condition="'$(RuntimeIdentifier)' == ''">
<RuntimeIdentifier>osx-arm64</RuntimeIdentifier>
</PropertyGroup>Publish per RID from the command line:
# Publish for each target RID
dotnet publish -c Release -r linux-x64
dotnet publish -c Release -r linux-arm64
dotnet publish -c Release -r osx-arm64
dotnet publish -c Release -r win-x64Single-File Publish
Single-file publish bundles the application and its dependencies into one executable.
Configuration
<PropertyGroup>
<PublishSingleFile>true</PublishSingleFile>
<!-- Required for single-file -->
<SelfContained>true</SelfContained>
<!-- Embed PDB for stack traces (optional, adds ~2-5 MB) -->
<DebugType>embedded</DebugType>
<!-- Include native libraries in the single file -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>Single-File with Native AOT
When combined with Native AOT, single-file is implicit -- AOT always produces a single native binary:
<PropertyGroup>
<PublishAot>true</PublishAot>
<!-- PublishSingleFile is not needed -- AOT output is inherently single-file -->
<!-- SelfContained is implied by PublishAot -->
</PropertyGroup>See references/native-aot.md for the full AOT publish configuration including ILLink, type preservation, and analyzer setup.
Publish Command
# Framework-dependent single-file (requires .NET runtime on target)
dotnet publish -c Release -r linux-x64 /p:PublishSingleFile=true --self-contained false
# Self-contained single-file (includes runtime, no AOT)
dotnet publish -c Release -r linux-x64 /p:PublishSingleFile=true --self-contained true
# Native AOT (inherently single-file, smallest and fastest)
dotnet publish -c Release -r linux-x64
# (when PublishAot=true is in csproj)Size Optimization for CLI Binaries
Trimming (Non-AOT)
Trimming removes unused code from the published output. For self-contained non-AOT builds:
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>link</TrimMode>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
</PropertyGroup>AOT Size Optimization
For Native AOT builds, size is controlled by AOT-specific MSBuild properties. See references/native-aot.md for the full configuration. Key CLI-relevant properties include StripSymbols, OptimizationPreference, InvariantGlobalization, and StackTraceSupport.
Size Comparison (Typical CLI Tool)
| Configuration | Approximate Size |
|---|---|
| Self-contained (no trim) | 60-80 MB |
| Self-contained + trimmed | 15-30 MB |
| Native AOT (default) | 15-25 MB |
| Native AOT + size optimized | 8-15 MB |
| Native AOT + invariant globalization + stripped | 5-10 MB |
| Framework-dependent | 1-5 MB |
Homebrew (macOS / Linux)
Homebrew is the primary package manager for macOS and widely used on Linux. Use a binary tap formula for Native AOT CLI tools.
Binary Tap (Formula)
A formula downloads pre-built binaries per platform:
# Formula/mytool.rb
class Mytool < Formula
desc "A CLI tool for managing widgets"
homepage "https://github.com/myorg/mytool"
version "1.2.3"
license "MIT"
on_macos do
on_arm do
url "https://github.com/myorg/mytool/releases/download/v1.2.3/mytool-1.2.3-osx-arm64.tar.gz"
sha256 "abc123..."
end
on_intel do
url "https://github.com/myorg/mytool/releases/download/v1.2.3/mytool-1.2.3-osx-x64.tar.gz"
sha256 "def456..."
end
end
on_linux do
on_arm do
url "https://github.com/myorg/mytool/releases/download/v1.2.3/mytool-1.2.3-linux-arm64.tar.gz"
sha256 "ghi789..."
end
on_intel do
url "https://github.com/myorg/mytool/releases/download/v1.2.3/mytool-1.2.3-linux-x64.tar.gz"
sha256 "jkl012..."
end
end
def install
bin.install "mytool"
end
test do
assert_match version.to_s, shell_output("#{bin}/mytool --version")
end
endHosting a Tap
Create a repo named homebrew-tap with a Formula/ directory containing the formula. Users install with:
brew tap myorg/tap
brew install mytoolapt/deb (Debian/Ubuntu)
Package Directory Structure
mytool_1.2.3_amd64/
DEBIAN/
control
usr/
bin/
mytoolControl File
Package: mytool
Version: 1.2.3
Section: utils
Priority: optional
Architecture: amd64
Maintainer: My Org <dev@myorg.com>
Description: A CLI tool for managing widgets
MyTool provides fast widget management from the command line.
Built with .NET Native AOT for zero-dependency execution.
Homepage: https://github.com/myorg/mytoolBuild Command
dpkg-deb --build --root-owner-group mytool_1.2.3_amd64RID to Debian architecture mapping:
| .NET RID | Debian Architecture |
|---|---|
linux-x64 | amd64 |
linux-arm64 | arm64 |
winget (Windows Package Manager)
Directory Structure
manifests/
m/
MyOrg/
MyTool/
1.2.3/
MyOrg.MyTool.yaml # Version manifest
MyOrg.MyTool.installer.yaml # Installer manifest
MyOrg.MyTool.locale.en-US.yaml # Locale manifestVersion Manifest (MyOrg.MyTool.yaml)
PackageIdentifier: MyOrg.MyTool
PackageVersion: 1.2.3
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.9.0Installer Manifest (MyOrg.MyTool.installer.yaml)
PackageIdentifier: MyOrg.MyTool
PackageVersion: 1.2.3
InstallerType: zip
NestedInstallerType: portable
NestedInstallerFiles:
- RelativeFilePath: mytool.exe
PortableCommandAlias: mytool
Installers:
- Architecture: x64
InstallerUrl: https://github.com/myorg/mytool/releases/download/v1.2.3/mytool-1.2.3-win-x64.zip
InstallerSha256: ABC123...
- Architecture: arm64
InstallerUrl: https://github.com/myorg/mytool/releases/download/v1.2.3/mytool-1.2.3-win-arm64.zip
InstallerSha256: DEF456...
ManifestType: installer
ManifestVersion: 1.9.0Submitting to winget-pkgs
1. Fork microsoft/winget-pkgs on GitHub 2. Create manifest files in the correct directory structure 3. Validate locally: winget validate --manifest <path> 4. Submit a PR -- automated checks run against the manifest
See references/cli-release-pipeline.md for automating winget PR creation.
Scoop (Windows)
Scoop is popular among Windows power users. Manifests are JSON files in a bucket repository.
{
"version": "1.2.3",
"description": "A CLI tool for managing widgets",
"homepage": "https://github.com/myorg/mytool",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://github.com/myorg/mytool/releases/download/v1.2.3/mytool-1.2.3-win-x64.zip",
"hash": "abc123..."
},
"arm64": {
"url": "https://github.com/myorg/mytool/releases/download/v1.2.3/mytool-1.2.3-win-arm64.zip",
"hash": "def456..."
}
},
"bin": "mytool.exe",
"checkver": {
"github": "https://github.com/myorg/mytool"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/myorg/mytool/releases/download/v$version/mytool-$version-win-x64.zip"
},
"arm64": {
"url": "https://github.com/myorg/mytool/releases/download/v$version/mytool-$version-win-arm64.zip"
}
}
}
}Host in a GitHub repo named scoop-mytool with a bucket/ directory. Users install with:
scoop bucket add myorg https://github.com/myorg/scoop-mytool
scoop install mytoolChocolatey
Package Structure
mytool/
mytool.nuspec
tools/
chocolateyInstall.ps1
LICENSE.txtmytool.nuspec
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.xmldata.org/2004/07/nuspec">
<metadata>
<id>mytool</id>
<version>1.2.3</version>
<title>MyTool</title>
<authors>My Org</authors>
<projectUrl>https://github.com/myorg/mytool</projectUrl>
<license type="expression">MIT</license>
<description>A CLI tool for managing widgets.</description>
<tags>cli dotnet tools</tags>
</metadata>
</package>tools/chocolateyInstall.ps1
$ErrorActionPreference = 'Stop'
$packageArgs = @{
packageName = 'mytool'
url64bit = 'https://github.com/myorg/mytool/releases/download/v1.2.3/mytool-1.2.3-win-x64.zip'
checksum64 = 'ABC123...'
checksumType64 = 'sha256'
unzipLocation = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
}
Install-ChocolateyZipPackage @packageArgsdotnet tool (Global and Local)
dotnet tool is the simplest distribution for .NET developers. Tools are distributed as NuGet packages.
Project Configuration for Tool Packaging
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<!-- Tool packaging properties -->
<PackAsTool>true</PackAsTool>
<ToolCommandName>mytool</ToolCommandName>
<PackageId>MyOrg.MyTool</PackageId>
<Version>1.2.3</Version>
<Description>A CLI tool for managing widgets</Description>
<Authors>My Org</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/myorg/mytool</PackageProjectUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="../../README.md" Pack="true" PackagePath="/" />
</ItemGroup>
</Project>Building and Publishing
# Pack the tool
dotnet pack -c Release
# Publish to NuGet.org
dotnet nuget push bin/Release/MyOrg.MyTool.1.2.3.nupkg \
--source https://api.nuget.org/v3/index.json \
--api-key "$NUGET_API_KEY"Installing dotnet Tools
# Global tool (available system-wide)
dotnet tool install -g MyOrg.MyTool
# Local tool (per-project, tracked in .config/dotnet-tools.json)
dotnet new tool-manifest # first time only
dotnet tool install MyOrg.MyTool
# Update
dotnet tool update -g MyOrg.MyTool
# Run local tool
dotnet tool run mytool
# or just:
dotnet mytoolGlobal vs Local Tools
| Aspect | Global Tool | Local Tool |
|---|---|---|
| Scope | System-wide (per user) | Per-project directory |
| Install location | ~/.dotnet/tools | .config/dotnet-tools.json |
| Version management | Manual update | Tracked in source control |
| CI/CD | Must install before use | dotnet tool restore restores all |
| Best for | Personal productivity tools | Project-specific build tools |
Agent Gotchas
1. Do not set RuntimeIdentifier in the .csproj for multi-platform CLI tools. Hardcoding a RID in the project file prevents building for other platforms. Pass -r <rid> at publish time instead. 2. Do not use PublishSingleFile with PublishAot. Native AOT output is inherently single-file. Setting both is redundant and may cause confusing build warnings. 3. Do not skip InvariantGlobalization for size-sensitive CLI tools. Globalization data adds ~25 MB to AOT binaries. Most CLI tools that do not format locale-specific dates/currencies should enable InvariantGlobalization=true. 4. Do not hardcode SHA-256 hashes in package manifests. Generate checksums from actual release artifacts, not placeholder values. All package managers validate checksums against downloaded files. 5. Do not use `InstallerType: exe` for portable CLI tools in winget. Use InstallerType: zip with NestedInstallerType: portable for standalone executables. The exe type implies an installer with silent flags. 6. Do not forget `PackAsTool` for dotnet tool projects. Without <PackAsTool>true</PackAsTool>, dotnet pack produces a library package, not an installable tool.
References
CLI Release Pipeline
Unified release CI/CD pipeline for .NET CLI tools: GitHub Actions workflow producing all distribution formats from a single version tag trigger, build matrix per Runtime Identifier (RID), artifact staging between jobs, GitHub Releases with SHA-256 checksums, automated Homebrew formula and winget manifest PR creation, and SemVer versioning strategy with git tags.
Version assumptions: .NET 8.0+ baseline. GitHub Actions workflow syntax v2. Patterns apply to any CI system but examples use GitHub Actions.
Versioning Strategy
SemVer + Git Tags
Use Semantic Versioning (SemVer) with git tags as the single source of truth for release versions.
Tag format: v{major}.{minor}.{patch} (e.g., v1.2.3)
# Tag a release
git tag -a v1.2.3 -m "Release v1.2.3"
git push origin v1.2.3Version Flow
git tag v1.2.3
│
▼
GitHub Actions trigger (on push tags: v*)
│
▼
Extract version from tag: GITHUB_REF_NAME → v1.2.3 → 1.2.3
│
▼
Pass to dotnet publish /p:Version=1.2.3
│
▼
Embed in binary (--version output)
│
▼
Stamp in package manifests (Homebrew, winget, Scoop, NuGet)Extracting Version from Tag
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# v1.2.3 → 1.2.3Pre-release Versions
# Pre-release tag
git tag -a v1.3.0-rc.1 -m "Release candidate 1"
# CI detects pre-release and skips package manager submissions
# but still creates GitHub Release as pre-releaseUnified GitHub Actions Workflow
Complete Workflow
name: Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+*" # v1.2.3, v1.2.3-rc.1
permissions:
contents: write # Create GitHub Releases
defaults:
run:
shell: bash
env:
PROJECT: src/MyCli/MyCli.csproj
DOTNET_VERSION: "8.0.x"
jobs:
build:
strategy:
matrix:
include:
- rid: linux-x64
os: ubuntu-latest
- rid: linux-arm64
os: ubuntu-latest
- rid: osx-arm64
os: macos-latest
- rid: win-x64
os: windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Extract version
id: version
shell: bash
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Publish
run: >-
dotnet publish ${{ env.PROJECT }}
-c Release
-r ${{ matrix.rid }}
-o ./publish
/p:Version=${{ steps.version.outputs.version }}
- name: Package (Unix)
if: runner.os != 'Windows'
run: |
set -euo pipefail
cd publish
tar -czf "$GITHUB_WORKSPACE/mytool-${{ steps.version.outputs.version }}-${{ matrix.rid }}.tar.gz" .
- name: Package (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
Compress-Archive -Path "publish/*" `
-DestinationPath "mytool-${{ steps.version.outputs.version }}-${{ matrix.rid }}.zip"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.rid }}
path: |
*.tar.gz
*.zip
release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Extract version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Generate checksums
working-directory: artifacts
run: |
set -euo pipefail
shasum -a 256 *.tar.gz *.zip > checksums-sha256.txt
cat checksums-sha256.txt
- name: Detect pre-release
id: prerelease
run: |
set -euo pipefail
if [[ "${{ steps.version.outputs.version }}" == *-* ]]; then
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
fi
# Pin third-party actions to a commit SHA in production for supply-chain security
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: v${{ steps.version.outputs.version }}
prerelease: ${{ steps.prerelease.outputs.is_prerelease }}
generate_release_notes: true
files: |
artifacts/*.tar.gz
artifacts/*.zip
artifacts/checksums-sha256.txt
publish-nuget:
needs: release
if: ${{ !contains(github.ref_name, '-') }} # Skip pre-releases
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Extract version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Pack
run: >-
dotnet pack ${{ env.PROJECT }}
-c Release
/p:Version=${{ steps.version.outputs.version }}
-o ./nupkgs
- name: Push to NuGet
run: >-
dotnet nuget push ./nupkgs/*.nupkg
--source https://api.nuget.org/v3/index.json
--api-key ${{ secrets.NUGET_API_KEY }}Build Matrix per RID
Matrix Strategy
The build matrix produces one artifact per RID. Each RID runs on the appropriate runner OS.
strategy:
matrix:
include:
- rid: linux-x64
os: ubuntu-latest
- rid: linux-arm64
os: ubuntu-latest # Cross-compile ARM64 on x64 runner
- rid: osx-arm64
os: macos-latest # Native ARM64 runner
- rid: win-x64
os: windows-latestCross-Compilation Notes
- linux-arm64 on ubuntu-latest: .NET supports cross-compilation for managed (non-AOT) builds.
dotnet publish -r linux-arm64on an x64 runner produces a valid ARM64 binary without QEMU. For Native AOT, cross-compiling ARM64 on an x64 runner requires the ARM64 cross-compilation toolchain (gcc-aarch64-linux-gnuor equivalent). Seereferences/native-aot.mdfor cross-compile prerequisites. - osx-arm64: Use
macos-latest(which provides ARM64 runners) for native compilation. Cross-compiling macOS ARM64 from Linux is not supported. - win-x64 on windows-latest: Native compilation on Windows runner.
Extended Matrix (Optional)
strategy:
matrix:
include:
# Primary targets
- rid: linux-x64
os: ubuntu-latest
- rid: linux-arm64
os: ubuntu-latest
- rid: osx-arm64
os: macos-latest
- rid: win-x64
os: windows-latest
# Extended targets
- rid: osx-x64
os: macos-13 # Intel macOS runner
- rid: linux-musl-x64
os: ubuntu-latest # Alpine musl cross-compileArtifact Staging
Upload Per-RID Artifacts
Each matrix job uploads its artifact with a RID-specific name:
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.rid }}
path: |
*.tar.gz
*.zip
retention-days: 1 # Short retention -- artifacts are published to GitHub ReleasesDownload in Release Job
The release job downloads all artifacts from the build matrix:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true # Merge all release-* artifacts into one directoryAfter download, artifacts/ contains:
artifacts/
mytool-1.2.3-linux-x64.tar.gz
mytool-1.2.3-linux-arm64.tar.gz
mytool-1.2.3-osx-arm64.tar.gz
mytool-1.2.3-win-x64.zipGitHub Releases with Checksums
Checksum Generation
- name: Generate checksums
working-directory: artifacts
run: |
set -euo pipefail
shasum -a 256 *.tar.gz *.zip > checksums-sha256.txt
cat checksums-sha256.txtOutput format (checksums-sha256.txt):
abc123... mytool-1.2.3-linux-x64.tar.gz
def456... mytool-1.2.3-linux-arm64.tar.gz
ghi789... mytool-1.2.3-osx-arm64.tar.gz
jkl012... mytool-1.2.3-win-x64.zipCreating the Release
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: v${{ steps.version.outputs.version }}
prerelease: ${{ steps.prerelease.outputs.is_prerelease }}
generate_release_notes: true
files: |
artifacts/*.tar.gz
artifacts/*.zip
artifacts/checksums-sha256.txtgenerate_release_notes: true auto-generates release notes from merged PRs and commit messages since the last tag.
Automated Formula/Manifest PR Creation
Homebrew Formula Update
After the GitHub Release is published, update the Homebrew tap automatically:
update-homebrew:
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Extract version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
repository: myorg/homebrew-tap
token: ${{ secrets.TAP_GITHUB_TOKEN }}
- name: Download checksums
run: |
set -euo pipefail
curl -sL "https://github.com/myorg/mytool/releases/download/v${{ steps.version.outputs.version }}/checksums-sha256.txt" \
-o checksums.txt
- name: Update formula
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
LINUX_X64_SHA=$(grep "linux-x64" checksums.txt | awk '{print $1}')
LINUX_ARM64_SHA=$(grep "linux-arm64" checksums.txt | awk '{print $1}')
OSX_ARM64_SHA=$(grep "osx-arm64" checksums.txt | awk '{print $1}')
# Use sed or a templating script to update Formula/mytool.rb
# with new version and SHA-256 values
python3 scripts/update-formula.py \
--version "$VERSION" \
--linux-x64-sha "$LINUX_X64_SHA" \
--linux-arm64-sha "$LINUX_ARM64_SHA" \
--osx-arm64-sha "$OSX_ARM64_SHA"
- name: Create PR
uses: peter-evans/create-pull-request@v6
with:
title: "mytool ${{ steps.version.outputs.version }}"
commit-message: "Update mytool to ${{ steps.version.outputs.version }}"
branch: "update-mytool-${{ steps.version.outputs.version }}"
body: |
Automated update for mytool v${{ steps.version.outputs.version }}
Release: https://github.com/myorg/mytool/releases/tag/v${{ steps.version.outputs.version }}winget Manifest Update
update-winget:
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: windows-latest
steps:
- name: Extract version
id: version
shell: bash
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Submit to winget-pkgs
uses: vedantmgoyal9/winget-releaser@main
with:
identifier: MyOrg.MyTool
version: ${{ steps.version.outputs.version }}
installers-regex: '\.zip$'
token: ${{ secrets.WINGET_GITHUB_TOKEN }}Scoop Manifest Update
update-scoop:
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Extract version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
repository: myorg/scoop-mytool
token: ${{ secrets.SCOOP_GITHUB_TOKEN }}
- name: Download checksums
run: |
set -euo pipefail
curl -sL "https://github.com/myorg/mytool/releases/download/v${{ steps.version.outputs.version }}/checksums-sha256.txt" \
-o checksums.txt
- name: Update manifest
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
WIN_X64_SHA=$(grep "win-x64" checksums.txt | awk '{print $1}')
# Update bucket/mytool.json with new version and hash
jq --arg v "$VERSION" --arg h "$WIN_X64_SHA" \
'.version = $v | .architecture."64bit".hash = $h |
.architecture."64bit".url = "https://github.com/myorg/mytool/releases/download/v\($v)/mytool-\($v)-win-x64.zip"' \
bucket/mytool.json > tmp.json && mv tmp.json bucket/mytool.json
- name: Create PR
uses: peter-evans/create-pull-request@v6
with:
title: "mytool ${{ steps.version.outputs.version }}"
commit-message: "Update mytool to ${{ steps.version.outputs.version }}"
branch: "update-mytool-${{ steps.version.outputs.version }}"Versioning Strategy Details
SemVer for CLI Tools
| Change Type | Version Bump | Example |
|---|---|---|
| Breaking CLI flag rename/removal | Major | 1.x.x -> 2.0.0 |
| New command or option | Minor | x.1.x -> x.2.0 |
| Bug fix, performance improvement | Patch | x.x.1 -> x.x.2 |
| Release candidate | Pre-release suffix | x.x.x-rc.1 |
Version Embedding
The version flows from the git tag through dotnet publish into the binary:
<!-- .csproj -- Version is set at publish time via /p:Version -->
<PropertyGroup>
<!-- Fallback version for local development -->
<Version>0.0.0-dev</Version>
</PropertyGroup># --version output matches the git tag
$ mytool --version
1.2.3Tagging Workflow
# 1. Update CHANGELOG.md (if applicable)
# 2. Commit the changelog
git commit -am "docs: update changelog for v1.2.3"
# 3. Tag the release
git tag -a v1.2.3 -m "Release v1.2.3"
# 4. Push tag -- triggers the release workflow
git push origin v1.2.3Workflow Security
Secret Management
# Required repository secrets:
# NUGET_API_KEY - NuGet.org API key for package publishing
# TAP_GITHUB_TOKEN - PAT with repo scope for homebrew-tap
# WINGET_GITHUB_TOKEN - PAT with public_repo scope for winget-pkgs PRs
# SCOOP_GITHUB_TOKEN - PAT with repo scope for scoop bucket
# CHOCO_API_KEY - Chocolatey API key for package pushPermissions
permissions:
contents: write # Minimum: create GitHub Releases and upload assetsUse job-level permissions when different jobs need different scopes. Never grant write-all.
Agent Gotchas
1. Do not use `set -e` without `set -o pipefail` in GitHub Actions bash steps. Without pipefail, a failing command piped to tee or another utility exits 0, masking the failure. Always use set -euo pipefail. 2. Do not hardcode the .NET version in the publish path. Use dotnet publish -o ./publish to control the output directory explicitly. Hardcoding net8.0 in artifact paths breaks when upgrading to .NET 9+. 3. Do not skip the pre-release detection step. Package manager submissions (Homebrew, winget, Scoop, Chocolatey, NuGet) must be gated on stable versions. Publishing a -rc.1 to winget-pkgs or NuGet as stable causes user confusion. 4. Do not use `actions/upload-artifact` v3 with `merge-multiple`. The merge-multiple parameter requires actions/download-artifact@v4. Using v3 silently ignores the flag and creates nested directories. 5. Do not forget `retention-days: 1` on intermediate build artifacts. Release artifacts are published to GitHub Releases (permanent). Workflow artifacts are temporary and should expire quickly to save storage. 6. Do not create GitHub Releases with `gh release create` in a matrix job. Only the release job (after all builds complete) should create the release. Matrix jobs upload artifacts; the release job assembles them.