Fix source-generator package delivery and preview restore (Roslyn packaging) (#4041)

## Failure

The source-generator projects disabled normal build-output packing
without adding
their analyzer assemblies and private runtime closure back to the NuGet
payload.
Debug and Release packages also shared IDs, and both preview workflows
passed the
unsupported `--configuration` option to `dotnet restore`.

## Fix

- Package each generator and its private runtime closure under
`analyzers/dotnet/cs`, excluding Roslyn host assemblies and package
dependencies.
- Preserve Roslyn compile references for project-reference test
consumers.
- Give Debug generator packages `.Debug` IDs.
- Validate package contents and clean net10 consumers in both preview
pipelines.
- Remove invalid restore configuration arguments from both preview
workflows.

## Reference

- Roslyn analyzer packaging and MSBuild restore semantics; no normative
OPC UA change.
- Split from #4021.

## Tests

- `Opc.Ua.SourceGeneration.Tests`: 68 passed on net10.0 and 68 on net48.
- `Opc.Ua.SourceGeneration.Stack.Tests`: 90 passed on net10.0 and 87 on
net48.
- Generated `preview-pack.slnx` and restored it with
`--disable-parallel`.
- Packed both Release generators with `--no-build`; package-content
checks and two clean net10 consumer builds passed with zero
warnings/errors.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Marc Schier
2026-07-22 12:40:27 +02:00
committed by GitHub
parent 15e795bbed
commit e73eb3a0e7
6 changed files with 365 additions and 9 deletions
+6 -1
View File
@@ -89,7 +89,6 @@ jobs:
inputs:
command: restore
projects: 'preview-pack.slnx'
arguments: '--configuration ${{parameters.config}}'
restoreArguments: '--disable-parallel'
- task: DotNetCoreCLI@2
displayName: Build ${{parameters.config}}
@@ -121,6 +120,12 @@ jobs:
configuration: ${{parameters.config}}
configurationToPack: ${{parameters.config}}
nobuild: true
- task: PowerShell@2
displayName: Validate source generator packages
condition: and(succeeded(), eq('${{parameters.config}}', 'Release'))
inputs:
filePath: '.azurepipelines/validate-source-generator-packages.ps1'
arguments: '-PackageDirectory "$(Build.ArtifactStagingDirectory)"'
- task: NuGetCommand@2
displayName: Pack Nuget Legacy Preview
condition: and(succeeded(), and(eq('${{parameters.config}}', 'Release'), ne(variables['NBGV_PublicRelease'], 'True')))
@@ -0,0 +1,301 @@
<#
.SYNOPSIS
Validates the source-generator NuGet payloads and builds clean consumers.
.PARAMETER PackageDirectory
Directory containing the packed source-generator NuGet packages.
#>
param(
[Parameter(Mandatory = $true)]
[string] $PackageDirectory
)
$ErrorActionPreference = "Stop"
function Assert-Condition
{
param(
[Parameter(Mandatory = $true)]
[bool] $Condition,
[Parameter(Mandatory = $true)]
[string] $Message
)
if (-not $Condition)
{
throw $Message
}
}
function Get-PackageInfo
{
param(
[Parameter(Mandatory = $true)]
[string] $PackageId
)
$packagePattern = "^$([Regex]::Escape($PackageId))\.(?<version>[0-9].+)\.nupkg$"
$packages = @(Get-ChildItem -Path $PackageDirectory -Filter "$PackageId*.nupkg" -File -Recurse |
Where-Object { $_.Name -notlike "*.snupkg" -and $_.Name -match $packagePattern })
Assert-Condition ($packages.Count -gt 0) "Package '$PackageId' was not found in '$PackageDirectory'."
Assert-Condition (
$packages.Count -eq 1
) "Expected exactly one package '$PackageId' in '$PackageDirectory'; found $($packages.Count)."
$package = $packages[0]
$archive = [IO.Compression.ZipFile]::OpenRead($package.FullName)
try
{
$entries = @($archive.Entries | ForEach-Object FullName)
$nuspecEntries = @($archive.Entries |
Where-Object { $_.FullName.EndsWith(".nuspec", [StringComparison]::OrdinalIgnoreCase) })
Assert-Condition (
$nuspecEntries.Count -eq 1
) "Package '$($package.Name)' must contain exactly one nuspec."
$nuspecEntry = $nuspecEntries[0]
$reader = [IO.StreamReader]::new($nuspecEntry.Open())
try
{
[xml] $nuspec = $reader.ReadToEnd()
}
finally
{
$reader.Dispose()
}
}
finally
{
$archive.Dispose()
}
[PSCustomObject] @{
Id = $PackageId
Path = $package.FullName
Version = [string] $nuspec.package.metadata.version
Entries = $entries
Dependencies = @($nuspec.SelectNodes("//*[local-name()='dependency']"))
}
}
function Test-PackageContents
{
param(
[Parameter(Mandatory = $true)]
[PSCustomObject] $Package,
[Parameter(Mandatory = $true)]
[string] $GeneratorAssembly
)
$analyzerPath = "analyzers/dotnet/cs/"
$requiredAssemblies = @(
$GeneratorAssembly,
"Opc.Ua.SourceGeneration.Core.dll",
"Opc.Ua.Types.dll",
"SourceGenerator.Foundations.Contracts.dll",
"SourceGenerator.Foundations.Windows.dll"
)
foreach ($assembly in $requiredAssemblies)
{
Assert-Condition (
$Package.Entries -contains "$analyzerPath$assembly"
) "Package '$($Package.Id)' is missing '$analyzerPath$assembly'."
}
$dllEntries = @($Package.Entries |
Where-Object { $_.EndsWith(".dll", [StringComparison]::OrdinalIgnoreCase) })
Assert-Condition ($dllEntries.Count -gt 0) "Package '$($Package.Id)' contains no assemblies."
Assert-Condition (
@($dllEntries | Where-Object { -not $_.StartsWith(
$analyzerPath,
[StringComparison]::OrdinalIgnoreCase) }).Count -eq 0
) "Package '$($Package.Id)' contains assemblies outside '$analyzerPath'."
Assert-Condition (
@($dllEntries | Where-Object {
[IO.Path]::GetFileName($_).StartsWith(
"Microsoft.CodeAnalysis",
[StringComparison]::OrdinalIgnoreCase)
}).Count -eq 0
) "Package '$($Package.Id)' must not ship Microsoft.CodeAnalysis host assemblies."
Assert-Condition (
$Package.Dependencies.Count -eq 0
) "Package '$($Package.Id)' must carry its analyzer runtime closure privately."
}
function Invoke-DotNet
{
param(
[Parameter(Mandatory = $true)]
[string[]] $Arguments
)
& dotnet @Arguments
if ($LASTEXITCODE -ne 0)
{
throw "dotnet $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
}
}
function Get-MSBuildProperty
{
param(
[Parameter(Mandatory = $true)]
[string] $ProjectPath,
[Parameter(Mandatory = $true)]
[string] $Configuration,
[Parameter(Mandatory = $true)]
[string] $PropertyName
)
$output = & dotnet msbuild $ProjectPath `
"-getProperty:$PropertyName" `
"-p:Configuration=$Configuration" `
-nologo
if ($LASTEXITCODE -ne 0)
{
throw "Could not read MSBuild property '$PropertyName' from '$ProjectPath'."
}
return [string]($output |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Last 1).Trim()
}
function Test-ConfigurationPackageIds
{
param(
[Parameter(Mandatory = $true)]
[string] $ProjectPath
)
$releaseId = Get-MSBuildProperty $ProjectPath "Release" "PackageId"
$debugId = Get-MSBuildProperty $ProjectPath "Debug" "PackageId"
Assert-Condition (
$debugId -eq "$releaseId.Debug"
) "Debug package '$debugId' must use the release ID '$releaseId' with a '.Debug' suffix."
}
function Test-CleanConsumer
{
param(
[Parameter(Mandatory = $true)]
[PSCustomObject] $Package,
[Parameter(Mandatory = $true)]
[string] $ValidationRoot
)
$consumerName = $Package.Id.Split(".")[-1]
$consumerDirectory = Join-Path $ValidationRoot $consumerName
New-Item -ItemType Directory -Path $consumerDirectory | Out-Null
$projectPath = Join-Path $consumerDirectory "$consumerName.csproj"
$expectedDiagnosticSuppression = if ($Package.Id.EndsWith(
".Stack",
[StringComparison]::Ordinal))
{
" <NoWarn>STACKGEN001</NoWarn>"
}
else
{
""
}
@"
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
$expectedDiagnosticSuppression
</PropertyGroup>
<ItemGroup>
<PackageReference Include="$($Package.Id)" Version="$($Package.Version)"
PrivateAssets="all" />
</ItemGroup>
</Project>
"@ | Set-Content -Path $projectPath -Encoding utf8
@"
namespace SourceGeneratorConsumer;
public static class ConsumerMarker
{
public static int Value => 42;
}
"@ | Set-Content -Path (Join-Path $consumerDirectory "ConsumerMarker.cs") -Encoding utf8
$nugetConfig = Join-Path $ValidationRoot "NuGet.Config"
$packagesPath = Join-Path $ValidationRoot "packages"
Invoke-DotNet @(
"restore",
$projectPath,
"--configfile",
$nugetConfig,
"--packages",
$packagesPath,
"--nologo"
)
Invoke-DotNet @(
"build",
$projectPath,
"--configuration",
"Release",
"--no-restore",
"--nologo"
)
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
$resolvedPackageDirectory = (Resolve-Path $PackageDirectory).Path
$PackageDirectory = $resolvedPackageDirectory
$repoRoot = Split-Path $PSScriptRoot -Parent
$validationRoot = Join-Path (Join-Path $repoRoot "artifacts") "source-generator-consumer"
Test-ConfigurationPackageIds (
Join-Path $repoRoot "tools\Opc.Ua.SourceGeneration\Opc.Ua.SourceGeneration.csproj")
Test-ConfigurationPackageIds (
Join-Path $repoRoot "tools\Opc.Ua.SourceGeneration.Stack\Opc.Ua.SourceGeneration.Stack.csproj")
$modelPackage = Get-PackageInfo "OPCFoundation.NetStandard.Opc.Ua.SourceGeneration"
$stackPackage = Get-PackageInfo "OPCFoundation.NetStandard.Opc.Ua.SourceGeneration.Stack"
Test-PackageContents $modelPackage "Opc.Ua.SourceGeneration.dll"
Test-PackageContents $stackPackage "Opc.Ua.SourceGeneration.Stack.dll"
Remove-Item -Path $validationRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $validationRoot | Out-Null
try
{
"<Project />" | Set-Content -Path (Join-Path $validationRoot "Directory.Build.props") -Encoding utf8
"<Project />" | Set-Content -Path (Join-Path $validationRoot "Directory.Build.targets") -Encoding utf8
@"
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
"@ | Set-Content -Path (Join-Path $validationRoot "Directory.Packages.props") -Encoding utf8
$escapedPackageDirectory = [System.Security.SecurityElement]::Escape($PackageDirectory)
@"
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="source-generator-packages" value="$escapedPackageDirectory" />
</packageSources>
</configuration>
"@ | Set-Content -Path (Join-Path $validationRoot "NuGet.Config") -Encoding utf8
Test-CleanConsumer $modelPackage $validationRoot
Test-CleanConsumer $stackPackage $validationRoot
}
finally
{
Remove-Item -Path $validationRoot -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host "Validated source-generator package contents and clean consumers."
+6 -1
View File
@@ -119,7 +119,6 @@ jobs:
shell: pwsh
run: |
dotnet restore 'preview-pack.slnx' `
--configuration Release `
--disable-parallel
- name: Build (with optional strong-name signing)
@@ -186,6 +185,12 @@ jobs:
--configuration Release `
--output "$env:ARTIFACTS_DIR"
- name: Validate source generator packages
shell: pwsh
run: |
./.azurepipelines/validate-source-generator-packages.ps1 `
-PackageDirectory "$env:ARTIFACTS_DIR"
- name: Pack NuGet Legacy (nuget/*.nuspec, version from PACK_MODE)
shell: pwsh
# Scoped to nuget/ - the MigrationAnalyzer nuspec at tools/Opc.Ua.MigrationAnalyzer/
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IncludeBuildOutput>false</IncludeBuildOutput>
<AssemblyName>$(AssemblyPrefix).SourceGeneration.Stack</AssemblyName>
<RootNamespace>Opc.Ua.SourceGeneration</RootNamespace>
<Description>OPC UA source generation Library</Description>
@@ -20,6 +19,9 @@
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<NoPackageAnalysis>true</NoPackageAnalysis>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<PackageId>$(PackageId).Debug</PackageId>
</PropertyGroup>
<PropertyGroup>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<AssemblySearchPath_UseOutDir>true</AssemblySearchPath_UseOutDir>
@@ -32,12 +34,13 @@
<InternalsVisibleTo Include="Opc.Ua.SourceGeneration.Stack.Tests"/>
</ItemGroup>
<ItemGroup>
<!-- Keep Roslyn compile assets transitive for project-reference test consumers.
SourceGeneratorPackaging.targets suppresses package dependencies and host DLL payloads. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
<PackageReference Include="SourceGenerator.Foundations" >
<!-- Make private asset because assembly name comparer symbol conflict in test -->
<PrivateAssets>all</PrivateAssets>
<OutputItemType>Analzyer</OutputItemType>
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<OutputItemType>Analyzer</OutputItemType>
</PackageReference>
</ItemGroup>
<ItemGroup>
@@ -56,4 +59,5 @@
<ItemGroup>
<None Include="NugetREADME.md" Pack="true" PackagePath="\" />
</ItemGroup>
<Import Project="..\Opc.Ua.SourceGeneration\SourceGeneratorPackaging.targets" />
</Project>
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IncludeBuildOutput>false</IncludeBuildOutput>
<AssemblyName>$(AssemblyPrefix).SourceGeneration</AssemblyName>
<RootNamespace>Opc.Ua.SourceGeneration</RootNamespace>
<Description>OPC UA source generation Library</Description>
@@ -20,6 +19,9 @@
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<NoPackageAnalysis>true</NoPackageAnalysis>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<PackageId>$(PackageId).Debug</PackageId>
</PropertyGroup>
<PropertyGroup>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<AssemblySearchPath_UseOutDir>true</AssemblySearchPath_UseOutDir>
@@ -32,12 +34,13 @@
<InternalsVisibleTo Include="Opc.Ua.SourceGeneration.Tests"/>
</ItemGroup>
<ItemGroup>
<!-- Keep Roslyn compile assets transitive for project-reference test consumers.
SourceGeneratorPackaging.targets suppresses package dependencies and host DLL payloads. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
<PackageReference Include="SourceGenerator.Foundations">
<!-- Make private asset because assembly name comparer symbol conflict in test -->
<PrivateAssets>all</PrivateAssets>
<OutputItemType>Analzyer</OutputItemType>
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<OutputItemType>Analyzer</OutputItemType>
</PackageReference>
</ItemGroup>
<ItemGroup>
@@ -54,5 +57,5 @@
<ItemGroup>
<None Include="NugetREADME.md" Pack="true" PackagePath="\" />
</ItemGroup>
<Import Project="SourceGeneratorPackaging.targets" />
</Project>
@@ -0,0 +1,38 @@
<Project>
<PropertyGroup>
<IncludeBuildOutput>false</IncludeBuildOutput>
<!-- The analyzer payload is custom package content, so no build-output symbol package is produced. -->
<IncludeSymbols>false</IncludeSymbols>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<DevelopmentDependency>true</DevelopmentDependency>
<TargetsForTfmSpecificContentInPackage>
$(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorRuntimeAssets
</TargetsForTfmSpecificContentInPackage>
<IncludeSourceGeneratorRuntimeAssetsDependsOn Condition="'$(NoBuild)' != 'true'">
Compile;CopyFilesToOutputDirectory;ResolveReferences
</IncludeSourceGeneratorRuntimeAssetsDependsOn>
</PropertyGroup>
<Target Name="IncludeSourceGeneratorRuntimeAssets"
DependsOnTargets="$(IncludeSourceGeneratorRuntimeAssetsDependsOn)">
<ItemGroup>
<_SourceGeneratorRuntimeAsset Include="@(ReferenceCopyLocalPaths)"
Condition="'$(NoBuild)' != 'true' AND
'%(ReferenceCopyLocalPaths.Extension)' == '.dll' AND
!$([System.String]::Copy('%(Filename)').StartsWith('Microsoft.CodeAnalysis')) AND
'%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.Common' AND
'%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.CSharp' AND
'%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.Workspaces.Common' AND
'%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.CSharp.Workspaces'" />
<!-- Preview no-build packaging reuses the closure copied by the preceding solution build. -->
<_SourceGeneratorRuntimeAsset Include="$(TargetDir)*.dll"
Exclude="$(TargetPath);$(TargetDir)Microsoft.CodeAnalysis*.dll"
Condition="'$(NoBuild)' == 'true'" />
<TfmSpecificPackageFile Include="$(TargetPath)">
<PackagePath>analyzers/dotnet/cs/$(TargetFileName)</PackagePath>
</TfmSpecificPackageFile>
<TfmSpecificPackageFile Include="@(_SourceGeneratorRuntimeAsset)">
<PackagePath>analyzers/dotnet/cs/%(_SourceGeneratorRuntimeAsset.Filename)%(_SourceGeneratorRuntimeAsset.Extension)</PackagePath>
</TfmSpecificPackageFile>
</ItemGroup>
</Target>
</Project>