Clik here to view.

In my previous post I described why you might want (or need) to target multiple versions of the .NET SDK in a source generator. In this post I show how I applied this to my NetEscapades.EnumGenerators source generator, so that I could support features only available in the .NET 8/9 SDK, while still supporting users stuck on the .NET 7 SDK.
.NET SDK versions, Microsoft.CodeAnalysis.CSharp, and NuGet package layouts
In my previous post I provided a long description of how and why you might need to target multiple versions of the .NET SDK. This post directly follows on from that one, so if you haven't, I suggest reading that one first.
In summary, when you create a source generator, you reference a specific version of the Microsoft.CodeAnalysis.CSharp NuGet package. The version of this package you choose defines the minimum version of Visual Studio, MSBuild, and the .NET SDK that your source generator will work with. The higher the version, the more Roslyn APIs you can use, but the higher the version of the .NET SDK the consumer must be using.
In the .NET 6 SDK, Microsoft updated the logic that finds analyzers/source generators in a NuGet file to allow versioning a source generator by Roslyn API version. That means you can ship a source generator that provides basic functionality when used with the .NET 6 SDK (for example) but which provides additional functionality when used with the .NET 7 or .NET 8 SDKs, for example. You do this by shipping multiple versions of the generator in the NuGet package, each compiled against a different version of Microsoft.CodeAnalysis.CSharp:
Image may be NSFW.
Clik here to view.
The .NET SDK then loads the highest supported version of the analyzer, based on the version of the Roslyn API that it supports.
I covered most of the details of why you might want to multi-target multiple roslyn versions in your package in the previous post, so in this post I'll show how I added multi-targeting support to my NetEscapades.EnumGenerators source generator.
Adding multi-targeting support for NetEscapades.EnumGenerators
The NetEscapades.EnumGenerators package is a source generator for making working with enums faster. I recently added experimental support for interceptors to the package, but this required using a newer version of the Roslyn API than I was previously. The interceptor API I needed was only available in .NET SDK version 8.0.400
(version 4.11
of the Roslyn API in Microsoft.CodeAnalysis.CSharp), whereas I was currently targeting 4.4
.
The easy approach would have been to simply update the version of Microsoft.CodeAnalysis.CSharp to 4.11.0
to give access to the 4.11
Roslyn API. However, doing so would have meant that anyone currently using the package with a .NET 7 SDK or a .NET 8 SDK below 8.0.400
would have been broken.
Rather than inconvenience people, I decided to have a go at multi-targeting. This would ensure that down-version users could use the basic functionality, but the interceptor support would not be available. Meanwhile, anyone using 8.0.400
or higher of the .NET SDK would be able to enable the interceptor functionality. The best of both worlds!
The approach I took to multi-targeting was heavily based on the approach taken by the built-in generators like the System.Text.Json generator and the Microsoft.Extensions.Logging generator when they first added multi-targeting support.
I implemented the bulk of the support in a single PR, and it's broadly the same approach I show in this post.
The original: prior to multi-targeting
To set the stage, prior to multi-targeting, the NetEscapades.EnumGenerators source generator targeted version 4.4
of the Roslyn API, by referencing 4.4.0
of the Microsoft.CodeAnalysis.CSharp NuGet package. This API version corresponds to the .NET 7 SDK, and gives access to the recommended ForAttributeWithMetadataName
API.
The solution consisted of two main projects:
NetEscapades.EnumGenerators
: The main source generator projectNetEscapades.EnumGenerators.Attributes
: A library project containing the "marker" attributes that drive the source generator
There's also a bunch of test projects, some of the main ones are described below. I've described these approaches in more detail previously in this series:
NetEscapades.EnumGenerators.Tests
: Unit tests for the generator functionality, and snapshot tests comparing the generated outputNetEscapades.EnumGenerators.IntegrationTests
: "Integration" test project that references the source generator project directly, and runs a bunch of tests. This ensures the generated output actually compiles, and gives the expected values when executed, for example.NetEscapades.EnumGenerators.Nuget.IntegrationTests
: Runs the same tests as the above project, but instead of referencing the source generator directly, it uses a version of the generator that has been fully packed into a NuGet package. This is the closest to a "real life" scenario you can get to.
There are a bunch of other tests for additional variations, but I'll ignore those for brevity:
Image may be NSFW.
Clik here to view.
Now we have the starting point, lets begin the migration to support multiple Roslyn versions.
Splitting the source generator project
Prior to supporting the multi-targeting, the NetEscapades.EnumGenerators .csproj file looked as shown below. This includes a variety of properties and items. Some of the properties (<TargetFramework>
, <PackageReference>
) are related to building the source generator dll, some parts are related to properties of the final NuGet package (<PackageId>
, <Description>
), and some are related to controlling the contents of the final NuGet package (<PackageReadmeFile>
, PackagePath
attributes):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IncludeBuildOutput>false</IncludeBuildOutput>
<Nullable>enable</Nullable>
<ImplicitUsings>true</ImplicitUsings>
<PackageId>NetEscapades.EnumGenerators</PackageId>
<Description>A source generator for creating helper extension methods on enums using a [EnumExtensions] attribute</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<!-- These are references required by the source generator -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.4.0" PrivateAssets="all" />
<ProjectReference Include="..\NetEscapades.EnumGenerators.Attributes\NetEscapades.EnumGenerators.Attributes.csproj" PrivateAssets="All" />
</ItemGroup>
<!-- These tweak and control the contents of the NuGet package -->
<ItemGroup>
<None Include="../../README.md" Pack="true" PackagePath="\" />
<!-- Pack the source generator and attributes dll -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<!-- Pack the attributes file to be referenced by the target project -->
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.dll" Pack="true" PackagePath="lib\netstandard2.0" Visible="true" />
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.xml" Pack="true" PackagePath="lib\netstandard2.0" Visible="true" />
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.dll" Pack="true" PackagePath="lib\net451" Visible="true" />
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.xml" Pack="true" PackagePath="lib\net451" Visible="true" />
</ItemGroup>
</Project>
To support multi-targeting, we need to make some significant changes to this project:
- Split the source generator project into multiple projects, each referencing different version of Microsoft.CodeAnalysis.CSharp.
- One project references version
4.4.0
. This is the "lower" bound for our SDK support, and corresponds to the .NET 7 SDK. - Another project references version
4.11.0
. This is the "enhanced" SDK support, which we need for interceptor support, and corresponds to8.0.4xx
of the .NET SDK.
- One project references version
- Update the projects to share most of the implementation. Update the implementation in the
4.11
project with the additional functionality. - Create a separate project that is used solely to pack the two source generator dlls into the final NuGet package. You could also use a nuspec file for this, but I prefer to use a csproj instead.
The final result looks something like this in the solution explorer:
Image may be NSFW.
Clik here to view.
So the original NetEscapades.EnumGenerators project has gone, and we have three projects in its place:
NetEscapades.EnumGenerators.Rolsyn4_04
: The version of the generator that references Microsoft.CodeAnalysis.CSharp4.4.0
.NetEscapades.EnumGenerators.Rolsyn4_11
: The version of the generator that references Microsoft.CodeAnalysis.CSharp4.11.0
.NetEscapades.EnumGenerators.Pack
: A project focused solely on collecting the output artifacts from the other source projects and producing the final NuGet package.
Note that the three projects are peers in the solution explorer, but the files are all side-by-side in the same folder in the filesystem, rather than being in separate folders:
Image may be NSFW.
Clik here to view.
Now lets look at the project files, as well as some ancillary files.
Exploring the new project files
We'll start by looking at the NetEscapades.EnumGenerators.Rolsyn4_04
and NetEscapades.EnumGenerators.Rolsyn4_11
.csproj files. These are quite simple, basically just defining the version of the Roslyn API they require and then importing a separate .targets
file.
The Rolsyn4_04
project looks like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RoslynApiVersion>4.4.0</RoslynApiVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<Import Project="NetEscapades.EnumGenerators.Build.targets" />
</Project>
and the Rolsyn4_11
project looks like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RoslynApiVersion>4.11.0</RoslynApiVersion>
<DefineConstants>$(DefineConstants);INTERCEPTORS</DefineConstants>
<IsPackable>false</IsPackable>
</PropertyGroup>
<Import Project="NetEscapades.EnumGenerators.Build.targets" />
</Project>
Note that I've defined an additional constant in the Rolsyn4_11
project, so that I can use #if INTERCEPTORS
in the source generator code, and share the same files between both projects.
The bulk of the project definition happens in the NetEscapades.EnumGenerators.Build.targets file (the name is chosen arbitrarily). This includes most of the same property definitions as we had before, with a couple of tweaks, which I've highlighted in comments below:
<Project>
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IncludeBuildOutput>false</IncludeBuildOutput>
<PackageId>NetEscapades.EnumGenerators</PackageId>
<!-- Explicitly define these, so that both projects produce dlls with the same name -->
<AssemblyName>$(PackageId)</AssemblyName>
<RootNamespace>$(PackageId)</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>true</ImplicitUsings>
<PackageId>NetEscapades.EnumGenerators</PackageId>
<Description>A source generator for creating helper extension methods on enums using a [EnumExtensions] attribute</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
<!-- Use the RoslynApiVersion version defined in the project's .csproj -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="$(RoslynApiVersion)" PrivateAssets="all" />
<ProjectReference Include="..\NetEscapades.EnumGenerators.Attributes\NetEscapades.EnumGenerators.Attributes.csproj" PrivateAssets="All" />
</ItemGroup>
</Project>
As you can see, this .targets
file only includes values for controlling how the dlls are built. There's nothing related to actually packaging the file here. That's all handled in the .Pack
project, which admittedly, feels a bit of a hack. The .csproj for the .Pack
project looks like the following (heavily commented to try to make sense of it all!):
<Project Sdk="Microsoft.NET.Sdk">
<!-- This project is just used to _pack_ the NuGet, containing both the other analyzers -->
<!-- We still import the same targets file to ensure we have the same values. There are -->
<!-- probably other, better ways of doing this (e.g. using Directory.Build.props), but -->
<!-- this is where I eventually ended up, and it works, so I haven't bothered experimenting -->
<PropertyGroup>
<RoslynApiVersion>4.11.0</RoslynApiVersion>
<IsPackable>true</IsPackable>
</PropertyGroup>
<Import Project="NetEscapades.EnumGenerators.Build.targets" />
<!-- These project references to each of the "real" projects ensures everything builds -->
<!-- in the correct order etc. We set ReferenceOutputAssembly="false" though -->
<ItemGroup>
<ProjectReference Include="..\NetEscapades.EnumGenerators\NetEscapades.EnumGenerators.Roslyn4_04.csproj"
PrivateAssets="All" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\NetEscapades.EnumGenerators\NetEscapades.EnumGenerators.Roslyn4_11.csproj"
PrivateAssets="All" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<!-- We don't want to actually build anything in this project -->
<Compile Remove="**\*.cs" />
<None Include="../../README.md" Pack="true" PackagePath="\" />
<!-- Pack the Roslyn4_04 project output into the analyzers/dotnet/roslyn4.4/cs folder -->
<!-- and the Roslyn4_11 project output into the analyzers/dotnet/roslyn4.11/cs folder -->
<None Include="$(OutputPath)\..\..\NetEscapades.EnumGenerators.Roslyn4_04\$(ArtifactsPivots)\$(AssemblyName).dll"
Pack="true" PackagePath="analyzers/dotnet/roslyn4.4/cs" Visible="false" />
<None Include="$(OutputPath)\..\..\NetEscapades.EnumGenerators.Roslyn4_11\$(ArtifactsPivots)\$(AssemblyName).dll"
Pack="true" PackagePath="analyzers/dotnet/roslyn4.11/cs" Visible="false" />
<!-- Pack the attributes dll for both Roslyn versions for referencing by the generator directly -->
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.dll" Pack="true"
PackagePath="analyzers/dotnet/roslyn4.4/cs" Visible="false" />
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.dll" Pack="true"
PackagePath="analyzers/dotnet/roslyn4.11/cs" Visible="false" />
<!-- Pack the attributes dll for referencing by the target project-->
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.dll" Pack="true" PackagePath="lib\netstandard2.0" Visible="false" />
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.xml" Pack="true" PackagePath="lib\netstandard2.0" Visible="false" />
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.dll" Pack="true" PackagePath="lib\net451" Visible="false" />
<None Include="$(OutputPath)\NetEscapades.EnumGenerators.Attributes.xml" Pack="true" PackagePath="lib\net451" Visible="false" />
</ItemGroup>
</Project>
Phew, that's a lot, but it is mostly just a way to define a nuspec file while reusing values between the dlls. A simple dotnet pack
and we have everything we need!
Of course, as we're now building for multiple Roslyn versions, we really should test for those different versions too, right?
Testing multi-targeting support
Testing is where things really start to get a little hairy. Prior to the multi-targeting support I had 3 main test projects, as I described previously:
NetEscapades.EnumGenerators.Tests
NetEscapades.EnumGenerators.IntegrationTests
NetEscapades.EnumGenerators.Nuget.IntegrationTests
However, if I really wanted to be sure that everything was working as expected, I decided I would need to run the tests both with the 4.4
Roslyn API and with the 4.11
Roslyn API. Which means running all the tests twice with two different versions of the .NET SDK.
But on top of that the unit test project, NetEscapades.EnumGenerators.Tests
, needs to reference one of the NetEscapades.EnumGenerators projects (Roslyn4_04
or Roslyn4_11
) directly, which means you actually need two versions of this project (or at least, that's what I did).
This uses a "normal" project reference, so you can reference either version of the projects, the version of the SDK you're using for the unit testing/snapshot testing isn't critical.
For the integration tests I took a slightly different approach. These test projects reference the NetEscapades.EnumGenerators projects as an analyzer. In this case we can't load the Roslyn4_11
project unless we're using a supported version of the SDK, so it makes things a bit trickier. In the end, I ended up creating several new test projects:
NetEscapades.EnumGenerators.Tests.Roslyn4_04
: Runs all the unit/snapshot tests for the4.4
version of the analyzer.NetEscapades.EnumGenerators.Tests.Roslyn4_11
: Runs all the unit/snapshot tests for the4.11
version of the analyzer, including some additional snapshot tests for the interceptor support.NetEscapades.EnumGenerators.IntegrationTests
: References the4.4
version of the generator, runs tests that are common to both implementations.NetEscapades.EnumGenerators.Interceptors.IntegrationTests
: References either the4.4
or4.11
version of the generator, depending on the currently active .NET SDK version, but only runs additional interceptor tests when using the4.11
versionNetEscapades.EnumGenerators.NuGet.IntegrationTests
: References the final generated NuGet package, and runs tests common to both implementations.NetEscapades.EnumGenerators.Interceptors.IntegrationTests
: References the final generated NuGet package, but only runs additional interceptor tests when using the4.11
version.
If all that extra duplication sounds like a pain, you're not wrong 😅 Still, for full coverage, it's pretty much necessary. The following sections show how I handled the MSBuild/csproj mess.
Splitting the snapshot test project
The NetEscapades.EnumGenerators.Tests
project is where I do the snapshot testing of the generators, and is the main feedback loop when I'm developing a source generator. As I wanted to be able to quickly test both versions of the generator, all while developing locally, I split the project in two, each referencing a different version of Microsoft.CodeAnalysis.CSharp and of the generator project.
As before, I reduced the duplication in the project files by moving the bulk of the definition into a .targets
file, and referencing it from two different .csproj files. The NetEscapades.EnumGenerators.Tests.Roslyn4_04
.csproj file looks like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RoslynApiVersion>4.4.0</RoslynApiVersion>
</PropertyGroup>
<Import Project="NetEscapades.EnumGenerators.Tests.Project.targets" />
<ItemGroup>
<ProjectReference Include="..\..\src\NetEscapades.EnumGenerators\NetEscapades.EnumGenerators.Roslyn4_04.csproj" />
</ItemGroup>
</Project>
The Roslyn4_11
version is the same, but references the Roslyn4_11
versions of the generator, sets RoslynApiVersion
to 4.11.0
, and defines an additional MSBuild constant, INTERCEPTORS
, for use in #if
within test files . Both of the files reference the .targets
file shown below, but the only real important part is the use of $(RoslynApiVersion)
, which is taken from the projects file:
<Project>
<PropertyGroup>
<AssemblyName>NetEscapades.EnumGenerators.Tests</AssemblyName>
<RootNamespace>$(AssemblyName)</RootNamespace>
<TargetFrameworks>netcoreapp3.1;net6.0;net7.0;net8.0</TargetFrameworks>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<!-- Use the Roslyn API version passed in from the project file -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="$(RoslynApiVersion)" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" Condition="'$(TargetFramework)' == 'net48'" />
<PackageReference Include="Verify.Xunit" Version="14.3.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NetEscapades.EnumGenerators.Attributes\NetEscapades.EnumGenerators.Attributes.csproj" />
</ItemGroup>
</Project>
Both of these test projects can run on any support .NET SDK version, because the generator directly uses the Compilation
types provided in the Microsoft.CodeAnalysis.CSharp package, rather than relying on it being provided by the .NET SDK itself. That's different from the integration test projects, which do rely on the SDK.
Multi-targeting the integration test project for multiple .NET SDK versions
The integration test projects for my source generators reference the generator projects, but add the generator with OutputItemType="Analyzer"
, as described in a previous post. This lets you test your generator similar to how it will be used "in production", but without needing to create and reference a NuGet package locally, which is a bit of a pain.
In integration tests, your source generator is running in the compiler, so you need to be careful about the Roslyn APIs. You can only reference and test functionality exposed by the Roslyn4_11
project if you're using a high enough SDK version. To achieve the testing I wanted, I needed to conditionally reference a different project (Roslyn4_04
or Roslyn4_11
) based on the version of the .NET SDK I was building with.
It turns out, I've never had to do that before. I've had minimum .NET SDK version requirements, but needing to run with two different SDK versions isn't something I'd tried before. Luckily, it turns out that MSBuild exposes the .NET SDK version you're using as a property. I used this in my test project .csproj to set a simple MSBuild property and a constant, which I could use further on in the .csproj file, and in #if
conditions in the code.
This post is already long, so I've just shown the pertinent parts of the .csproj file below:
<!-- Are we building with .NET SDK 8.0.400 or greater? -->
<PropertyGroup Condition="$([MSBuild]::VersionGreaterThanOrEquals('$(NETCoreSdkVersion)', '8.0.400'))">
<!-- If so, set some properties and constants -->
<UsingModernDotNetSdk>true</UsingModernDotNetSdk>
<DefineConstants>$(DefineConstants);INTERCEPTORS</DefineConstants>
</PropertyGroup>
<!-- This project can only be loaded when we have a high enough compiler version -->
<ItemGroup Condition="$(UsingModernDotNetSdk) == true">
<!-- Can use the 4_11 project -->
<ProjectReference Include="..\..\src\NetEscapades.EnumGenerators\NetEscapades.EnumGenerators.Roslyn4_11.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup Condition="$(UsingModernDotNetSdk) != true">
<!-- Have to use the 4_04 project instead-->
<ProjectReference Include="..\..\src\NetEscapades.EnumGenerators\NetEscapades.EnumGenerators.Roslyn4_04.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
That's the biggest difference in this project; the test files make judicious use of #if INTERCEPTORS
to both test the behaviour where interceptors are available (in 4.11
) but also where they're not available (4.4
).
The final piece of the puzzle I want to touch on are how I set up the testing for this in CI.
Setting up a CI build in GitHub Actions
Prior to the above changes, I already had a relatively simple, but thorough, GitHub Actions CI pipeline set up for building and testing the package, which looked something like the following (I've reduced some of the unimportant aspects):
name: BuildAndPack
# Build pushes and PRs
on:
push:
branches:
- main
tags:
- '*'
pull_request:
branches:
- '*'
jobs:
# Build and test on multiple OS
build-and-test:
strategy:
matrix:
include:
- os: windows
vm: windows-latest
- os: linux
vm: ubuntu-latest
- os: macos
vm: macos-13
name: ${{ matrix.os}}
runs-on: ${{ matrix.vm}}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.402
6.0.x
3.1.x
# Build, Test, Pack, Test the NuGet package, and (optionally) push to NuGet
- name: Run './build.cmd Clean Test TestPackage PushToNuGet'
run: ./build.cmd Clean Test TestPackage PushToNuGet
env:
NuGetToken: ${{ secrets.NUGET_TOKEN }}
Previously, this whole build was using the 8.0.402
.NET SDK, but with my changes I wanted to ensure I could run some of the tests with an earlier version of the .NET SDK. Specifically, and for simplicity, I decided to only run the NuGet package tests on both SDKs in CI. Consequently, I updated the build pipeline to be something like the following (again, simplified, but with the changes highlighted):
name: BuildAndPack
# Build pushes and PRs
on:
push:
branches:
- main
tags:
- '*'
pull_request:
branches:
- '*'
jobs:
# Build and test on multiple OS
build-and-test:
strategy:
matrix:
include:
- os: windows
vm: windows-latest
- os: linux
vm: ubuntu-latest
- os: macos
vm: macos-13
name: ${{ matrix.os}}
runs-on: ${{ matrix.vm}}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
# NOTE: installing the 8.0.110 SDK too
dotnet-version: |
8.0.402
8.0.110
6.0.x
3.1.x
# Force the build to use the 8.0.402 SDK specifically
- run: dotnet new globaljson --sdk-version "8.0.402" --force
# Run the build, test, pack, and package test with the 8.0.402 SDK
- name: Run './build.cmd Clean Test TestPackage'
run: ./build.cmd Clean Test TestPackage
# Force the build to use an earlier .NET SDK
- run: dotnet new globaljson --sdk-version "8.0.110" --force
# Run the TestPackage stage with the earlier SDK (skipping dependent steps)
- name: Run './build.cmd TestPackage PushToNuGet --skip'
run: ./build.cmd TestPackage PushToNuGet --skip
env:
NuGetToken: ${{ secrets.NUGET_TOKEN }}
I could have chosen to run all the integration tests with the earlier SDK, but it didn't seem worth the effort, as I share the same actual test files between both the integration tests and package tests. So with that, I'm done!
Was it worth it? Should you do it?
As you can tell by the length of the post, there's a lot of moving pieces required to support multiple Roslyn API versions in a source generator. This post is actually quite brief compared to the changes I found I needed to make in my project, as I haven't touched on a bunch of additional points.
A prime example of work not covered here is tracking your tests in CI. I wanted to make sure I didn't "lose" any tests with these changes, and to understand exactly what I was testing under what SDK. I used the
EnricoMi/publish-unit-test-result-action@v2
GitHub Action for this, but tracking it properly required a bunch of gnarly#if
/#elif
that I didn't go into in this post, but which you can see in the repo if you like pain😆
So…was it worth it? Well, it depends. If you don't have to do this, then don't. It's a lot of work and added complexity to a project, without a big pay off. In general there aren't a lot of reasons not to just update your .NET SDK, so there shouldn't be a big call for supporting older versions of the SDK.
Ironically, I have one in my day job—.NET 8 dropped support for the unsupported (but still heavily used) CentOS 7 Linux distro (and related distros), which means we can't upgrade the SDK for the portions of the build that need to run on CentOS 7🙁
In my case, I wanted to increase the .NET SDK requirement so I could add a preview of an optional component. That didn't seem a big enough justification for forcing everyone to update their .NET SDK, so multi-targeting somewhat made sense. Honestly, I mostly took the multi-targeting approach to see how bad it would be. And the verdict is: it's Pretty Bad™😅
Summary
In this post I showed how you could update an existing source generator project to add multi-targeting support for multiple Roslyn API versions. This ensures your source generator can run on the widest possible range of .NET SDK versions and that features which require a newer SDK version can "light up" when it's available. Unfortunately this process is somewhat complex.
In this post I described the changes I made to the project files of my NetEscapades.EnumGenerators source generator project when I added Roslyn API multi-targeting support. I split the main source generator API into two, to produce two different dlls targeting different versions of the Microsoft.CodeAnalysis.CSharp package. To test the changes, I split my unit testing projects in two, each testing a different version of the source generator project, and updated the integration tests to support working with multiple versions of the .NET SDK.
Overall multi-targeting multiple versions of the .NET SDK adds quite a lot of complexity to the various projects you need, and I wouldn't recommend it unless you definitely need to.