diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e1be7cd4..57b838c08 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -135,6 +135,14 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Upload all PDB symbols archive + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.build-package.outputs.artifact-name }}-all-symbols + path: tested/eng/Builder/bin/nupkg/*.symbols.zip + if-no-files-found: error + retention-days: 7 + publish-package: needs: [build-solution, build-package] runs-on: windows-latest diff --git a/eng/Builder.Tests/GitHubActionsBuildTests.cs b/eng/Builder.Tests/GitHubActionsBuildTests.cs index 845d86f87..0e45a8e57 100644 --- a/eng/Builder.Tests/GitHubActionsBuildTests.cs +++ b/eng/Builder.Tests/GitHubActionsBuildTests.cs @@ -58,6 +58,15 @@ public void PullRequestEvent_CreatesTraceablePackageIdentity() "nupkg", "WpfLab.WpfRuntime.0.0.0-test.20260311123456.sha333333.snupkg")), identity.SymbolPackagePath); + Assert.Equal( + Path.GetFullPath(Path.Join( + repositoryPath, + "eng", + "Builder", + "bin", + "nupkg", + "WpfLab.WpfRuntime.0.0.0-test.20260311123456.sha333333.symbols.zip")), + identity.AllSymbolsArchivePath); Assert.Equal( $"WpfLab.WpfRuntime-nupkg-pr-11781-sha-{TestedSha}-run-42-attempt-3-version-0.0.0-test.20260311123456.sha333333", identity.ArtifactName); diff --git a/eng/Builder.Tests/GitHubArtifactCommentTests.cs b/eng/Builder.Tests/GitHubArtifactCommentTests.cs index c86e80431..bf243d875 100644 --- a/eng/Builder.Tests/GitHubArtifactCommentTests.cs +++ b/eng/Builder.Tests/GitHubArtifactCommentTests.cs @@ -66,6 +66,7 @@ public void ArtifactFilter_RequiresExactIdentityAndSortsById() CreateArtifact(2, $"WpfLab.WpfRuntime-nupkg-pr-11781-sha-{TestedSha}-run-42-attempt-2-version-0.0.0-test.20260311123456.sha222222"), CreateArtifact(1, $"WpfLab.WpfRuntime-nupkg-pr-11781-sha-{TestedSha}-run-42-attempt-2-version-0.0.0-test.20260311123456.sha222222"), CreateArtifact(6, $"WpfLab.WpfRuntime-nupkg-pr-11781-sha-{TestedSha}-run-42-attempt-2-version-0.0.0-test.20260311123456.sha222222-symbols"), + CreateArtifact(7, $"WpfLab.WpfRuntime-nupkg-pr-11781-sha-{TestedSha}-run-42-attempt-2-version-0.0.0-test.20260311123456.sha222222-all-symbols"), CreateArtifact(3, $"WpfLab.WpfRuntime-nupkg-pr-11781-sha-{HeadSha}-run-99-attempt-2-version-0.0.0-test.20260311123456.sha111111"), CreateArtifact(4, $"WpfLab.WpfRuntime-nupkg-pr-11781-sha-{HeadSha}-run-42-attempt-2-version-0.0.0-test.20260311123456.sha111111", expired: true), CreateArtifact(5, $"WpfLab.WpfRuntime-nupkg-pr-11781-sha-{HeadSha}-run-42-attempt-2-version-0.0.0-test.20260311123456.sha111111", size: 0), @@ -73,7 +74,7 @@ public void ArtifactFilter_RequiresExactIdentityAndSortsById() var filtered = GitHubArtifactCommentFormatter.FilterArtifacts(artifacts, 11781, 42, 2); - Assert.Equal([1L, 2L, 6L], filtered.Select(artifact => artifact.Id)); + Assert.Equal([1L, 2L, 6L, 7L], filtered.Select(artifact => artifact.Id)); Assert.All(filtered, artifact => Assert.Equal(TestedSha, artifact.TestedSha.ToString())); Assert.All(filtered, artifact => Assert.Equal("0.0.0-test.20260311123456.sha222222", artifact.PackageVersion)); } diff --git a/eng/Builder.Tests/NuGetPackageServiceTests.cs b/eng/Builder.Tests/NuGetPackageServiceTests.cs index 71c730ea7..57f268c99 100644 --- a/eng/Builder.Tests/NuGetPackageServiceTests.cs +++ b/eng/Builder.Tests/NuGetPackageServiceTests.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using System.Xml.Linq; using WpfReorganize.Builder; @@ -5,19 +6,44 @@ namespace WpfReorganize.Builder.Tests; public sealed class NuGetPackageServiceTests { + [Fact] + public void GenerateNuspecIncludesRepositoryReadme() + { + var stagingDirectory = CreateStagingDirectory(); + foreach (var rid in new[] { "win-x64", "win-x86" }) + { + Directory.CreateDirectory(Path.Join(stagingDirectory, "runtimes", rid, "native")); + } + + var readmePath = Path.Join(Path.GetTempPath(), $"builder-readme-{Guid.NewGuid():N}.md"); + File.WriteAllText(readmePath, "# Package README"); + + var nuspecPath = NuGetPackageService.GenerateNuspec(stagingDirectory, "1.2.3", [], readmePath); + var document = XDocument.Load(nuspecPath); + XNamespace ns = "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"; + var readme = document.Descendants(ns + "readme").Single().Value; + var readmeTarget = document.Descendants(ns + "file") + .Single(element => element.Attribute("src")?.Value == "README.md") + .Attribute("target")?.Value; + var packagedContent = File.ReadAllText(Path.Join(stagingDirectory, "README.md")); + + Assert.Equal(("README.md", "README.md", "# Package README"), (readme, readmeTarget, packagedContent)); + } + [Fact] public void GenerateSymbolNuspecIncludesMultiplePdbFilesWithRuntimePaths() { var stagingDirectory = CreateStagingDirectory(); - WritePdb(stagingDirectory, "win-x64", "PresentationCore.pdb"); - WritePdb(stagingDirectory, "win-x64", "PresentationFramework.pdb"); - WritePdb(stagingDirectory, "win-x86", "PresentationCore.pdb"); + WritePortablePdb(stagingDirectory, "win-x64", "PresentationCore.pdb"); + WritePortablePdb(stagingDirectory, "win-x64", "PresentationFramework.pdb"); + WritePortablePdb(stagingDirectory, "win-x86", "PresentationCore.pdb"); var nuspecPath = NuGetPackageService.GenerateSymbolNuspec(stagingDirectory, "1.2.3"); var document = XDocument.Load(nuspecPath); XNamespace ns = "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"; var targets = document.Descendants(ns + "file") - .Select(element => element.Attribute("target")?.Value) + .Select(element => element.Attribute("target")?.Value + ?? throw new InvalidDataException("Symbol file target is missing")) .ToArray(); Assert.Equal( @@ -30,14 +56,67 @@ public void GenerateSymbolNuspecIncludesMultiplePdbFilesWithRuntimePaths() } [Fact] - public void GenerateSymbolNuspecThrowsWhenNoPdbFilesExist() + public void GenerateSymbolNuspecDeclaresSymbolsPackageType() + { + var stagingDirectory = CreateStagingDirectory(); + WritePortablePdb(stagingDirectory, "win-x64", "PresentationCore.pdb"); + + var nuspecPath = NuGetPackageService.GenerateSymbolNuspec(stagingDirectory, "1.2.3"); + var document = XDocument.Load(nuspecPath); + XNamespace ns = "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"; + var packageType = document.Descendants(ns + "packageType").Single(); + + Assert.Equal("SymbolsPackage", packageType.Attribute("name")?.Value); + } + + [Fact] + public void GenerateSymbolNuspecExcludesNonPortablePdbFiles() { var stagingDirectory = CreateStagingDirectory(); + WritePortablePdb(stagingDirectory, "win-x64", "Portable.pdb"); + WriteWindowsPdb(stagingDirectory, "win-x64", "Windows.pdb"); + + var nuspecPath = NuGetPackageService.GenerateSymbolNuspec(stagingDirectory, "1.2.3"); + var document = XDocument.Load(nuspecPath); + XNamespace ns = "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"; + var targets = document.Descendants(ns + "file") + .Select(element => element.Attribute("target")?.Value + ?? throw new InvalidDataException("Symbol file target is missing")) + .ToArray(); + + Assert.Equal([@"runtimes\win-x64\lib\net8.0\Portable.pdb"], targets); + } + + [Fact] + public void GenerateSymbolNuspecThrowsWhenNoPortablePdbFilesExist() + { + var stagingDirectory = CreateStagingDirectory(); + WriteWindowsPdb(stagingDirectory, "win-x64", "Windows.pdb"); Assert.Throws(() => NuGetPackageService.GenerateSymbolNuspec(stagingDirectory, "1.2.3")); } + [Fact] + public void CreateAllSymbolsArchiveIncludesPortableAndNonPortablePdbFiles() + { + var stagingDirectory = CreateStagingDirectory(); + WritePortablePdb(stagingDirectory, "win-x64", "PresentationCore.pdb"); + WriteWindowsPdb(stagingDirectory, "win-x86", "PresentationCore.pdb"); + var outputDirectory = Path.Join(Path.GetTempPath(), $"builder-symbol-output-{Guid.NewGuid():N}"); + + var archivePath = NuGetPackageService.CreateAllSymbolsArchive(stagingDirectory, "1.2.3", outputDirectory); + using var archive = ZipFile.OpenRead(archivePath); + var entries = archive.Entries.Select(entry => entry.FullName).ToArray(); + + Assert.Equal( + [ + "runtimes/win-x64/lib/net8.0/PresentationCore.pdb", + "runtimes/win-x86/lib/net8.0/PresentationCore.pdb", + ], + entries); + } + private static string CreateStagingDirectory() { var stagingDirectory = Path.Join(Path.GetTempPath(), $"builder-symbol-tests-{Guid.NewGuid():N}"); @@ -49,6 +128,13 @@ private static string CreateStagingDirectory() return stagingDirectory; } - private static void WritePdb(string stagingDirectory, string rid, string fileName) => - File.WriteAllText(Path.Join(stagingDirectory, "runtimes", rid, "lib", "net8.0", fileName), "pdb"); + private static void WritePortablePdb(string stagingDirectory, string rid, string fileName) => + File.WriteAllBytes( + Path.Join(stagingDirectory, "runtimes", rid, "lib", "net8.0", fileName), + "BSJBportable"u8.ToArray()); + + private static void WriteWindowsPdb(string stagingDirectory, string rid, string fileName) => + File.WriteAllBytes( + Path.Join(stagingDirectory, "runtimes", rid, "lib", "net8.0", fileName), + "Microsoft C/C++ MSF 7.00"u8.ToArray()); } diff --git a/eng/Builder.Tests/WorkflowContractTests.cs b/eng/Builder.Tests/WorkflowContractTests.cs index c22e33d36..3480e006f 100644 --- a/eng/Builder.Tests/WorkflowContractTests.cs +++ b/eng/Builder.Tests/WorkflowContractTests.cs @@ -21,10 +21,12 @@ public void BuildWorkflow_UsesTrustedReadOnlyPullRequestTargetContract() Assert.Equal(2, CountOccurrences(normalized, " path: tested\n fetch-depth: 0")); Assert.Equal(2, CountOccurrences(workflow, "Builder.dll ci-build")); Assert.DoesNotContain("github.event.pull_request.head.sha", workflow, StringComparison.Ordinal); - Assert.Equal(2, CountOccurrences(workflow, "tested/eng/Builder/bin/nupkg/*.")); + Assert.Equal(3, CountOccurrences(workflow, "tested/eng/Builder/bin/nupkg/*.")); Assert.Contains("path: tested/eng/Builder/bin/nupkg/*.nupkg", workflow, StringComparison.Ordinal); Assert.Contains("path: tested/eng/Builder/bin/nupkg/*.snupkg", workflow, StringComparison.Ordinal); + Assert.Contains("path: tested/eng/Builder/bin/nupkg/*.symbols.zip", workflow, StringComparison.Ordinal); Assert.Contains("name: ${{ steps.build-package.outputs.artifact-name }}-symbols", workflow, StringComparison.Ordinal); + Assert.Contains("name: ${{ steps.build-package.outputs.artifact-name }}-all-symbols", workflow, StringComparison.Ordinal); Assert.DoesNotContain("steps.build-package.outputs.symbol-package-path", workflow, StringComparison.Ordinal); Assert.Contains("name: Push generated package to NuGet registries", workflow, StringComparison.Ordinal); Assert.DoesNotContain("if: github.event_name != 'pull_request_target'", workflow, StringComparison.Ordinal); diff --git a/eng/Builder/BuildService.cs b/eng/Builder/BuildService.cs index cf60a4223..7d8a42e73 100644 --- a/eng/Builder/BuildService.cs +++ b/eng/Builder/BuildService.cs @@ -196,10 +196,12 @@ public static int Run(BuilderContext context, string version) return 1; } var runtimePackageDependencies = NuGetPackageService.ReadRuntimePackageDependencies(context.RepoRoot); -var nuspecPath = NuGetPackageService.GenerateNuspec(context.StagingDir, version, runtimePackageDependencies); +var readmePath = Path.Join(context.RepoRoot, "README.md"); +var nuspecPath = NuGetPackageService.GenerateNuspec(context.StagingDir, version, runtimePackageDependencies, readmePath); var symbolNuspecPath = NuGetPackageService.GenerateSymbolNuspec(context.StagingDir, version); var nupkgPath = NuGetPackageService.PackNuGet(nuspecPath, context.NupkgOutputDir); var snupkgPath = NuGetPackageService.PackSymbolNuGet(symbolNuspecPath, context.NupkgOutputDir); +var allSymbolsArchivePath = NuGetPackageService.CreateAllSymbolsArchive(context.ArtifactsDir, version, context.NupkgOutputDir); // ---- Step 7: Compare against official package ---- Log.Step("Comparing against official Microsoft.WindowsDesktop.App.Ref..."); @@ -210,6 +212,7 @@ public static int Run(BuilderContext context, string version) Log.Info($"Build complete! Elapsed: {elapsed.TotalSeconds:F1}s"); Log.Info($"NuGet package: {nupkgPath}"); Log.Info($"NuGet symbol package: {snupkgPath}"); +Log.Info($"All-symbols archive: {allSymbolsArchivePath}"); return failedProjects.Count > 0 ? 2 : 0; } diff --git a/eng/Builder/GitHubActionsBuildCommand.cs b/eng/Builder/GitHubActionsBuildCommand.cs index 985c79e4c..43f92e5eb 100644 --- a/eng/Builder/GitHubActionsBuildCommand.cs +++ b/eng/Builder/GitHubActionsBuildCommand.cs @@ -97,6 +97,7 @@ public async Task RunAsync() ["version"] = identity.PackageVersion, ["package-path"] = identity.PackagePath, ["symbol-package-path"] = identity.SymbolPackagePath, + ["all-symbols-archive-path"] = identity.AllSymbolsArchivePath, ["artifact-name"] = identity.ArtifactName, }); } diff --git a/eng/Builder/GitHubActionsBuildIdentity.cs b/eng/Builder/GitHubActionsBuildIdentity.cs index 2412516ed..45a0d9d14 100644 --- a/eng/Builder/GitHubActionsBuildIdentity.cs +++ b/eng/Builder/GitHubActionsBuildIdentity.cs @@ -5,6 +5,7 @@ internal sealed record GitHubActionsBuildIdentity( string PackageVersion, string PackagePath, string SymbolPackagePath, + string AllSymbolsArchivePath, string ArtifactName) { public static GitHubActionsBuildIdentity Create( @@ -41,6 +42,9 @@ public static GitHubActionsBuildIdentity Create( "nupkg", $"{PackageMetadata.Id}.{packageVersion}.nupkg")); var symbolPackagePath = Path.ChangeExtension(packagePath, ".snupkg"); + var allSymbolsArchivePath = Path.Join( + Path.GetDirectoryName(packagePath)!, + $"{Path.GetFileNameWithoutExtension(packagePath)}.symbols.zip"); var artifactName = $"{PackageMetadata.Id}-nupkg-{artifactIdentity}-sha-{parsedTestedSha}-run-{runId}-attempt-{runAttempt}-version-{packageVersion}"; @@ -49,6 +53,7 @@ public static GitHubActionsBuildIdentity Create( packageVersion, packagePath, symbolPackagePath, + allSymbolsArchivePath, artifactName); } diff --git a/eng/Builder/GitHubActionsBuildService.cs b/eng/Builder/GitHubActionsBuildService.cs index 769017ab6..c4048b50d 100644 --- a/eng/Builder/GitHubActionsBuildService.cs +++ b/eng/Builder/GitHubActionsBuildService.cs @@ -192,6 +192,7 @@ await RunRequiredAsync( identity.PackageVersion).ConfigureAwait(false); RequireNonEmptyFile(identity.PackagePath); RequireNonEmptyFile(identity.SymbolPackagePath); + RequireNonEmptyFile(identity.AllSymbolsArchivePath); await RunRequiredAsync( "test generated package", _dotnetPath, @@ -209,6 +210,7 @@ await RunRequiredAsync( identity.PackagePath).ConfigureAwait(false); RequireNonEmptyFile(identity.PackagePath); RequireNonEmptyFile(identity.SymbolPackagePath); + RequireNonEmptyFile(identity.AllSymbolsArchivePath); } private Task RebuildSolutionAsync( diff --git a/eng/Builder/GitHubArtifactCommentFormatter.cs b/eng/Builder/GitHubArtifactCommentFormatter.cs index 4c7b4aeb3..038775f2d 100644 --- a/eng/Builder/GitHubArtifactCommentFormatter.cs +++ b/eng/Builder/GitHubArtifactCommentFormatter.cs @@ -117,7 +117,7 @@ public static IReadOnlyList FilterArtifacts( { ArgumentNullException.ThrowIfNull(artifacts); var pattern = new Regex( - $"^{Regex.Escape(PackageMetadata.Id)}-nupkg-pr-{pullRequestNumber}-sha-([0-9a-fA-F]{{40}})-run-{runId}-attempt-{runAttempt}-version-([0-9A-Za-z.+-]+?)(?:-symbols)?$", + $"^{Regex.Escape(PackageMetadata.Id)}-nupkg-pr-{pullRequestNumber}-sha-([0-9a-fA-F]{{40}})-run-{runId}-attempt-{runAttempt}-version-([0-9A-Za-z.+-]+?)(?:-symbols|-all-symbols)?$", RegexOptions.CultureInvariant); return artifacts .Where(artifact => artifact is not null diff --git a/eng/Builder/NuGetPackageService.cs b/eng/Builder/NuGetPackageService.cs index 61b50719d..43bb594f4 100644 --- a/eng/Builder/NuGetPackageService.cs +++ b/eng/Builder/NuGetPackageService.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using System.Text; using System.Xml.Linq; @@ -84,8 +85,17 @@ public static void CopyIjwHostFromPackage(Dictionary packagePath public static string GenerateNuspec( string stagingDir, string version, - IReadOnlyList runtimePackageDependencies) + IReadOnlyList runtimePackageDependencies, + string readmePath) { + if (!File.Exists(readmePath)) + { + throw new FileNotFoundException("Package README file was not found", readmePath); + } + + const string packageReadmeFileName = "README.md"; + File.Copy(readmePath, Path.Join(stagingDir, packageReadmeFileName), overwrite: true); + var referenceDir = Path.Join(stagingDir, "ref", "net8.0"); var referenceFiles = Directory.Exists(referenceDir) ? Directory.GetFiles(referenceDir, "*.dll").Select(Path.GetFileName).OrderBy(x => x).ToList() @@ -126,6 +136,7 @@ public static string GenerateNuspec( } files.AppendLine($" "); + files.AppendLine($" "); var nuspecContent = $$""" @@ -138,6 +149,7 @@ public static string GenerateNuspec( WpfLab MIT {{PackageMetadata.ProjectUrl}} + {{packageReadmeFileName}} WPF WindowsDesktop {{dependencyGroups}} @@ -161,13 +173,19 @@ public static string GenerateSymbolNuspec(string stagingDir, string version) foreach (var file in Directory.GetFiles(runtimeLibDir, "*.pdb").OrderBy(Path.GetFileName)) { var fileName = Path.GetFileName(file); + if (!IsPortablePdb(file)) + { + Log.Info($" Excluding non-portable PDB from symbol package: runtimes/{rid}/lib/net8.0/{fileName}"); + continue; + } + files.AppendLine($" "); } } if (files.Length == 0) { - throw new InvalidOperationException("No PDB files were found for the symbol package"); + throw new InvalidOperationException("No portable PDB files were found for the symbol package"); } var nuspecContent = $$""" @@ -182,6 +200,9 @@ public static string GenerateSymbolNuspec(string stagingDir, string version) MIT {{PackageMetadata.ProjectUrl}} WPF WindowsDesktop symbols + + + {{files}} @@ -324,6 +345,34 @@ public static string PackNuGet(string nuspecPath, string outputDir) return nupkgPath; } +public static string CreateAllSymbolsArchive(string buildOutputDir, string version, string outputDir) +{ + Directory.CreateDirectory(outputDir); + var archivePath = Path.Join(outputDir, $"{PackageMetadata.Id}.{version}.symbols.zip"); + File.Delete(archivePath); + + var pdbFiles = Directory.GetFiles(buildOutputDir, "*.pdb", SearchOption.AllDirectories) + .OrderBy(path => Path.GetRelativePath(buildOutputDir, path), StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (pdbFiles.Length == 0) + { + throw new InvalidOperationException("No PDB files were found for the all-symbols archive"); + } + + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + foreach (var pdbPath in pdbFiles) + { + var entryName = Path.GetRelativePath(buildOutputDir, pdbPath).Replace('\\', '/'); + archive.CreateEntryFromFile(pdbPath, entryName, CompressionLevel.Optimal); + } + } + + var fileInfo = new FileInfo(archivePath); + Log.Info($" All-symbols archive generated: {archivePath} ({fileInfo.Length / 1024.0:F1} KB, {pdbFiles.Length} PDB files)"); + return archivePath; +} + public static string PackSymbolNuGet(string nuspecPath, string outputDir) { Directory.CreateDirectory(outputDir); @@ -345,6 +394,14 @@ public static string PackSymbolNuGet(string nuspecPath, string outputDir) } } +private static bool IsPortablePdb(string path) +{ + Span signature = stackalloc byte[4]; + using var stream = File.OpenRead(path); + return stream.Read(signature) == signature.Length + && signature.SequenceEqual("BSJB"u8); +} + private static string PackNuspec(string nuspecPath, string outputDir) { // Place _pack.csproj outside the repo tree (system temp directory)