Distributing Roslyn Analyzers as NuGet Packages in .NET
You've built a working Roslyn analyzer. Your diagnostic rules fire correctly, your code fixes apply cleanly, and your tests pass. Now comes the part that determines whether your analyzer actually helps anyone -- packaging it as a roslyn analyzer nuget package and distributing it so other developers can drop it into their projects without friction.
A roslyn analyzer nuget package has specific requirements that differ from regular library packages. Get the structure wrong and your analyzer silently does nothing when installed. Get the metadata wrong and consumers can't figure out what your rules do. Get versioning wrong and a patch release breaks builds across a team.
This guide covers everything you need to ship a production-quality roslyn analyzer nuget package in .NET 10: the directory structure the Roslyn compiler expects, the <PrivateAssets> configuration that keeps your analyzer nuget package out of consumers' dependency graphs, multi-targeting for broad compatibility, local testing workflows, CI/CD automation, and a versioning strategy that keeps breaking changes predictable.
Before diving in, make sure you have a working, tested analyzer. Check out the Roslyn Analyzers guide for the full picture, read up on building your analyzer for first-time implementation details, and review testing before distribution to confirm your diagnostics are solid before any analyzer nuget package goes out.
Roslyn Analyzer NuGet Package Structure
The Roslyn compiler looks for analyzer assemblies in a specific location inside a NuGet package. Understanding this structure is the first step to building a roslyn analyzer nuget package that actually works on install. The convention is:
analyzers/dotnet/cs/YourAnalyzer.dll
This path is not negotiable. If your DLL lands anywhere else, the compiler won't pick it up. The folder structure breaks down as:
analyzers/-- the root folder for all compile-time toolingdotnet/-- targets the .NET Roslyn host (as opposed to the Mono host)cs/-- targets C# (usevb/for Visual Basic analyzers, or include both)
Here's how you configure your .csproj to produce this exact layout when packed:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>true</IsPackable>
<!-- Prevents the analyzer DLL from being added to the lib/ folder -->
<IncludeBuildOutput>false</IncludeBuildOutput>
</PropertyGroup>
<ItemGroup>
<!-- Pack the compiled analyzer DLL into the Roslyn-expected path -->
<None Include="$(TargetPath)"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup>
</Project>
The IncludeBuildOutput=false property is critical. Without it, NuGet places your analyzer DLL in the lib/ folder in addition to analyzers/dotnet/cs/, which makes it a compile-time reference in consuming projects. Analyzer assemblies must never appear as runtime references -- they run inside the compiler host, not in the consumer's application.
.props and .targets Files
Some analyzer packages need to inject properties or targets into the consuming project's MSBuild pipeline. Common use cases include configuring additional files the analyzer reads, setting default severity levels, or registering your analyzer DLL as an <Analyzer> item in environments where SDK-style project auto-discovery doesn't work reliably.
Place these files in the build/ folder of your package. The file names must exactly match your <PackageId>:
<ItemGroup>
<None Include="buildYourAnalyzer.props"
Pack="true"
PackagePath="build" />
<None Include="buildYourAnalyzer.targets"
Pack="true"
PackagePath="build" />
</ItemGroup>
NuGet automatically imports build/{PackageId}.props at the beginning of the consuming project build and build/{PackageId}.targets near the end. A minimal .targets file that guarantees analyzer registration looks like this:
<Project>
<ItemGroup>
<Analyzer
Include="$(MSBuildThisFileDirectory)..analyzersdotnetcsYourAnalyzer.dll"
Condition="Exists('$(MSBuildThisFileDirectory)..analyzersdotnetcsYourAnalyzer.dll')" />
</ItemGroup>
</Project>
Modern SDK-style projects handle analyzer registration automatically via the analyzers/dotnet/cs/ path convention. Including the explicit .targets file makes your roslyn analyzer nuget package robust in older project formats and non-standard build environments.
PrivateAssets and DevelopmentDependency for Roslyn Analyzer NuGet Packages
This is the configuration step most developers skip -- and then wonder why their roslyn analyzer nuget package appears as a transitive dependency in unrelated projects.
When a project installs your analyzer nuget package, NuGet needs to understand two things:
- Which assets from your package to use in this project
- Whether those assets should flow to projects that transitively depend on this project
Analyzers are development-time tools. They must not travel down the dependency graph to projects that don't explicitly want them. Here's the correct <PackageReference> configuration for consuming projects:
<PackageReference Include="YourCompany.YourAnalyzer" Version="1.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PrivateAssets>all</PrivateAssets>prevents the package from appearing in the consuming project's generated.nuspec, so downstream projects never inherit it transitively.<IncludeAssets>explicitly allows the analyzer and build assets to be consumed, while excludingcompile-- which would incorrectly make your analyzer DLL a compile-time assembly reference in the consumer's code.
Baking It Into the Package
Rather than requiring every consumer to remember this boilerplate, set <DevelopmentDependency>true</DevelopmentDependency> in your packaging project:
<PropertyGroup>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
This adds <developmentDependency>true</developmentDependency> to the generated .nuspec. When a developer adds your package through Visual Studio's Package Manager UI or dotnet add package, the tooling automatically inserts <PrivateAssets>all</PrivateAssets> into their project file. That small addition saves every consumer from having to know the nuance -- and prevents the most common misuse of analyzer nuget packages in the wild.
Multi-Targeting Your Roslyn Analyzer NuGet Package
Roslyn analyzer assemblies should target netstandard2.0. This is the strongly enforced convention: the Roslyn compiler host requires the analyzer DLL to be loadable within a netstandard2.0 context, and targeting anything broader will silently prevent your rules from running.
This constraint becomes complex when your analyzer project shares helper code with a companion runtime library (for example, base classes that consuming projects extend, with your analyzer validating correct usage). The clean solution is the split-project pattern.
The Split-Project Pattern
Separate your solution into two projects:
Project 1: The analyzer implementation -- targets netstandard2.0 only, contains all Roslyn-specific code.
<!-- YourAnalyzer.Core.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- Pin to the Roslyn version that ships with your target SDK; for .NET 10 this is ~4.13.x -- verify with dotnet --version -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
Project 2: The NuGet packaging project -- builds the .nupkg, references the core analyzer project to ensure it compiles first, then picks up the output DLL.
<!-- YourAnalyzer.csproj (packaging project) -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>true</IsPackable>
<IncludeBuildOutput>false</IncludeBuildOutput>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
<!-- Build dependency only -- do NOT add as a compile reference -->
<ProjectReference
Include="..YourAnalyzer.CoreYourAnalyzer.Core.csproj"
ReferenceOutputAssembly="false" />
<!-- Pick up the compiled DLL and place it at the NuGet-expected path -->
<None
Include="$(OutputPath)..YourAnalyzer.Core
etstandard2.0YourAnalyzer.Core.dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup>
</Project>
ReferenceOutputAssembly="false" tells MSBuild to build the core project (guaranteeing it compiles before the packaging project runs) without adding its output to the packaging project's own assembly references. Without this flag you'd get compile errors because the packaging project's IncludeBuildOutput=false prevents it from resolving analyzer types at compile time.
Any companion runtime library -- the classes consumers actually instantiate in their code -- lives in a separate project that targets net10.0 or whichever framework your runtime requirements dictate. It gets its own NuGet package. Do not mix runtime references into a roslyn analyzer nuget package.
NuGet Package Metadata Best Practices for Analyzer Packages
A well-crafted roslyn analyzer nuget package is self-documenting. Developers should understand what it enforces and why they want it before they've written a single line of code that triggers a diagnostic. Poor metadata is one of the top reasons teams uninstall or ignore analyzer packages -- the rules fire but no one knows what they mean.
Here is a complete metadata block for your packaging project:
<PropertyGroup>
<PackageId>YourCompany.YourAnalyzer</PackageId>
<Version>1.0.0</Version>
<Authors>Your Name</Authors>
<Description>
Roslyn analyzer for C# that enforces [your rules]. Provides actionable
diagnostics and one-click code fixes for [specific pattern]. Targets .NET 10
and all netstandard2.0-compatible projects.
</Description>
<PackageTags>roslyn;analyzer;csharp;dotnet;code-quality;nuget</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageIcon>icon.png</PackageIcon>
<PackageProjectUrl>https://github.com/yourorg/youranalyzer</PackageProjectUrl>
<RepositoryUrl>https://github.com/yourorg/youranalyzer</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageType>Analyzer</PackageType>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="....README.md" Pack="true" PackagePath="" />
<None Include="....icon.png" Pack="true" PackagePath="" />
</ItemGroup>
The <PackageType>Analyzer</PackageType> setting signals to NuGet tooling and Visual Studio that this is a Roslyn analyzer package. It enables the "Analyzers" node in Solution Explorer where consumers can see your active rules, adjust default severities, and review diagnostic descriptions without leaving the IDE.
Your package README should include these sections at minimum:
- What it enforces -- a plain-English summary of the rules your analyzer implements
- Diagnostic reference table -- columns for Diagnostic ID, description, default severity, and whether a code fix is available
- Installation snippet -- the full
<PackageReference>block with<PrivateAssets>all</PrivateAssets>pre-filled - Suppression guide -- examples of
#pragma warning disable,[SuppressMessage], and.editorconfigconfigurations
The diagnostic ID reference table is the section most developers skip in their documentation. It is also the first thing a developer searches for when they encounter an unexplained error code in a CI log. Providing it upfront dramatically reduces the friction of adopting your package.
Testing Your Roslyn Analyzer NuGet Package Locally Before Publish
Never push to a feed -- public or private -- without first verifying the installed package experience locally. The combination of dotnet pack and a local NuGet feed catches packaging mistakes before they affect anyone else. This step is especially important for a roslyn analyzer nuget package because installation failures are often silent -- no error, just no diagnostics.
Setting Up a Local NuGet Feed
Create a directory to serve as a local package source:
mkdir C:LocalNuGet
Register it in a NuGet.Config file at the root of your solution:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="LocalFeed" value="C:LocalNuGet" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
Pack, Install, and Verify
Build and pack with a pre-release version so you can distinguish test packages from released ones:
dotnet pack src/YourAnalyzer/YourAnalyzer.csproj
--configuration Release
--output C:LocalNuGet
/p:Version=1.0.0-local
In a separate consumer test project, add the reference:
dotnet add package YourCompany.YourAnalyzer
--version 1.0.0-local
--source C:LocalNuGet
Force a full build to ensure analyzer execution isn't cached:
dotnet build --no-incremental
The --no-incremental flag matters here. Roslyn analyzers execute as part of the build pipeline, and incremental builds cache prior output. A cached build may not re-run your analyzers even if the package changed, producing false-negative test results.
Before calling the local test complete, verify each of the following manually:
- Diagnostics fire on intentionally bad code patterns
- Diagnostics do not fire on clean, conformant code
- Code fixes appear in the IDE and apply the expected transformation
- The package does not appear as a transitive dependency in projects that reference the consumer project
- Your analyzer DLL does not appear in the consumer's
bin/output directory
If the DLL shows up in bin/, your IncludeBuildOutput setting or PackagePath is misconfigured. Inspect the .nupkg by renaming it to .zip and extracting it -- your DLL should appear only at analyzers/dotnet/cs/ with nothing under lib/.
Publishing Your Roslyn Analyzer NuGet Package to NuGet.org and Private Feeds
API Key Management
For NuGet.org, create a scoped API key at https://www.nuget.org/account/apikeys. Scope it to your specific package ID pattern rather than a wildcard. Store it as a GitHub Actions secret (NUGET_API_KEY) and never commit it to source code or include it in any log output.
For private feeds -- GitHub Packages or Azure Artifacts -- use feed-scoped personal access tokens or service principal credentials. The principle is the same regardless of provider: minimum privilege, stored as a secret, rotated on a schedule.
GitHub Actions Workflow for Automated Publish
The workflow below triggers on a version tag push, runs tests, packs the package, and pushes to NuGet.org:
name: Publish NuGet Package
on:
push:
tags:
- 'v*.*.*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Run tests
run: dotnet test --configuration Release --no-build --verbosity normal
- name: Pack
run: |
dotnet pack src/YourAnalyzer/YourAnalyzer.csproj
--configuration Release
--no-build
--output ./artifacts
/p:Version=${{ steps.version.outputs.VERSION }}
- name: Publish to NuGet.org
run: |
dotnet nuget push ./artifacts/*.nupkg
--api-key ${{ secrets.NUGET_API_KEY }}
--source https://api.nuget.org/v3/index.json
--skip-duplicate
- name: Publish symbol package
run: |
dotnet nuget push ./artifacts/*.snupkg
--api-key ${{ secrets.NUGET_API_KEY }}
--source https://api.nuget.org/v3/index.json
--skip-duplicate
The --skip-duplicate flag prevents the workflow from failing if the version was already pushed -- useful when re-running a failed workflow after fixing a non-packaging issue.
For private feeds on GitHub Packages, replace the publish steps with:
- name: Authenticate to GitHub Packages
run: |
dotnet nuget add source
--username ${{ github.actor }}
--password ${{ secrets.GITHUB_TOKEN }}
--store-password-in-clear-text
--name github
"https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json"
- name: Publish to GitHub Packages
run: |
dotnet nuget push ./artifacts/*.nupkg
--source github
--skip-duplicate
GITHUB_TOKEN is automatically injected by the Actions runner with no configuration required. No PAT rotation needed.
Versioning and Backward Compatibility for Roslyn Analyzer NuGet Packages
Semantic versioning for a roslyn analyzer nuget package has a wrinkle that regular library packages don't: diagnostic IDs are part of your public API.
When a consumer suppresses one of your diagnostics:
#pragma warning disable YOURCO001 // Known false positive in this context
SomeProblematiccall();
#pragma warning restore YOURCO001
Or configures it in .editorconfig:
dotnet_diagnostic.YOURCO001.severity = warning
-- the string YOURCO001 is embedded in their codebase. If you rename, remove, or renumber that ID in any release, their suppression silently stops working. The rule runs again on code they intentionally excluded, and there is no compiler error to signal what happened. It just breaks.
Semantic Versioning for Analyzer Rules
Apply SemVer to analyzer packages using this mapping:
| Change type | Version bump | Rationale |
|---|---|---|
| New diagnostic rule added | Minor (1.1.0) | Additive but can turn clean builds red |
| Diagnostic ID renamed or removed | Major (2.0.0) | Suppressions and configs silently break |
| Severity lowered for existing rule | Patch (1.0.1) | Conservative relaxation, never breaks builds |
| Severity raised for existing rule | Minor (1.1.0) | Can break CI pipelines set to treat warnings as errors |
| Bug fix in detection logic | Patch (1.0.1) | Fixes false positives or missed violations |
| Code fix added for existing rule | Patch (1.0.1) | Purely additive, no behavior change |
Handling Breaking Rule Changes
When you must change or remove a diagnostic ID -- whether you're consolidating rules, improving naming consistency, or splitting one broad rule into several focused ones -- a clean migration approach looks like this:
- In the minor version before the breaking major release, mark the old ID as obsolete in its description and emit both the old ID and the new ID simultaneously.
- In the major version, remove the old ID and update all documentation.
- Ship a migration snippet in your changelog that consumers can apply to their
.editorconfigfiles to translate old suppressions to the new IDs.
This gives teams a full release cycle to migrate without having their builds break unexpectedly. Organizations running your analyzer across dozens of repositories will appreciate the window.
These versioning principles connect directly to broader design thinking. The SOLID Principles C# Guide covers the open/closed principle, which maps naturally to how you evolve diagnostic rules -- extend behavior through new rules rather than modifying existing ones in ways that break consumers.
If you're curious about the mechanics of how the Roslyn compiler host discovers and loads your analyzer assembly, the pattern is similar to .NET's general approach to compile-time extensibility -- the C# Reflection vs Source Generators in .NET 10 guide explores this broader landscape and explains where Roslyn analyzers fit relative to source generators.
Frequently Asked Questions
What does the analyzers/dotnet/cs/ folder do in a roslyn analyzer nuget package?
It tells the Roslyn compiler where to find your analyzer DLL. When NuGet installs your package, MSBuild automatically registers any DLL at that path as a Roslyn analyzer reference in the consuming project. If your DLL lands anywhere else -- including lib/ -- the compiler treats it as a regular library reference or ignores it entirely. Your diagnostics will not fire.
Why do I need PrivateAssets=all on my analyzer package reference?
Without <PrivateAssets>all</PrivateAssets>, NuGet treats your analyzer as a normal library dependency. Any project that references the consuming project inherits your analyzer package as a transitive dependency, which pollutes its dependency graph with a compile-time-only tool that it never opted into running. Marking it private keeps the analyzer contained to the project that explicitly installed it.
Can I target net10.0 instead of netstandard2.0 for my analyzer assembly?
No. The Roslyn compiler host targets netstandard2.0, and analyzer DLLs are loaded directly into the compiler's process. If your analyzer DLL targets net10.0, the compiler can't load it into a netstandard2.0 host. The result is silent: no diagnostics, no error, the analyzer simply doesn't run. Always target netstandard2.0 for the analyzer assembly. A companion runtime library that consuming projects use in their own code can target net10.0 independently.
What happens if I change a diagnostic ID in a patch release?
Any consumer who suppressed that diagnostic by ID -- via #pragma warning disable, [SuppressMessage], or .editorconfig -- will lose their suppression silently. Code that was intentionally excluded from enforcement starts failing rules again. From a SemVer perspective, changing or removing a diagnostic ID is always a major breaking change, regardless of how minor the detection logic change was.
How do I inspect my .nupkg to verify the package structure before publishing?
Rename the .nupkg file to .zip and extract it with any archive tool. Your analyzer DLL should appear at analyzers/dotnet/cs/YourAnalyzer.dll. There should be nothing under lib/. If you included .targets and .props files, they should be under build/. The NuGet Package Explorer GUI tool (available on Windows via winget) lets you inspect the package visually without renaming the file.
Should I ship a symbol package (.snupkg) with my roslyn analyzer nuget package?
Yes, if you publish to NuGet.org. Symbol packages allow developers to step into your analyzer code in the debugger to understand exactly why a diagnostic is or isn't firing on a particular code pattern. Add <IncludeSymbols>true</IncludeSymbols> and <SymbolPackageFormat>snupkg</SymbolPackageFormat> to your packaging project and push both the .nupkg and .snupkg files in your CI workflow.
Conclusion
Shipping a roslyn analyzer nuget package that works reliably across teams -- and stays reliable as your rules evolve -- requires getting several non-obvious details right simultaneously: the analyzers/dotnet/cs/ folder path, IncludeBuildOutput=false, the <PrivateAssets>all</PrivateAssets> development dependency configuration, netstandard2.0 targeting for the analyzer assembly, and a versioning strategy that treats diagnostic IDs as first-class public API.
The workflow covered here -- structuring the package correctly, setting development dependency metadata, splitting projects for multi-targeting compatibility, testing locally with a real feed before publishing, automating publish via a tag-triggered GitHub Actions pipeline, and handling breaking rule changes with a migration window -- gives you a complete, production-grade deployment process for your analyzer nuget package in .NET 10.
Combined with building your analyzer and testing before distribution, you now have the full path from first diagnostic prototype to published package. The Roslyn Analyzers guide provides the complete end-to-end reference when you need to revisit any stage.
About the author: Nick Cosentino is a Principal Engineering Manager at Microsoft and the creator of Dev Leader. He writes about C#, .NET, software architecture, and engineering leadership. Find him on YouTube and LinkedIn.

