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..7aa227db3b --- /dev/null +++ b/.azurepipelines/validate-source-generator-packages.ps1 @@ -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))\.(?[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)) + { + " 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 (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..9002af118b --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets @@ -0,0 +1,38 @@ + + + false + + false + true + true + + $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorRuntimeAssets + + + Compile;CopyFilesToOutputDirectory;ResolveReferences + + + + + <_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'" /> + + <_SourceGeneratorRuntimeAsset Include="$(TargetDir)*.dll" + Exclude="$(TargetPath);$(TargetDir)Microsoft.CodeAnalysis*.dll" + Condition="'$(NoBuild)' == 'true'" /> + + analyzers/dotnet/cs/$(TargetFileName) + + + analyzers/dotnet/cs/%(_SourceGeneratorRuntimeAsset.Filename)%(_SourceGeneratorRuntimeAsset.Extension) + + + +