From 7e842289200647a86d1074a43aa30ab25f7c1163 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Sun, 19 Jul 2026 12:14:37 +0200 Subject: [PATCH 1/2] Fix source-generator package delivery and preview restore (Roslyn packaging) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .azurepipelines/preview.yml | 7 +- .../validate-source-generator-packages.ps1 | 299 ++++++++++++++++++ .github/workflows/preview-publish.yml | 7 +- .../Opc.Ua.SourceGeneration.Stack.csproj | 10 +- .../Opc.Ua.SourceGeneration.csproj | 11 +- .../SourceGeneratorPackaging.targets | 37 +++ 6 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 .azurepipelines/validate-source-generator-packages.ps1 create mode 100644 tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets diff --git a/.azurepipelines/preview.yml b/.azurepipelines/preview.yml index 789ea451bd..03f2ba5cfd 100644 --- a/.azurepipelines/preview.yml +++ b/.azurepipelines/preview.yml @@ -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'))) diff --git a/.azurepipelines/validate-source-generator-packages.ps1 b/.azurepipelines/validate-source-generator-packages.ps1 new file mode 100644 index 0000000000..b34ecd3ef8 --- /dev/null +++ b/.azurepipelines/validate-source-generator-packages.ps1 @@ -0,0 +1,299 @@ +<# +.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))\.(?[0-9].+)\.nupkg$" + $package = Get-ChildItem -Path $PackageDirectory -Filter "$PackageId*.nupkg" -File -Recurse | + Where-Object { $_.Name -notlike "*.snupkg" -and $_.Name -match $packagePattern } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + + Assert-Condition ($null -ne $package) "Package '$PackageId' was not found in '$PackageDirectory'." + + $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)) + { + " STACKGEN001" + } + else + { + "" + } + @" + + + net10.0 + enable + true +$expectedDiagnosticSuppression + + + + + +"@ | 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 $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 +{ + "" | Set-Content -Path (Join-Path $validationRoot "Directory.Build.props") -Encoding utf8 + "" | Set-Content -Path (Join-Path $validationRoot "Directory.Build.targets") -Encoding utf8 + @" + + + false + + +"@ | Set-Content -Path (Join-Path $validationRoot "Directory.Packages.props") -Encoding utf8 + $escapedPackageDirectory = [System.Security.SecurityElement]::Escape($PackageDirectory) + @" + + + + + + + +"@ | 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." diff --git a/.github/workflows/preview-publish.yml b/.github/workflows/preview-publish.yml index f57849648e..4c7c5d820d 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -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/ diff --git a/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj b/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj index 288c856525..bd7ef460a7 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj +++ b/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj @@ -1,7 +1,6 @@ netstandard2.0 - false $(AssemblyPrefix).SourceGeneration.Stack Opc.Ua.SourceGeneration OPC UA source generation Library @@ -20,6 +19,9 @@ true true + + $(PackageId).Debug + true true @@ -32,12 +34,13 @@ + all - Analzyer - false + Analyzer @@ -56,4 +59,5 @@ + diff --git a/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj b/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj index 77d0003518..4a1eda9a27 100644 --- a/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj +++ b/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj @@ -1,7 +1,6 @@ netstandard2.0 - false $(AssemblyPrefix).SourceGeneration Opc.Ua.SourceGeneration OPC UA source generation Library @@ -20,6 +19,9 @@ true true + + $(PackageId).Debug + true true @@ -32,12 +34,13 @@ + all - Analzyer - false + Analyzer @@ -54,5 +57,5 @@ + - diff --git a/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets b/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets new file mode 100644 index 0000000000..23ec2edffa --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets @@ -0,0 +1,37 @@ + + + false + + false + true + true + + $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorRuntimeAssets + + + Compile;CopyFilesToOutputDirectory;ResolveReferences + + + + + <_SourceGeneratorRuntimeAsset Include="@(ReferenceCopyLocalPaths)" + Condition="'$(NoBuild)' != 'true' AND + '%(ReferenceCopyLocalPaths.Extension)' == '.dll' 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'" /> + + <_SourceGeneratorRuntimeAsset Include="$(TargetDir)*.dll" + Exclude="$(TargetPath);$(TargetDir)Microsoft.CodeAnalysis*.dll" + Condition="'$(NoBuild)' == 'true'" /> + + analyzers/dotnet/cs/$(TargetFileName) + + + analyzers/dotnet/cs/%(_SourceGeneratorRuntimeAsset.Filename)%(_SourceGeneratorRuntimeAsset.Extension) + + + + From 80de016c4a199ead5a49a3fefc3a42c1649481d6 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Sun, 19 Jul 2026 18:17:07 +0200 Subject: [PATCH 2/2] Address source generator review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../validate-source-generator-packages.ps1 | 14 ++++++++------ .../SourceGeneratorPackaging.targets | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.azurepipelines/validate-source-generator-packages.ps1 b/.azurepipelines/validate-source-generator-packages.ps1 index b34ecd3ef8..7aa227db3b 100644 --- a/.azurepipelines/validate-source-generator-packages.ps1 +++ b/.azurepipelines/validate-source-generator-packages.ps1 @@ -37,12 +37,14 @@ function Get-PackageInfo ) $packagePattern = "^$([Regex]::Escape($PackageId))\.(?[0-9].+)\.nupkg$" - $package = Get-ChildItem -Path $PackageDirectory -Filter "$PackageId*.nupkg" -File -Recurse | - Where-Object { $_.Name -notlike "*.snupkg" -and $_.Name -match $packagePattern } | - Sort-Object LastWriteTimeUtc -Descending | - Select-Object -First 1 + $packages = @(Get-ChildItem -Path $PackageDirectory -Filter "$PackageId*.nupkg" -File -Recurse | + Where-Object { $_.Name -notlike "*.snupkg" -and $_.Name -match $packagePattern }) - Assert-Condition ($null -ne $package) "Package '$PackageId' was not found in '$PackageDirectory'." + 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 @@ -253,7 +255,7 @@ Add-Type -AssemblyName System.IO.Compression.FileSystem $resolvedPackageDirectory = (Resolve-Path $PackageDirectory).Path $PackageDirectory = $resolvedPackageDirectory $repoRoot = Split-Path $PSScriptRoot -Parent -$validationRoot = Join-Path $repoRoot "artifacts\source-generator-consumer" +$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 ( diff --git a/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets b/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets index 23ec2edffa..9002af118b 100644 --- a/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets +++ b/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets @@ -18,6 +18,7 @@ <_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