Incremental Source Generators Done Right: Ship It Without Breaking Consumers
This is part 4 of 4, and it is the part where everything can still go wrong. Part 1 established the equality contract, part 2 shaped the pipeline, and part 3 proved the cache hits with a test the driver cannot lie to. At this point the generator is correct, incremental, and regression-protected — on your machine.
Then you pack it, someone installs the package, and nothing happens. No generated code, no error, no diagnostic. Or worse: it works in dotnet build and silently does nothing in Visual Studio. Every one of these failures is a packaging failure, and every one of them is invisible in your own repository because a ProjectReference with OutputItemType="Analyzer" hides all of it. The package is the product. Let us get it right.
netstandard2.0 Is Non-Negotiable
Your generator is a plugin loaded into the compiler process, and you do not control where that process runs. dotnet build hosts Roslyn on modern .NET. Visual Studio hosts it on .NET Framework 4.7.2+. MSBuild.exe in classic CI pipelines: also .NET Framework. The only TFM loadable in all of these hosts is netstandard2.0. Target anything else and your generator works in exactly the environments you tested and fails in the rest — typically by not loading at all, with a warning buried in the build output that nobody reads.
Here is the project file for AutoToString.Generators — the package that ships part 2’s AutoToStringGenerator — and the minimum viable configuration I would accept:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Enables the F5 debugging experience, see below. -->
<IsRoslynComponent>true</IsRoslynComponent>
<!-- Opts into RS1035 and friends: bans APIs that are
unreliable or forbidden inside the compiler. -->
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp"
Version="4.8.0"
PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers"
Version="3.11.0"
PrivateAssets="all" />
</ItemGroup>
</Project>
Two details deserve emphasis. LangVersion is decoupled from the TFM — netstandard2.0 defaults to C# 7.3, but the language version is a compiler switch, so latest gives you records, pattern matching, and raw string literals while still producing a netstandard2.0 assembly. And PrivateAssets="all" on both Roslyn packages is mandatory: the compiler provides Microsoft.CodeAnalysis at runtime, and if your package declares it as a NuGet dependency, every consumer inherits a dependency on a specific Roslyn version they never asked for. The Microsoft.CodeAnalysis.CSharp version you compile against is also your minimum supported compiler — 4.8.0 means SDK 8.0.100 or newer, so pick it deliberately, not by taking the latest.
The Package Layout: analyzers/dotnet/cs or Nothing
NuGet decides what your DLL is by where it sits in the package. Assemblies under lib/<tfm>/ are compile-time and runtime references. Generators and analyzers live under analyzers/dotnet/cs/ — the conventional path for C#-only generators. (NuGet also loads assemblies from the language-agnostic analyzers/dotnet/ folder, but a C# generator has no business being offered to VB compilations, so use the cs subfolder.) The SDK’s restore targets read that folder to populate the @(Analyzer) item group that gets passed to the compiler.
The default dotnet pack puts your build output into lib/netstandard2.0/, which is precisely wrong twice over. The consumer gets your generator assembly as a runtime dependency — it ships to their bin folder and their deployment, referencing Roslyn types that may not resolve. And the compiler never receives it as an analyzer, so the generator simply never runs. No error. The user’s [AutoToString] attribute (from part 2) does nothing, and the first symptom is a compile error about a missing generated member.
The fix is two moves in the csproj: suppress the default placement, then pack the DLL explicitly into the analyzer path.
<PropertyGroup>
<!-- Keep the build output out of lib/. -->
<IncludeBuildOutput>false</IncludeBuildOutput>
<!-- Suppress NU5128: no lib/ folder is intentional here. -->
<NoWarn>$(NoWarn);NU5128</NoWarn>
</PropertyGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup>
Run dotnet pack and inspect the result — a .nupkg is a zip archive, so any archive tool shows you the truth:
AutoToString.Generators.1.0.0.nupkg
├── analyzers/
│ └── dotnet/
│ └── cs/
│ └── AutoToString.Generators.dll ← the compiler loads this
├── AutoToString.Generators.nuspec
└── [no lib/ folder — on purpose]
I verify this layout in CI with a script that unzips the package and asserts the path exists. It is a five-line check that has caught more release-breaking mistakes than any other test in my generator repositories.
Dependencies Are Your Problem, Not NuGet’s
Here is the rule that surprises everyone once: the compiler does not restore your generator’s NuGet dependencies. When Roslyn loads analyzers/dotnet/cs/AutoToString.Generators.dll, it loads the assemblies in that folder — and nothing else. A PackageReference to Newtonsoft.Json in your generator project restores fine on your machine, but in the consumer’s build there is no restore step for analyzer dependencies. Your generator throws FileNotFoundException inside the compiler, which surfaces as a cryptic CS8785 warning about a generator failing, and the consumer blames you. Correctly.
The mechanical fix: pack the dependency’s DLL into the same analyzer folder, and tell Roslyn’s project system about it for the in-IDE case. GeneratePathProperty="true" on the PackageReference gives you an MSBuild property pointing into the restored package (the pattern is $(PKG<PackageId>) with dots replaced by underscores):
<ItemGroup>
<PackageReference Include="Newtonsoft.Json"
Version="13.0.3"
PrivateAssets="all"
GeneratePathProperty="true" />
</ItemGroup>
<PropertyGroup>
<GetTargetPathDependsOn>
$(GetTargetPathDependsOn);GetDependencyTargetPaths
</GetTargetPathDependsOn>
</PropertyGroup>
<!-- Makes the dependency visible to the consuming project's
analyzer set when referenced via ProjectReference. -->
<Target Name="GetDependencyTargetPaths">
<ItemGroup>
<TargetPathWithTargetPlatformMoniker
Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll"
IncludeRuntimeDependency="false" />
</ItemGroup>
</Target>
<!-- And into the package, next to the generator itself. -->
<ItemGroup>
<None Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup>
This works, and it is also a trap with a bow on it. All analyzers in a consumer’s build share a load context per host — if two generator packages each bundle a different version of the same dependency, one of them loses, non-deterministically, depending on load order. This is not theoretical; it is a recurring issue label in half the generator repositories on GitHub.
Which is why my actual advice is blunter: the correct number of dependencies for a source generator is zero. You are generating strings from syntax. You do not need a JSON library — part 2’s AdditionalTexts pattern can parse a config file with fifty lines of hand-rolled code that never version-conflicts with anyone. Every dependency you bundle is a collision waiting for a consumer whose build you cannot see.
Delivering the Marker Attribute
Your generator triggers on an attribute. Somebody has to define it, and you have exactly two good options.
Option 1: generate it, via RegisterPostInitializationOutput as shown in part 2 — with two refinements for shipping. The attribute must be internal, because if two projects in one solution both use your generator, two public copies of the same attribute type produce ambiguity errors the moment one project references the other. And it should be [Conditional], so that applications of it vanish from the compiled output:
private const string AttributeSource = """
// <auto-generated/>
namespace AutoToString;
/// <summary>Marks a partial class for generation.</summary>
[System.AttributeUsage(System.AttributeTargets.Class)]
[System.Diagnostics.Conditional("AUTOTOSTRING_KEEP_ATTRIBUTE")]
internal sealed class AutoToStringAttribute : System.Attribute
{
}
""";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(static ctx =>
ctx.AddSource("AutoToStringAttribute.g.cs",
SourceText.From(AttributeSource, Encoding.UTF8)));
// ... pipeline as in part 2 ...
}
The [Conditional] trick exploits a compiler rule: applications of an attribute whose condition symbol is undefined are not emitted into the IL at all. The generator still sees the attribute in syntax and symbols at compile time — ForAttributeWithMetadataName works unchanged — but the consumer’s compiled assembly carries no applications of it, so nothing downstream ever takes a dependency on the attribute type. Be precise about what this does not do, though: the attribute’s class definition is still compiled into every consuming assembly. Under InternalsVisibleTo, both assemblies define that same internal type and both can see the other’s copy, so CS0436 type-conflict warnings fire — [Conditional] does not help here, whatever half the blog posts on the topic claim.
For the InternalsVisibleTo case you have three honest fixes. On Roslyn 4.14 or newer (SDK 9.0.300+), call context.AddEmbeddedAttributeDefinition() and mark your generated attribute with [Embedded] — the compiler then treats the type as invisible outside its own assembly, and the conflict disappears by design; Andrew Lock walks through the mechanism. On older compilers, suppressing CS0436 with NoWarn is the pragmatic fallback — ugly, but harmless, because every copy of the attribute is byte-identical; Andrew Lock’s earlier deep dive into the marker attribute problem weighs the alternatives. And if neither appeals, option 2 sidesteps generation entirely.
Option 2: a separate runtime package. If the attribute must survive into IL — because you or your users reflect over it at runtime — generate nothing and ship a tiny ordinary library instead, with the generator package depending on it:
<!-- In AutoToString.Generators.csproj -->
<ItemGroup>
<!-- A real dependency this time: the attributes package is a
normal lib/ package that consumers reference at runtime. -->
<PackageReference Include="AutoToString.Attributes" Version="1.0.0" />
</ItemGroup>
This is how CommunityToolkit.Mvvm does it: [ObservableProperty] and friends are public types in the runtime assembly, and the generator ships alongside them in the same package. The cost is real — a runtime assembly in every consumer’s deployment, and if you split attributes and generator into separate packages, two versions to keep in lockstep. My default is option 1 with [Conditional]; option 2 only when runtime reflection is a genuine, stated requirement — not “maybe someday.”
Debugging the Shipped Experience
“Attach debugger to a compiler process” used to be the initiation ritual of generator authors. It is obsolete. The IsRoslynComponent property from the first section unlocks a dedicated debug profile: Visual Studio launches a compilation of a target project with your generator attached and your breakpoints live. The profile goes into Properties/launchSettings.json of the generator project:
{
"profiles": {
"Debug AutoToString.Generators": {
"commandName": "DebugRoslynComponent",
"targetProject": "../../samples/AutoToString.Sample/AutoToString.Sample.csproj"
}
}
}
Set the generator project as the startup project, press F5, and you are stepping through your transform against real sample code. This requires the .NET Compiler Platform SDK component in the Visual Studio installer — a one-time setup cost that repays itself the first time you inspect an IncrementalGeneratorInitializationContext live instead of debugging by AddSource-driven printf.
That covers the generator. It does not cover the package — ProjectReference and PackageReference take different code paths, and only the latter exercises your layout. So before every release, I pack into a local folder and consume the actual .nupkg from a throwaway project:
dotnet pack src/AutoToString.Generators -o ./local-packages -p:Version=1.0.0-local.1
dotnet new console -o /tmp/pkgtest
dotnet add /tmp/pkgtest package AutoToString.Generators \
--source "$PWD/local-packages" --prerelease
dotnet build /tmp/pkgtest -bl
Note the absolute --source path — dotnet add package resolves relative sources against the project you are adding to, not against your shell’s working directory, and a silently unresolvable source is exactly the kind of error this smoke test exists to avoid.
Then open the binlog and confirm the generator appears under RunGenerators — the same binlog workflow from the predecessor article, pointed at yourself this time. One gotcha: NuGet caches packages by version in the global packages folder, which is why the pack command above stamps a -local.1 suffix — bump it to -local.2 on the next iteration, or clear ~/.nuget/packages/autotostring.generators between attempts. Rebuilding the same version number silently serves you the stale cached copy: twenty minutes of confusion, guaranteed, the first time you forget.
Analyzer Hygiene for Shippers
The Microsoft.CodeAnalysis.Analyzers package you referenced in the first section is not decoration — it is a set of analyzers about analyzers, and two of its rules matter enormously once you ship.
RS1035 fires when analyzer code calls banned APIs: file I/O, Environment.CurrentDirectory, random number generation, anything culture- or machine-dependent. The compiler may run your generator concurrently, repeatedly, and on machines you have never seen; a generator that reads a file from disk produces builds that are non-deterministic by construction. EnforceExtendedAnalyzerRules turns this from a suggestion into an error, which is where it belongs. If you need file content, it enters through AdditionalTexts — that is the supported channel, and the only one.
RS2008 enforces release tracking for your diagnostics. Every DiagnosticDescriptor you declare:
private static readonly DiagnosticDescriptor ClassMustBePartial = new(
id: "ATS0001",
title: "Class must be partial",
messageFormat: "The class '{0}' must be declared partial to use [AutoToString]",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
must be recorded in two markdown files, AnalyzerReleases.Shipped.md and AnalyzerReleases.Unshipped.md, registered as AdditionalFiles in the project. New rules land in the unshipped file:
### New Rules
Rule ID | Category | Severity | Notes
--------|----------|----------|--------------------------------
ATS0001 | Usage | Warning | Class must be partial
On release, the entries move to AnalyzerReleases.Shipped.md under a version heading. This feels like bureaucracy until the first time it stops you from silently changing a diagnostic’s severity between minor versions — the analyzer equivalent of a breaking API change, except it breaks builds with TreatWarningsAsErrors instead of breaking compilation. Diagnostic IDs are public API. The tracking files are the changelog that makes that contract auditable.
The Series, Closed
Four parts, four sentences. Part 1: every value in the pipeline must compare by value — records, EquatableArray<T>, symbols flattened exactly once. Part 2: enter through ForAttributeWithMetadataName, filter early, transform late, project the compilation before combining. Part 3: run the driver twice with step tracking and assert Cached — untested incrementalism is folklore. Part 4: netstandard2.0, analyzers/dotnet/cs, zero dependencies, a [Conditional] attribute, and a .nupkg you actually installed before your users did.
Notice what the packaging part has in common with the other three: none of it is hard, and all of it is invisible until it fails in someone else’s build. That is the defining property of shipping a source generator — you are running your code inside other people’s compilers, on hosts you do not control, next to generators you have never heard of. The discipline this series describes is not perfectionism. It is the minimum courtesy owed to every developer whose keystroke latency, build time, and dependency graph you are about to join, as the predecessor article measured in painful detail.
Correct, incremental, proven, packaged. Now it is done right.
Your generator runs in everyone else’s build. Package it like you know that.

Comments