From 950b581dd5089958ccefbaeb418b7073fca48dc9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 29 Dec 2025 21:11:28 +0300 Subject: [PATCH 1/7] feat(nuget): Add workspaces support for sequential publishing - Add automatic discovery of NuGet packages from .sln/.csproj files - Topologically sort packages by dependencies for correct publish order - Change from parallel (Promise.all) to sequential publishing - Remove --skip-duplicate flag (no longer needed with proper ordering) - Add workspace config options: workspaces, solutionPath, includeWorkspaces, excludeWorkspaces, artifactTemplate - Security: All discovery uses static file parsing only (no code execution) Closes #649 --- docs/src/content/docs/targets/nuget.md | 69 +++- src/targets/__tests__/nuget.test.ts | 275 +++++++++++++ src/targets/nuget.ts | 217 ++++++++-- .../complex-solution/Sentry.sln | 19 + .../Sentry.AspNetCore.csproj | 13 + .../Sentry.Extensions.Logging.csproj | 13 + .../src/Sentry.Serilog/Sentry.Serilog.csproj | 13 + .../complex-solution/src/Sentry/Sentry.csproj | 9 + .../dotnet-workspaces/no-solution/empty.txt | 1 + .../simple-solution/Sentry.sln | 15 + .../Sentry.AspNetCore.csproj | 13 + .../src/Sentry.Core/Sentry.Core.csproj | 9 + src/utils/__tests__/dotnetWorkspaces.test.ts | 233 +++++++++++ src/utils/dotnetWorkspaces.ts | 383 ++++++++++++++++++ 14 files changed, 1242 insertions(+), 40 deletions(-) create mode 100644 src/targets/__tests__/nuget.test.ts create mode 100644 src/utils/__fixtures__/dotnet-workspaces/complex-solution/Sentry.sln create mode 100644 src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj create mode 100644 src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj create mode 100644 src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.Serilog/Sentry.Serilog.csproj create mode 100644 src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry/Sentry.csproj create mode 100644 src/utils/__fixtures__/dotnet-workspaces/no-solution/empty.txt create mode 100644 src/utils/__fixtures__/dotnet-workspaces/simple-solution/Sentry.sln create mode 100644 src/utils/__fixtures__/dotnet-workspaces/simple-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj create mode 100644 src/utils/__fixtures__/dotnet-workspaces/simple-solution/src/Sentry.Core/Sentry.Core.csproj create mode 100644 src/utils/__tests__/dotnetWorkspaces.test.ts create mode 100644 src/utils/dotnetWorkspaces.ts diff --git a/docs/src/content/docs/targets/nuget.md b/docs/src/content/docs/targets/nuget.md index 74a89bc3..b7c439dc 100644 --- a/docs/src/content/docs/targets/nuget.md +++ b/docs/src/content/docs/targets/nuget.md @@ -5,13 +5,32 @@ description: Publish .NET packages to NuGet Uploads packages to [NuGet](https://www.nuget.org/) via .NET Core. -:::note -This target allows re-entrant publishing to handle interrupted releases when publishing multiple packages. -::: - ## Configuration -No additional configuration options. +| Option | Type | Description | +|--------|------|-------------| +| `workspaces` | `boolean` | Enable workspace discovery to auto-generate targets for all packages in the solution | +| `solutionPath` | `string` | Path to the solution file (`.sln`) relative to repo root. Auto-discovers if not specified | +| `includeWorkspaces` | `string` | Regex pattern to filter which packages to include. Example: `'/^Sentry\./'` | +| `excludeWorkspaces` | `string` | Regex pattern to filter which packages to exclude. Example: `'/\.Tests$/'` | +| `artifactTemplate` | `string` | Template for artifact filenames. Variables: `{{packageId}}`, `{{version}}` | +| `serverUrl` | `string` | NuGet server URL. Default: `https://api.nuget.org/v3/index.json` | + +## Workspace Support + +When `workspaces: true` is enabled, Craft will automatically: + +1. Parse the solution file (`.sln`) to discover all projects +2. Parse each `.csproj` file to extract package IDs and dependencies +3. Sort packages topologically so dependencies are published before dependents +4. Expand the single nuget target into multiple individual targets (one per package) +5. Publish packages sequentially in the correct order + +This is useful for monorepos with multiple NuGet packages that depend on each other. + +:::note +Workspace discovery uses static file parsing only and does not execute any code from the target repository. +::: ## Environment Variables @@ -20,9 +39,47 @@ No additional configuration options. | `NUGET_API_TOKEN` | NuGet [API token](https://www.nuget.org/account/apikeys) | | `NUGET_DOTNET_BIN` | Path to .NET Core. Default: `dotnet` | -## Example +## Examples + +### Basic Usage + +Publishes all `.nupkg` artifacts found: + +```yaml +targets: + - name: nuget +``` + +### Workspace Discovery + +Automatically discovers and publishes all packages from a solution file in dependency order: + +```yaml +targets: + - name: nuget + workspaces: true +``` + +### Workspace with Filtering + +Publish only packages matching a pattern, excluding test packages: + +```yaml +targets: + - name: nuget + workspaces: true + solutionPath: src/Sentry.sln + includeWorkspaces: '/^Sentry\./' + excludeWorkspaces: '/\.Tests$/' +``` + +### Custom Artifact Template + +Use a custom artifact filename pattern: ```yaml targets: - name: nuget + workspaces: true + artifactTemplate: 'packages/{{packageId}}.{{version}}.nupkg' ``` diff --git a/src/targets/__tests__/nuget.test.ts b/src/targets/__tests__/nuget.test.ts new file mode 100644 index 00000000..15794b42 --- /dev/null +++ b/src/targets/__tests__/nuget.test.ts @@ -0,0 +1,275 @@ +import { vi, type MockInstance } from 'vitest'; +import { NugetTarget } from '../nuget'; +import * as dotnetWorkspaces from '../../utils/dotnetWorkspaces'; + +describe('NugetTarget.expand', () => { + let discoverDotnetPackagesMock: MockInstance; + + afterEach(() => { + discoverDotnetPackagesMock?.mockRestore(); + }); + + it('returns config as-is when workspaces is not enabled', async () => { + const config = { name: 'nuget', id: 'Sentry.Core' }; + const result = await NugetTarget.expand(config, '/root'); + + expect(result).toEqual([config]); + }); + + it('returns empty array when no packages found', async () => { + discoverDotnetPackagesMock = vi + .spyOn(dotnetWorkspaces, 'discoverDotnetPackages') + .mockResolvedValue(null); + + const config = { name: 'nuget', workspaces: true }; + const result = await NugetTarget.expand(config, '/root'); + + expect(result).toEqual([]); + }); + + it('expands to individual targets for each package', async () => { + discoverDotnetPackagesMock = vi + .spyOn(dotnetWorkspaces, 'discoverDotnetPackages') + .mockResolvedValue({ + solutionPath: '/root/Sentry.sln', + packages: [ + { + packageId: 'Sentry.Core', + projectPath: '/root/src/Sentry.Core/Sentry.Core.csproj', + isPackable: true, + projectDependencies: [], + }, + { + packageId: 'Sentry.AspNetCore', + projectPath: '/root/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj', + isPackable: true, + projectDependencies: ['Sentry.Core'], + }, + ], + }); + + const config = { name: 'nuget', workspaces: true }; + const result = await NugetTarget.expand(config, '/root'); + + // Should return targets in dependency order (Core before AspNetCore) + expect(result).toHaveLength(2); + expect(result[0].id).toBe('Sentry.Core'); + expect(result[1].id).toBe('Sentry.AspNetCore'); + }); + + it('generates correct includeNames pattern for each package', async () => { + discoverDotnetPackagesMock = vi + .spyOn(dotnetWorkspaces, 'discoverDotnetPackages') + .mockResolvedValue({ + solutionPath: '/root/Sentry.sln', + packages: [ + { + packageId: 'Sentry.Core', + projectPath: '/root/src/Sentry.Core/Sentry.Core.csproj', + isPackable: true, + projectDependencies: [], + }, + ], + }); + + const config = { name: 'nuget', workspaces: true }; + const result = await NugetTarget.expand(config, '/root'); + + expect(result).toHaveLength(1); + expect(result[0].includeNames).toBe('/^Sentry\\.Core\\.\\d.*\\.nupkg$/'); + }); + + it('filters packages by includeWorkspaces pattern', async () => { + discoverDotnetPackagesMock = vi + .spyOn(dotnetWorkspaces, 'discoverDotnetPackages') + .mockResolvedValue({ + solutionPath: '/root/Sentry.sln', + packages: [ + { + packageId: 'Sentry.Core', + projectPath: '/root/src/Sentry.Core/Sentry.Core.csproj', + isPackable: true, + projectDependencies: [], + }, + { + packageId: 'Sentry.AspNetCore', + projectPath: '/root/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj', + isPackable: true, + projectDependencies: [], + }, + { + packageId: 'Other.Package', + projectPath: '/root/src/Other/Other.Package.csproj', + isPackable: true, + projectDependencies: [], + }, + ], + }); + + const config = { + name: 'nuget', + workspaces: true, + includeWorkspaces: '/^Sentry\\./', + }; + const result = await NugetTarget.expand(config, '/root'); + + expect(result).toHaveLength(2); + expect(result.map(r => r.id)).toEqual(['Sentry.Core', 'Sentry.AspNetCore']); + }); + + it('filters packages by excludeWorkspaces pattern', async () => { + discoverDotnetPackagesMock = vi + .spyOn(dotnetWorkspaces, 'discoverDotnetPackages') + .mockResolvedValue({ + solutionPath: '/root/Sentry.sln', + packages: [ + { + packageId: 'Sentry.Core', + projectPath: '/root/src/Sentry.Core/Sentry.Core.csproj', + isPackable: true, + projectDependencies: [], + }, + { + packageId: 'Sentry.Tests', + projectPath: '/root/test/Sentry.Tests/Sentry.Tests.csproj', + isPackable: true, + projectDependencies: [], + }, + ], + }); + + const config = { + name: 'nuget', + workspaces: true, + excludeWorkspaces: '/\\.Tests$/', + }; + const result = await NugetTarget.expand(config, '/root'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('Sentry.Core'); + }); + + it('propagates serverUrl to expanded targets', async () => { + discoverDotnetPackagesMock = vi + .spyOn(dotnetWorkspaces, 'discoverDotnetPackages') + .mockResolvedValue({ + solutionPath: '/root/Sentry.sln', + packages: [ + { + packageId: 'Sentry.Core', + projectPath: '/root/src/Sentry.Core/Sentry.Core.csproj', + isPackable: true, + projectDependencies: [], + }, + ], + }); + + const config = { + name: 'nuget', + workspaces: true, + serverUrl: 'https://custom.nuget.server/v3/index.json', + }; + const result = await NugetTarget.expand(config, '/root'); + + expect(result).toHaveLength(1); + expect(result[0].serverUrl).toBe( + 'https://custom.nuget.server/v3/index.json' + ); + }); + + it('uses artifactTemplate when provided', async () => { + discoverDotnetPackagesMock = vi + .spyOn(dotnetWorkspaces, 'discoverDotnetPackages') + .mockResolvedValue({ + solutionPath: '/root/Sentry.sln', + packages: [ + { + packageId: 'Sentry.Core', + projectPath: '/root/src/Sentry.Core/Sentry.Core.csproj', + isPackable: true, + projectDependencies: [], + }, + ], + }); + + const config = { + name: 'nuget', + workspaces: true, + artifactTemplate: 'packages/{{packageId}}.{{version}}.nupkg', + }; + const result = await NugetTarget.expand(config, '/root'); + + expect(result).toHaveLength(1); + expect(result[0].includeNames).toBe( + '/^packages\\/Sentry\\.Core\\.\\d.*\\.nupkg$/' + ); + }); + + it('sorts packages topologically by dependencies', async () => { + discoverDotnetPackagesMock = vi + .spyOn(dotnetWorkspaces, 'discoverDotnetPackages') + .mockResolvedValue({ + solutionPath: '/root/Sentry.sln', + packages: [ + { + packageId: 'Sentry.AspNetCore', + projectPath: '/root/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj', + isPackable: true, + projectDependencies: ['Sentry.Extensions.Logging'], + }, + { + packageId: 'Sentry', + projectPath: '/root/src/Sentry/Sentry.csproj', + isPackable: true, + projectDependencies: [], + }, + { + packageId: 'Sentry.Extensions.Logging', + projectPath: + '/root/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj', + isPackable: true, + projectDependencies: ['Sentry'], + }, + ], + }); + + const config = { name: 'nuget', workspaces: true }; + const result = await NugetTarget.expand(config, '/root'); + + expect(result).toHaveLength(3); + // Order should be: Sentry -> Extensions.Logging -> AspNetCore + expect(result.map(r => r.id)).toEqual([ + 'Sentry', + 'Sentry.Extensions.Logging', + 'Sentry.AspNetCore', + ]); + }); + + it('passes solutionPath to discoverDotnetPackages', async () => { + discoverDotnetPackagesMock = vi + .spyOn(dotnetWorkspaces, 'discoverDotnetPackages') + .mockResolvedValue({ + solutionPath: '/root/src/Sentry.sln', + packages: [ + { + packageId: 'Sentry', + projectPath: '/root/src/Sentry/Sentry.csproj', + isPackable: true, + projectDependencies: [], + }, + ], + }); + + const config = { + name: 'nuget', + workspaces: true, + solutionPath: 'src/Sentry.sln', + }; + await NugetTarget.expand(config, '/root'); + + expect(discoverDotnetPackagesMock).toHaveBeenCalledWith( + '/root', + 'src/Sentry.sln' + ); + }); +}); diff --git a/src/targets/nuget.ts b/src/targets/nuget.ts index aba0a5eb..8c0d386c 100644 --- a/src/targets/nuget.ts +++ b/src/targets/nuget.ts @@ -1,11 +1,21 @@ import { TargetConfig } from '../schemas/project_config'; +import { forEachChained } from '../utils/async'; import { ConfigurationError, reportError } from '../utils/errors'; +import { stringToRegexp } from '../utils/filters'; import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; import { BaseTarget } from './base'; import { BaseArtifactProvider, RemoteArtifact, } from '../artifact_providers/base'; +import { + discoverDotnetPackages, + sortDotnetPackages, + packageIdToNugetArtifactPattern, + packageIdToNugetArtifactFromTemplate, +} from '../utils/dotnetWorkspaces'; +import { filterWorkspacePackages } from '../utils/workspaces'; +import { logger } from '../logger'; /** Command to launch dotnet tools */ export const NUGET_DOTNET_BIN = process.env.NUGET_DOTNET_BIN || 'dotnet'; @@ -23,6 +33,40 @@ const SYMBOLS_NUGET_REGEX = /^.*\d\.\d.*\.snupkg$/; */ const DOTNET_SPAWN_OPTIONS = { cwd: '/' }; +/** Extended NuGet target configuration with workspace options */ +export interface NugetTargetConfig extends TargetConfig { + /** + * Enable workspace discovery to auto-generate nuget targets for all packages. + * When enabled, this target will be expanded into multiple targets, one per NuGet package. + */ + workspaces?: boolean; + /** + * Path to the solution file (.sln) relative to the repository root. + * If not specified, auto-discovers the first .sln file in the root directory. + */ + solutionPath?: string; + /** + * Regex pattern to filter which packages to include. + * Only packages with IDs matching this pattern will be published. + * Example: '/^Sentry\\./' + */ + includeWorkspaces?: string; + /** + * Regex pattern to filter which packages to exclude. + * Packages with IDs matching this pattern will not be published. + * Example: '/\\.Tests$/' + */ + excludeWorkspaces?: string; + /** + * Template for generating artifact filenames from package IDs. + * Variables: {{packageId}}, {{version}} + * Default convention: Sentry.Core -> Sentry.Core.{version}.nupkg + */ + artifactTemplate?: string; + /** NuGet server URL */ + serverUrl?: string; +} + /** Nuget target configuration options */ export interface NugetTargetOptions { /** Nuget API token */ @@ -40,8 +84,119 @@ export class NugetTarget extends BaseTarget { /** Target options */ public readonly nugetConfig: NugetTargetOptions; + /** + * Expand a nuget target config into multiple targets if workspaces is enabled. + * This static method is called during config loading to expand workspace targets. + * + * @param config The nuget target config + * @param rootDir The root directory of the project + * @returns Array of expanded target configs, or the original config in an array + */ + public static async expand( + config: NugetTargetConfig, + rootDir: string + ): Promise { + // If workspaces is not enabled, return the config as-is + if (!config.workspaces) { + return [config]; + } + + const result = await discoverDotnetPackages(rootDir, config.solutionPath); + + if (!result || result.packages.length === 0) { + logger.warn( + 'nuget target has workspaces enabled but no packable projects were found' + ); + return []; + } + + // Convert to workspace packages for filtering + const workspacePackages = result.packages.map(pkg => ({ + name: pkg.packageId, + location: pkg.projectPath, + private: !pkg.isPackable, + hasPublicAccess: true, + workspaceDependencies: pkg.projectDependencies, + })); + + // Filter packages based on include/exclude patterns + let includePattern: RegExp | undefined; + let excludePattern: RegExp | undefined; + + if (config.includeWorkspaces) { + includePattern = stringToRegexp(config.includeWorkspaces); + } + if (config.excludeWorkspaces) { + excludePattern = stringToRegexp(config.excludeWorkspaces); + } + + const filteredWorkspacePackages = filterWorkspacePackages( + workspacePackages, + includePattern, + excludePattern + ); + + if (filteredWorkspacePackages.length === 0) { + logger.warn('No publishable NuGet packages found after filtering'); + return []; + } + + // Map back to DotnetPackage for sorting + const filteredNames = new Set(filteredWorkspacePackages.map(p => p.name)); + const filteredPackages = result.packages.filter(p => + filteredNames.has(p.packageId) + ); + + // Sort packages topologically (dependencies first) + const sortedPackages = sortDotnetPackages(filteredPackages); + + logger.info( + `Discovered ${sortedPackages.length} publishable NuGet packages from ${result.solutionPath}` + ); + logger.debug( + `Expanding nuget workspace target to ${ + sortedPackages.length + } packages (dependency order): ${sortedPackages + .map(p => p.packageId) + .join(', ')}` + ); + + // Generate a target config for each package + return sortedPackages.map(pkg => { + // Generate the artifact pattern + let includeNames: string; + if (config.artifactTemplate) { + includeNames = packageIdToNugetArtifactFromTemplate( + pkg.packageId, + config.artifactTemplate + ); + } else { + includeNames = packageIdToNugetArtifactPattern(pkg.packageId); + } + + // Create the expanded target config + const expandedTarget: TargetConfig = { + name: 'nuget', + id: pkg.packageId, + includeNames, + }; + + // Copy over common target options + if (config.excludeNames) { + expandedTarget.excludeNames = config.excludeNames; + } + + // Copy over nuget-specific target options + if (config.serverUrl) { + expandedTarget.serverUrl = config.serverUrl; + } + + return expandedTarget; + }); + } + public constructor( - config: TargetConfig, + config: NugetTargetConfig, artifactProvider: BaseArtifactProvider ) { super(config, artifactProvider); @@ -78,12 +233,6 @@ export class NugetTarget extends BaseTarget { path, '--api-key', '${NUGET_API_TOKEN}', - // Warning: `--skip-duplicate` means we will NOT error when a version - // already exists. This is unlike any other target in Craft but - // became needed here as NuGet repo is quite flaky and we need to - // publish many packages at once without another way to resume a - // broken release. - '--skip-duplicate', '--source', this.nugetConfig.serverUrl, ]; @@ -124,33 +273,33 @@ export class NugetTarget extends BaseTarget { DOTNET_SPAWN_OPTIONS ); - await Promise.all( - packageFiles.map(async (file: RemoteArtifact) => { - const path = await this.artifactProvider.downloadArtifact(file); - - // If an artifact containing a .snupkg file exists with the same base - // name as the .nupkg file, then download it to the same location. - // It will be picked up automatically when pushing the .nupkg. - - // Note, this approach is required vs sending them separately, because - // we need to send the .nupkg *first*, and it must succeed before the - // .snupkg is sent. - - const symbolFileName = file.filename.replace('.nupkg', '.snupkg'); - const symbolFile = symbolFiles.find(f => f.filename === symbolFileName); - if (symbolFile) { - await this.artifactProvider.downloadArtifact(symbolFile); - } - - this.logger.info( - `Uploading file "${file.filename}" via "dotnet nuget"` + - (symbolFile - ? `, including symbol file "${symbolFile.filename}"` - : '') - ); - return this.uploadAsset(path); - }) - ); + // Publish packages sequentially to avoid reentrancy issues with NuGet.org + // When using workspace expansion, packages are already sorted in dependency order + await forEachChained(packageFiles, async (file: RemoteArtifact) => { + const path = await this.artifactProvider.downloadArtifact(file); + + // If an artifact containing a .snupkg file exists with the same base + // name as the .nupkg file, then download it to the same location. + // It will be picked up automatically when pushing the .nupkg. + + // Note, this approach is required vs sending them separately, because + // we need to send the .nupkg *first*, and it must succeed before the + // .snupkg is sent. + + const symbolFileName = file.filename.replace('.nupkg', '.snupkg'); + const symbolFile = symbolFiles.find(f => f.filename === symbolFileName); + if (symbolFile) { + await this.artifactProvider.downloadArtifact(symbolFile); + } + + this.logger.info( + `Uploading file "${file.filename}" via "dotnet nuget"` + + (symbolFile + ? `, including symbol file "${symbolFile.filename}"` + : '') + ); + await this.uploadAsset(path); + }); this.logger.info('Nuget release complete'); } diff --git a/src/utils/__fixtures__/dotnet-workspaces/complex-solution/Sentry.sln b/src/utils/__fixtures__/dotnet-workspaces/complex-solution/Sentry.sln new file mode 100644 index 00000000..b858c01e --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/complex-solution/Sentry.sln @@ -0,0 +1,19 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry", "src\Sentry\Sentry.csproj", "{11111111-1111-1111-1111-111111111111}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.Extensions.Logging", "src\Sentry.Extensions.Logging\Sentry.Extensions.Logging.csproj", "{22222222-2222-2222-2222-222222222222}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.AspNetCore", "src\Sentry.AspNetCore\Sentry.AspNetCore.csproj", "{33333333-3333-3333-3333-333333333333}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.Serilog", "src\Sentry.Serilog\Sentry.Serilog.csproj", "{44444444-4444-4444-4444-444444444444}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj b/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj new file mode 100644 index 00000000..c1ec7abe --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + Sentry.AspNetCore + true + + + + + + + diff --git a/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj b/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj new file mode 100644 index 00000000..1066ce1b --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + Sentry.Extensions.Logging + true + + + + + + + diff --git a/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.Serilog/Sentry.Serilog.csproj b/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.Serilog/Sentry.Serilog.csproj new file mode 100644 index 00000000..c8ab2743 --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry.Serilog/Sentry.Serilog.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + Sentry.Serilog + true + + + + + + + diff --git a/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry/Sentry.csproj b/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry/Sentry.csproj new file mode 100644 index 00000000..b0d8d8db --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/complex-solution/src/Sentry/Sentry.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + Sentry + true + + + diff --git a/src/utils/__fixtures__/dotnet-workspaces/no-solution/empty.txt b/src/utils/__fixtures__/dotnet-workspaces/no-solution/empty.txt new file mode 100644 index 00000000..d6519166 --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/no-solution/empty.txt @@ -0,0 +1 @@ +This directory has no solution file. diff --git a/src/utils/__fixtures__/dotnet-workspaces/simple-solution/Sentry.sln b/src/utils/__fixtures__/dotnet-workspaces/simple-solution/Sentry.sln new file mode 100644 index 00000000..7d57ac4a --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/simple-solution/Sentry.sln @@ -0,0 +1,15 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.Core", "src\Sentry.Core\Sentry.Core.csproj", "{12345678-1234-1234-1234-123456789ABC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.AspNetCore", "src\Sentry.AspNetCore\Sentry.AspNetCore.csproj", "{87654321-4321-4321-4321-CBA987654321}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/utils/__fixtures__/dotnet-workspaces/simple-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj b/src/utils/__fixtures__/dotnet-workspaces/simple-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj new file mode 100644 index 00000000..4bb85d7a --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/simple-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + Sentry.AspNetCore + true + + + + + + + diff --git a/src/utils/__fixtures__/dotnet-workspaces/simple-solution/src/Sentry.Core/Sentry.Core.csproj b/src/utils/__fixtures__/dotnet-workspaces/simple-solution/src/Sentry.Core/Sentry.Core.csproj new file mode 100644 index 00000000..b220bc53 --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/simple-solution/src/Sentry.Core/Sentry.Core.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + Sentry.Core + true + + + diff --git a/src/utils/__tests__/dotnetWorkspaces.test.ts b/src/utils/__tests__/dotnetWorkspaces.test.ts new file mode 100644 index 00000000..e2247d27 --- /dev/null +++ b/src/utils/__tests__/dotnetWorkspaces.test.ts @@ -0,0 +1,233 @@ +import { resolve } from 'path'; + +import { + discoverDotnetPackages, + parseSolutionFile, + parseCsprojFile, + sortDotnetPackages, + packageIdToNugetArtifactPattern, + packageIdToNugetArtifactFromTemplate, + DotnetPackage, +} from '../dotnetWorkspaces'; + +const fixturesDir = resolve(__dirname, '../__fixtures__/dotnet-workspaces'); + +describe('parseSolutionFile', () => { + test('parses solution file and extracts project paths', () => { + const solutionPath = resolve(fixturesDir, 'simple-solution/Sentry.sln'); + const projectPaths = parseSolutionFile(solutionPath); + + expect(projectPaths).toHaveLength(2); + + // Paths should be absolute and normalized + expect(projectPaths).toContain( + resolve(fixturesDir, 'simple-solution/src/Sentry.Core/Sentry.Core.csproj') + ); + expect(projectPaths).toContain( + resolve( + fixturesDir, + 'simple-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj' + ) + ); + }); + + test('returns empty array for non-existent solution file', () => { + const solutionPath = resolve(fixturesDir, 'does-not-exist.sln'); + const projectPaths = parseSolutionFile(solutionPath); + + expect(projectPaths).toHaveLength(0); + }); +}); + +describe('parseCsprojFile', () => { + test('parses csproj file and extracts package info', () => { + const projectPath = resolve( + fixturesDir, + 'simple-solution/src/Sentry.Core/Sentry.Core.csproj' + ); + const pkg = parseCsprojFile(projectPath); + + expect(pkg).not.toBeNull(); + expect(pkg?.packageId).toBe('Sentry.Core'); + expect(pkg?.isPackable).toBe(true); + expect(pkg?.projectDependencies).toHaveLength(0); + }); + + test('extracts project references from csproj', () => { + const projectPath = resolve( + fixturesDir, + 'simple-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj' + ); + const pkg = parseCsprojFile(projectPath); + + expect(pkg).not.toBeNull(); + expect(pkg?.packageId).toBe('Sentry.AspNetCore'); + expect(pkg?.projectDependencies).toContain('Sentry.Core'); + }); + + test('returns null for non-existent csproj file', () => { + const projectPath = resolve(fixturesDir, 'does-not-exist.csproj'); + const pkg = parseCsprojFile(projectPath); + + expect(pkg).toBeNull(); + }); +}); + +describe('discoverDotnetPackages', () => { + test('discovers packages from solution file', async () => { + const result = await discoverDotnetPackages( + resolve(fixturesDir, 'simple-solution') + ); + + expect(result).not.toBeNull(); + expect(result?.packages).toHaveLength(2); + + const packageIds = result?.packages.map(p => p.packageId).sort(); + expect(packageIds).toEqual(['Sentry.AspNetCore', 'Sentry.Core']); + }); + + test('discovers packages with explicit solution path', async () => { + const result = await discoverDotnetPackages( + fixturesDir, + 'simple-solution/Sentry.sln' + ); + + expect(result).not.toBeNull(); + expect(result?.packages).toHaveLength(2); + }); + + test('returns null when no solution file found', async () => { + const result = await discoverDotnetPackages( + resolve(fixturesDir, 'no-solution') + ); + + expect(result).toBeNull(); + }); + + test('returns null for non-existent directory', async () => { + const result = await discoverDotnetPackages( + resolve(fixturesDir, 'does-not-exist') + ); + + expect(result).toBeNull(); + }); + + test('filters dependencies to only include workspace packages', async () => { + const result = await discoverDotnetPackages( + resolve(fixturesDir, 'simple-solution') + ); + + const aspNetCore = result?.packages.find( + p => p.packageId === 'Sentry.AspNetCore' + ); + + // Sentry.AspNetCore depends on Sentry.Core (which is in the workspace) + expect(aspNetCore?.projectDependencies).toContain('Sentry.Core'); + }); +}); + +describe('sortDotnetPackages', () => { + test('sorts packages in dependency order', () => { + const packages: DotnetPackage[] = [ + { + packageId: 'Sentry.AspNetCore', + projectPath: '/path/AspNetCore.csproj', + isPackable: true, + projectDependencies: ['Sentry.Core'], + }, + { + packageId: 'Sentry.Core', + projectPath: '/path/Core.csproj', + isPackable: true, + projectDependencies: [], + }, + ]; + + const sorted = sortDotnetPackages(packages); + + expect(sorted.map(p => p.packageId)).toEqual([ + 'Sentry.Core', // no dependencies, comes first + 'Sentry.AspNetCore', // depends on Core + ]); + }); + + test('sorts complex dependency graph', async () => { + const result = await discoverDotnetPackages( + resolve(fixturesDir, 'complex-solution') + ); + + expect(result).not.toBeNull(); + + const sorted = sortDotnetPackages(result!.packages); + const packageIds = sorted.map(p => p.packageId); + + // Sentry has no dependencies, should come first + expect(packageIds[0]).toBe('Sentry'); + + // Extensions.Logging and Serilog depend on Sentry + const extLoggingIdx = packageIds.indexOf('Sentry.Extensions.Logging'); + const serilogIdx = packageIds.indexOf('Sentry.Serilog'); + expect(extLoggingIdx).toBeGreaterThan(0); + expect(serilogIdx).toBeGreaterThan(0); + + // AspNetCore depends on Extensions.Logging, should come after it + const aspNetCoreIdx = packageIds.indexOf('Sentry.AspNetCore'); + expect(aspNetCoreIdx).toBeGreaterThan(extLoggingIdx); + }); +}); + +describe('packageIdToNugetArtifactPattern', () => { + test('converts simple package ID to pattern', () => { + const pattern = packageIdToNugetArtifactPattern('Sentry'); + expect(pattern).toBe('/^Sentry\\.\\d.*\\.nupkg$/'); + }); + + test('escapes dots in package ID', () => { + const pattern = packageIdToNugetArtifactPattern('Sentry.AspNetCore'); + expect(pattern).toBe('/^Sentry\\.AspNetCore\\.\\d.*\\.nupkg$/'); + }); + + test('handles package ID with multiple dots', () => { + const pattern = packageIdToNugetArtifactPattern( + 'Sentry.Extensions.Logging' + ); + expect(pattern).toBe('/^Sentry\\.Extensions\\.Logging\\.\\d.*\\.nupkg$/'); + }); +}); + +describe('packageIdToNugetArtifactFromTemplate', () => { + test('replaces {{packageId}} placeholder', () => { + const pattern = packageIdToNugetArtifactFromTemplate( + 'Sentry.Core', + '{{packageId}}.nupkg' + ); + expect(pattern).toBe('/^Sentry\\.Core\\.nupkg$/'); + }); + + test('replaces {{version}} with regex pattern', () => { + const pattern = packageIdToNugetArtifactFromTemplate( + 'Sentry.Core', + '{{packageId}}.{{version}}.nupkg' + ); + expect(pattern).toBe('/^Sentry\\.Core\\.\\d.*\\.nupkg$/'); + }); + + test('replaces {{version}} with specific version', () => { + const pattern = packageIdToNugetArtifactFromTemplate( + 'Sentry.Core', + '{{packageId}}.{{version}}.nupkg', + '1.0.0' + ); + expect(pattern).toBe('/^Sentry\\.Core\\.1\\.0\\.0\\.nupkg$/'); + }); + + test('handles complex templates', () => { + const pattern = packageIdToNugetArtifactFromTemplate( + 'Sentry.Core', + 'packages/{{packageId}}/{{packageId}}.{{version}}.nupkg' + ); + expect(pattern).toBe( + '/^packages\\/Sentry\\.Core\\/Sentry\\.Core\\.\\d.*\\.nupkg$/' + ); + }); +}); diff --git a/src/utils/dotnetWorkspaces.ts b/src/utils/dotnetWorkspaces.ts new file mode 100644 index 00000000..98ab89bb --- /dev/null +++ b/src/utils/dotnetWorkspaces.ts @@ -0,0 +1,383 @@ +import { readFileSync } from 'fs'; +import * as path from 'path'; +import { glob } from 'glob'; +import { XMLParser } from 'fast-xml-parser'; + +import { logger } from '../logger'; +import { WorkspacePackage, topologicalSortPackages } from './workspaces'; + +/** + * Check if an error is a "file not found" error + */ +function isNotFoundError(err: unknown): boolean { + return err instanceof Error && 'code' in err && err.code === 'ENOENT'; +} + +/** Information about a .NET project that produces a NuGet package */ +export interface DotnetPackage { + /** The NuGet package ID (from PackageId or project name) */ + packageId: string; + /** Absolute path to the .csproj file */ + projectPath: string; + /** Whether the project is packable (produces a .nupkg) */ + isPackable: boolean; + /** Package IDs of other projects this project depends on */ + projectDependencies: string[]; +} + +/** Result of .NET workspace discovery */ +export interface DotnetDiscoveryResult { + /** List of discovered packages */ + packages: DotnetPackage[]; + /** Path to the solution file used */ + solutionPath: string; +} + +/** Parsed .csproj PropertyGroup structure */ +interface CsprojPropertyGroup { + PackageId?: string; + IsPackable?: boolean | string; +} + +/** Parsed .csproj ItemGroup structure */ +interface CsprojItemGroup { + ProjectReference?: + | Array<{ '@_Include': string }> + | { '@_Include': string }; +} + +/** Parsed .csproj structure */ +interface CsprojProject { + Project?: { + PropertyGroup?: CsprojPropertyGroup | CsprojPropertyGroup[]; + ItemGroup?: CsprojItemGroup | CsprojItemGroup[]; + }; +} + +/** + * Parse a .sln file and extract all .csproj project paths. + * Uses static regex parsing - no code execution. + * + * @param solutionPath Absolute path to the .sln file + * @returns Array of absolute paths to .csproj files + */ +export function parseSolutionFile(solutionPath: string): string[] { + let content: string; + try { + content = readFileSync(solutionPath, 'utf-8'); + } catch (err) { + if (isNotFoundError(err)) { + logger.warn(`Solution file not found: ${solutionPath}`); + return []; + } + throw err; + } + + const solutionDir = path.dirname(solutionPath); + const projectPaths: string[] = []; + + // Match project entries in .sln file + // Format: Project("{GUID}") = "ProjectName", "path\to\project.csproj", "{GUID}" + const projectRegex = + /Project\("\{[^}]+\}"\)\s*=\s*"[^"]*",\s*"([^"]+\.csproj)"/gi; + + let match; + while ((match = projectRegex.exec(content)) !== null) { + const relativePath = match[1]; + // Normalize path separators (Windows uses backslashes in .sln files) + const normalizedPath = relativePath.replace(/\\/g, '/'); + const absolutePath = path.resolve(solutionDir, normalizedPath); + projectPaths.push(absolutePath); + } + + return projectPaths; +} + +/** + * Parse a .csproj file and extract package information. + * Uses XML parsing - no code execution. + * + * @param projectPath Absolute path to the .csproj file + * @returns DotnetPackage info or null if not found/parseable + */ +export function parseCsprojFile(projectPath: string): DotnetPackage | null { + let content: string; + try { + content = readFileSync(projectPath, 'utf-8'); + } catch (err) { + if (isNotFoundError(err)) { + logger.warn(`Project file not found: ${projectPath}`); + return null; + } + throw err; + } + + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + }); + + let parsed: CsprojProject; + try { + parsed = parser.parse(content); + } catch (err) { + logger.warn(`Failed to parse csproj file ${projectPath}:`, err); + return null; + } + + const project = parsed.Project; + if (!project) { + logger.warn(`Invalid csproj structure in ${projectPath}: missing Project element`); + return null; + } + + // Extract PackageId and IsPackable from PropertyGroup(s) + let packageId: string | undefined; + let isPackable = true; // Default to true for SDK-style projects + + const propertyGroups = Array.isArray(project.PropertyGroup) + ? project.PropertyGroup + : project.PropertyGroup + ? [project.PropertyGroup] + : []; + + for (const pg of propertyGroups) { + if (pg.PackageId && !packageId) { + packageId = pg.PackageId; + } + if (pg.IsPackable !== undefined) { + // IsPackable can be boolean or string "true"/"false" + isPackable = + pg.IsPackable === true || + pg.IsPackable === 'true' || + pg.IsPackable === 'True'; + } + } + + // Use project filename (without extension) as fallback for PackageId + if (!packageId) { + packageId = path.basename(projectPath, '.csproj'); + } + + // Extract ProjectReference dependencies + const projectDependencies: string[] = []; + const itemGroups = Array.isArray(project.ItemGroup) + ? project.ItemGroup + : project.ItemGroup + ? [project.ItemGroup] + : []; + + for (const ig of itemGroups) { + if (!ig.ProjectReference) continue; + + const refs = Array.isArray(ig.ProjectReference) + ? ig.ProjectReference + : [ig.ProjectReference]; + + for (const ref of refs) { + const includePath = ref['@_Include']; + if (includePath) { + // Normalize and extract the project name from the reference path + const normalizedPath = includePath.replace(/\\/g, '/'); + const refProjectName = path.basename(normalizedPath, '.csproj'); + projectDependencies.push(refProjectName); + } + } + } + + return { + packageId, + projectPath, + isPackable, + projectDependencies, + }; +} + +/** + * Find a .sln file in the given directory + * + * @param rootDir Directory to search in + * @returns Path to the first .sln file found, or null + */ +export async function findSolutionFile( + rootDir: string +): Promise { + const matches = await glob('*.sln', { + cwd: rootDir, + absolute: true, + }); + + if (matches.length === 0) { + return null; + } + + if (matches.length > 1) { + logger.warn( + `Multiple solution files found in ${rootDir}, using first one: ${matches[0]}` + ); + } + + return matches[0]; +} + +/** + * Discover all NuGet packages in a .NET solution. + * Uses only static file parsing - no code execution. + * + * @param rootDir Root directory of the repository + * @param solutionPath Optional path to .sln file (relative to rootDir or absolute) + * @returns Discovery result with packages and solution path + */ +export async function discoverDotnetPackages( + rootDir: string, + solutionPath?: string +): Promise { + // Resolve solution path + let resolvedSolutionPath: string; + if (solutionPath) { + resolvedSolutionPath = path.isAbsolute(solutionPath) + ? solutionPath + : path.resolve(rootDir, solutionPath); + } else { + const found = await findSolutionFile(rootDir); + if (!found) { + logger.debug('No solution file found in root directory'); + return null; + } + resolvedSolutionPath = found; + } + + logger.debug(`Using solution file: ${resolvedSolutionPath}`); + + // Parse solution file to get project paths + const projectPaths = parseSolutionFile(resolvedSolutionPath); + if (projectPaths.length === 0) { + logger.warn('No projects found in solution file'); + return null; + } + + logger.debug(`Found ${projectPaths.length} projects in solution`); + + // Parse each project file + const packages: DotnetPackage[] = []; + const packageIdSet = new Set(); + + for (const projectPath of projectPaths) { + const pkg = parseCsprojFile(projectPath); + if (pkg && pkg.isPackable) { + packages.push(pkg); + packageIdSet.add(pkg.packageId); + } + } + + // Filter projectDependencies to only include packages in our set + // (convert project names to package IDs where possible) + for (const pkg of packages) { + pkg.projectDependencies = pkg.projectDependencies.filter(dep => + packageIdSet.has(dep) + ); + } + + logger.debug( + `Discovered ${packages.length} packable projects: ${packages.map(p => p.packageId).join(', ')}` + ); + + return { + packages, + solutionPath: resolvedSolutionPath, + }; +} + +/** + * Convert DotnetPackage array to WorkspacePackage array for use with + * the generic topologicalSortPackages function. + * + * @param packages Array of DotnetPackage + * @returns Array of WorkspacePackage + */ +export function dotnetPackagesToWorkspacePackages( + packages: DotnetPackage[] +): WorkspacePackage[] { + return packages.map(pkg => ({ + name: pkg.packageId, + location: path.dirname(pkg.projectPath), + private: !pkg.isPackable, + hasPublicAccess: true, // NuGet packages are public by default + workspaceDependencies: pkg.projectDependencies, + })); +} + +/** + * Sort .NET packages topologically based on their project dependencies. + * Packages with no dependencies come first. + * + * @param packages Array of DotnetPackage + * @returns Sorted array of DotnetPackage + */ +export function sortDotnetPackages(packages: DotnetPackage[]): DotnetPackage[] { + // Convert to WorkspacePackage, sort, then map back + const workspacePackages = dotnetPackagesToWorkspacePackages(packages); + const sorted = topologicalSortPackages(workspacePackages); + + // Create a map for O(1) lookup + const packageMap = new Map(packages.map(p => [p.packageId, p])); + + // Return packages in sorted order + return sorted + .map(wp => packageMap.get(wp.name)) + .filter((p): p is DotnetPackage => p !== undefined); +} + +/** + * Convert a NuGet package ID to an artifact filename pattern. + * + * @param packageId The NuGet package ID (e.g., "Sentry.AspNetCore") + * @returns A regex pattern string to match the artifact + */ +export function packageIdToNugetArtifactPattern(packageId: string): string { + // NuGet package artifacts are named: {PackageId}.{Version}.nupkg + // We need to escape dots in the package ID for the regex + const escaped = packageId.replace(/\./g, '\\.'); + return `/^${escaped}\\.\\d.*\\.nupkg$/`; +} + +/** + * Convert a NuGet package ID to an artifact filename using a template. + * + * Template variables: + * - {{packageId}}: The package ID (e.g., Sentry.AspNetCore) + * - {{version}}: The version string + * + * @param packageId The NuGet package ID + * @param template The artifact template string + * @param version Optional version to substitute (defaults to regex pattern) + * @returns The artifact filename pattern + */ +export function packageIdToNugetArtifactFromTemplate( + packageId: string, + template: string, + version = '\\d.*' +): string { + // Escape special regex characters + const escapeRegex = (str: string): string => + str.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + + const PACKAGE_ID_PLACEHOLDER = '\x00PKGID\x00'; + const VERSION_PLACEHOLDER = '\x00VERSION\x00'; + + // Replace template markers with placeholders + let result = template + .replace(/\{\{packageId\}\}/g, PACKAGE_ID_PLACEHOLDER) + .replace(/\{\{version\}\}/g, VERSION_PLACEHOLDER); + + // Escape regex special characters in the template + result = escapeRegex(result); + + // Replace placeholders with escaped values + const versionValue = version === '\\d.*' ? version : escapeRegex(version); + result = result + .replace(new RegExp(escapeRegex(PACKAGE_ID_PLACEHOLDER), 'g'), escapeRegex(packageId)) + .replace(new RegExp(escapeRegex(VERSION_PLACEHOLDER), 'g'), versionValue); + + return `/^${result}$/`; +} From df63f218159da02d847d0cb0d027870f2b23820b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 29 Dec 2025 21:49:26 +0300 Subject: [PATCH 2/7] fix(nuget): Escape all regex special chars in packageIdToNugetArtifactPattern Refactor escapeRegex helper to top-level function and use it in packageIdToNugetArtifactPattern to properly escape all regex meta-characters, not just dots. --- src/utils/dotnetWorkspaces.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/utils/dotnetWorkspaces.ts b/src/utils/dotnetWorkspaces.ts index 98ab89bb..965172c3 100644 --- a/src/utils/dotnetWorkspaces.ts +++ b/src/utils/dotnetWorkspaces.ts @@ -328,6 +328,14 @@ export function sortDotnetPackages(packages: DotnetPackage[]): DotnetPackage[] { .filter((p): p is DotnetPackage => p !== undefined); } +/** + * Escape special regex characters in a string. + * Only escapes characters that have special meaning in regex. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); +} + /** * Convert a NuGet package ID to an artifact filename pattern. * @@ -336,8 +344,8 @@ export function sortDotnetPackages(packages: DotnetPackage[]): DotnetPackage[] { */ export function packageIdToNugetArtifactPattern(packageId: string): string { // NuGet package artifacts are named: {PackageId}.{Version}.nupkg - // We need to escape dots in the package ID for the regex - const escaped = packageId.replace(/\./g, '\\.'); + // Escape all regex special characters in the package ID + const escaped = escapeRegex(packageId); return `/^${escaped}\\.\\d.*\\.nupkg$/`; } @@ -358,10 +366,6 @@ export function packageIdToNugetArtifactFromTemplate( template: string, version = '\\d.*' ): string { - // Escape special regex characters - const escapeRegex = (str: string): string => - str.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); - const PACKAGE_ID_PLACEHOLDER = '\x00PKGID\x00'; const VERSION_PLACEHOLDER = '\x00VERSION\x00'; From 99f9ad7e65c85f93fba35d9ba347256115eb220a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 29 Dec 2025 21:53:02 +0300 Subject: [PATCH 3/7] refactor: Extract escapeRegex to shared utility and fix npm artifact pattern - Export escapeRegex from workspaces.ts for reuse - Fix packageNameToArtifactPattern to escape all regex special chars - Use shared escapeRegex in dotnetWorkspaces.ts and powershell.ts --- src/targets/powershell.ts | 8 +++----- src/utils/dotnetWorkspaces.ts | 10 +--------- src/utils/workspaces.ts | 20 +++++++++++--------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/targets/powershell.ts b/src/targets/powershell.ts index 0b7b346a..93655c5a 100644 --- a/src/targets/powershell.ts +++ b/src/targets/powershell.ts @@ -10,6 +10,7 @@ import { extractZipArchive, spawnProcess, } from '../utils/system'; +import { escapeRegex } from '../utils/workspaces'; import { BaseTarget } from './base'; /** Command to launch PowerShell */ @@ -121,11 +122,8 @@ export class PowerShellTarget extends BaseTarget { `); // Escape the given module artifact name to avoid regex issues. - let moduleArtifactRegex = `${this.psConfig.module}`.replace( - /[/\-\\^$*+?.()|[\]{}]/g, - '\\$&' - ); - moduleArtifactRegex = `/^${moduleArtifactRegex}\\.zip$/`; + const escapedModule = escapeRegex(this.psConfig.module); + const moduleArtifactRegex = `/^${escapedModule}\\.zip$/`; this.logger.debug(`Looking for artifact matching ${moduleArtifactRegex}`); const packageFiles = await this.getArtifactsForRevision(revision, { diff --git a/src/utils/dotnetWorkspaces.ts b/src/utils/dotnetWorkspaces.ts index 965172c3..42b40686 100644 --- a/src/utils/dotnetWorkspaces.ts +++ b/src/utils/dotnetWorkspaces.ts @@ -4,7 +4,7 @@ import { glob } from 'glob'; import { XMLParser } from 'fast-xml-parser'; import { logger } from '../logger'; -import { WorkspacePackage, topologicalSortPackages } from './workspaces'; +import { WorkspacePackage, topologicalSortPackages, escapeRegex } from './workspaces'; /** * Check if an error is a "file not found" error @@ -328,14 +328,6 @@ export function sortDotnetPackages(packages: DotnetPackage[]): DotnetPackage[] { .filter((p): p is DotnetPackage => p !== undefined); } -/** - * Escape special regex characters in a string. - * Only escapes characters that have special meaning in regex. - */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); -} - /** * Convert a NuGet package ID to an artifact filename pattern. * diff --git a/src/utils/workspaces.ts b/src/utils/workspaces.ts index b4fa5bfa..80680a4b 100644 --- a/src/utils/workspaces.ts +++ b/src/utils/workspaces.ts @@ -251,6 +251,14 @@ export async function discoverWorkspaces( return { type: 'none', packages: [] }; } +/** + * Escape special regex characters in a string. + * Only escapes characters that have special meaning in regex. + */ +export function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); +} + /** * Convert a package name to an artifact filename pattern * @@ -264,16 +272,10 @@ export async function discoverWorkspaces( export function packageNameToArtifactPattern(packageName: string): string { // Remove @ prefix, replace / with - const normalized = packageName.replace(/^@/, '').replace(/\//g, '-'); + // Escape all regex special characters in the normalized name + const escaped = escapeRegex(normalized); // Create a regex pattern that matches the artifact filename - return `/^${normalized}-\\d.*\\.tgz$/`; -} - -/** - * Escape special regex characters in a string. - * Only escapes characters that have special meaning in regex. - */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + return `/^${escaped}-\\d.*\\.tgz$/`; } /** From 181d8ae25f43e905080e2e491e46288efa6b1c06 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 30 Dec 2025 00:03:52 +0300 Subject: [PATCH 4/7] feat(nuget): Use p-limit for concurrent publishing with limit of 3 Instead of fully sequential publishing, use p-limit to allow up to 3 concurrent package uploads. This provides a good balance between speed and avoiding overwhelming NuGet.org. --- package.json | 13 ++++----- src/targets/nuget.ts | 64 +++++++++++++++++++++++++------------------- yarn.lock | 12 +++++++++ 3 files changed, 55 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 18c62e4a..69106309 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@aws-sdk/client-lambda": "^3.723.0", + "@eslint/js": "^9.17.0", "@google-cloud/storage": "^7.14.0", "@octokit/plugin-retry": "^7.1.2", "@octokit/request-error": "^7.0.0", @@ -35,9 +36,6 @@ "@types/tar": "^4.0.0", "@types/tmp": "^0.0.33", "@types/yargs": "^17", - "@eslint/js": "^9.17.0", - "typescript-eslint": "^8.18.2", - "zod": "^3.24.1", "async": "3.2.2", "aws4": "^1.11.0", "chalk": "4.1.1", @@ -52,7 +50,6 @@ "git-url-parse": "^16.1.0", "glob": "^11.0.0", "is-ci": "^2.0.0", - "vitest": "^3.0.2", "js-yaml": "4.1.1", "mkdirp": "^1.0.4", "mustache": "3.0.1", @@ -71,7 +68,10 @@ "tar": "6.2.1", "tmp": "0.2.4", "typescript": "^5.7.2", - "yargs": "^18" + "typescript-eslint": "^8.18.2", + "vitest": "^3.0.2", + "yargs": "^18", + "zod": "^3.24.1" }, "scripts": { "build:fat": "tsc -p tsconfig.build.json", @@ -92,6 +92,7 @@ "yarn": "1.22.19" }, "dependencies": { - "marked": "^17.0.1" + "marked": "^17.0.1", + "p-limit": "^7.2.0" } } diff --git a/src/targets/nuget.ts b/src/targets/nuget.ts index 8c0d386c..97cc9c38 100644 --- a/src/targets/nuget.ts +++ b/src/targets/nuget.ts @@ -1,5 +1,6 @@ +import pLimit from 'p-limit'; + import { TargetConfig } from '../schemas/project_config'; -import { forEachChained } from '../utils/async'; import { ConfigurationError, reportError } from '../utils/errors'; import { stringToRegexp } from '../utils/filters'; import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; @@ -273,33 +274,40 @@ export class NugetTarget extends BaseTarget { DOTNET_SPAWN_OPTIONS ); - // Publish packages sequentially to avoid reentrancy issues with NuGet.org - // When using workspace expansion, packages are already sorted in dependency order - await forEachChained(packageFiles, async (file: RemoteArtifact) => { - const path = await this.artifactProvider.downloadArtifact(file); - - // If an artifact containing a .snupkg file exists with the same base - // name as the .nupkg file, then download it to the same location. - // It will be picked up automatically when pushing the .nupkg. - - // Note, this approach is required vs sending them separately, because - // we need to send the .nupkg *first*, and it must succeed before the - // .snupkg is sent. - - const symbolFileName = file.filename.replace('.nupkg', '.snupkg'); - const symbolFile = symbolFiles.find(f => f.filename === symbolFileName); - if (symbolFile) { - await this.artifactProvider.downloadArtifact(symbolFile); - } - - this.logger.info( - `Uploading file "${file.filename}" via "dotnet nuget"` + - (symbolFile - ? `, including symbol file "${symbolFile.filename}"` - : '') - ); - await this.uploadAsset(path); - }); + // Publish packages with limited concurrency to avoid overwhelming NuGet.org + // while still being faster than fully sequential publishing. + // When using workspace expansion, packages are already sorted in dependency order. + const limit = pLimit(3); + + await Promise.all( + packageFiles.map((file: RemoteArtifact) => + limit(async () => { + const path = await this.artifactProvider.downloadArtifact(file); + + // If an artifact containing a .snupkg file exists with the same base + // name as the .nupkg file, then download it to the same location. + // It will be picked up automatically when pushing the .nupkg. + + // Note, this approach is required vs sending them separately, because + // we need to send the .nupkg *first*, and it must succeed before the + // .snupkg is sent. + + const symbolFileName = file.filename.replace('.nupkg', '.snupkg'); + const symbolFile = symbolFiles.find(f => f.filename === symbolFileName); + if (symbolFile) { + await this.artifactProvider.downloadArtifact(symbolFile); + } + + this.logger.info( + `Uploading file "${file.filename}" via "dotnet nuget"` + + (symbolFile + ? `, including symbol file "${symbolFile.filename}"` + : '') + ); + await this.uploadAsset(path); + }) + ) + ); this.logger.info('Nuget release complete'); } diff --git a/yarn.lock b/yarn.lock index 71a11346..2235ea34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3734,6 +3734,13 @@ p-limit@^3.0.1, p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" +p-limit@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-7.2.0.tgz#afcf6b5a86d093660140497dda0e640dd01a7b3b" + integrity sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ== + dependencies: + yocto-queue "^1.2.1" + p-locate@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" @@ -4551,6 +4558,11 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +yocto-queue@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00" + integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ== + zod@^3.24.1: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" From 38f2772a03bd42dcd0a2444ae0cf57846f07c5d3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 30 Dec 2025 00:06:15 +0300 Subject: [PATCH 5/7] chore: remove redundant comments from nuget target --- src/targets/nuget.ts | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/targets/nuget.ts b/src/targets/nuget.ts index 97cc9c38..a72a3de6 100644 --- a/src/targets/nuget.ts +++ b/src/targets/nuget.ts @@ -80,24 +80,16 @@ export interface NugetTargetOptions { * Target responsible for publishing releases on Nuget */ export class NugetTarget extends BaseTarget { - /** Target name */ public readonly name: string = 'nuget'; - /** Target options */ public readonly nugetConfig: NugetTargetOptions; /** * Expand a nuget target config into multiple targets if workspaces is enabled. - * This static method is called during config loading to expand workspace targets. - * - * @param config The nuget target config - * @param rootDir The root directory of the project - * @returns Array of expanded target configs, or the original config in an array */ public static async expand( config: NugetTargetConfig, rootDir: string ): Promise { - // If workspaces is not enabled, return the config as-is if (!config.workspaces) { return [config]; } @@ -111,7 +103,6 @@ export class NugetTarget extends BaseTarget { return []; } - // Convert to workspace packages for filtering const workspacePackages = result.packages.map(pkg => ({ name: pkg.packageId, location: pkg.projectPath, @@ -120,7 +111,6 @@ export class NugetTarget extends BaseTarget { workspaceDependencies: pkg.projectDependencies, })); - // Filter packages based on include/exclude patterns let includePattern: RegExp | undefined; let excludePattern: RegExp | undefined; @@ -142,13 +132,11 @@ export class NugetTarget extends BaseTarget { return []; } - // Map back to DotnetPackage for sorting const filteredNames = new Set(filteredWorkspacePackages.map(p => p.name)); const filteredPackages = result.packages.filter(p => filteredNames.has(p.packageId) ); - // Sort packages topologically (dependencies first) const sortedPackages = sortDotnetPackages(filteredPackages); logger.info( @@ -162,9 +150,7 @@ export class NugetTarget extends BaseTarget { .join(', ')}` ); - // Generate a target config for each package return sortedPackages.map(pkg => { - // Generate the artifact pattern let includeNames: string; if (config.artifactTemplate) { includeNames = packageIdToNugetArtifactFromTemplate( @@ -175,19 +161,15 @@ export class NugetTarget extends BaseTarget { includeNames = packageIdToNugetArtifactPattern(pkg.packageId); } - // Create the expanded target config const expandedTarget: TargetConfig = { name: 'nuget', id: pkg.packageId, includeNames, }; - // Copy over common target options if (config.excludeNames) { expandedTarget.excludeNames = config.excludeNames; } - - // Copy over nuget-specific target options if (config.serverUrl) { expandedTarget.serverUrl = config.serverUrl; } @@ -223,9 +205,6 @@ export class NugetTarget extends BaseTarget { /** * Uploads an archive to Nuget using "dotnet nuget" - * - * @param path Absolute path to the archive to upload - * @returns A promise that resolves when the upload has completed */ public async uploadAsset(path: string): Promise { const args = [ @@ -242,9 +221,6 @@ export class NugetTarget extends BaseTarget { /** * Publishes a package tarball to the Nuget registry - * - * @param version New version to be released - * @param revision Git commit SHA to be published */ public async publish(_version: string, revision: string): Promise { this.logger.debug('Fetching artifact list...'); @@ -261,12 +237,10 @@ export class NugetTarget extends BaseTarget { ); } - // Emit the .NET version for informational purposes. this.logger.info('.NET Version:'); await spawnProcess(NUGET_DOTNET_BIN, ['--version'], DOTNET_SPAWN_OPTIONS); - // Also emit the nuget version, which is informative and works around a bug. - // See https://github.com/NuGet/Home/issues/12159#issuecomment-1278360511 + // Works around a bug: https://github.com/NuGet/Home/issues/12159#issuecomment-1278360511 this.logger.info('Nuget Version:'); await spawnProcess( NUGET_DOTNET_BIN, From 7bc2461f3ec9571cabab8a06f16062958e3b59b4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 30 Dec 2025 00:10:21 +0300 Subject: [PATCH 6/7] chore: consolidate tests and fix fixtures --- src/targets/__tests__/nuget.test.ts | 27 +----- .../dotnet-workspaces/no-solution/.gitkeep | 0 .../dotnet-workspaces/no-solution/empty.txt | 1 - src/utils/__tests__/dotnetWorkspaces.test.ts | 84 +++++++++---------- 4 files changed, 42 insertions(+), 70 deletions(-) create mode 100644 src/utils/__fixtures__/dotnet-workspaces/no-solution/.gitkeep delete mode 100644 src/utils/__fixtures__/dotnet-workspaces/no-solution/empty.txt diff --git a/src/targets/__tests__/nuget.test.ts b/src/targets/__tests__/nuget.test.ts index 15794b42..afccb7b6 100644 --- a/src/targets/__tests__/nuget.test.ts +++ b/src/targets/__tests__/nuget.test.ts @@ -51,32 +51,13 @@ describe('NugetTarget.expand', () => { const config = { name: 'nuget', workspaces: true }; const result = await NugetTarget.expand(config, '/root'); - // Should return targets in dependency order (Core before AspNetCore) expect(result).toHaveLength(2); expect(result[0].id).toBe('Sentry.Core'); - expect(result[1].id).toBe('Sentry.AspNetCore'); - }); - - it('generates correct includeNames pattern for each package', async () => { - discoverDotnetPackagesMock = vi - .spyOn(dotnetWorkspaces, 'discoverDotnetPackages') - .mockResolvedValue({ - solutionPath: '/root/Sentry.sln', - packages: [ - { - packageId: 'Sentry.Core', - projectPath: '/root/src/Sentry.Core/Sentry.Core.csproj', - isPackable: true, - projectDependencies: [], - }, - ], - }); - - const config = { name: 'nuget', workspaces: true }; - const result = await NugetTarget.expand(config, '/root'); - - expect(result).toHaveLength(1); expect(result[0].includeNames).toBe('/^Sentry\\.Core\\.\\d.*\\.nupkg$/'); + expect(result[1].id).toBe('Sentry.AspNetCore'); + expect(result[1].includeNames).toBe( + '/^Sentry\\.AspNetCore\\.\\d.*\\.nupkg$/' + ); }); it('filters packages by includeWorkspaces pattern', async () => { diff --git a/src/utils/__fixtures__/dotnet-workspaces/no-solution/.gitkeep b/src/utils/__fixtures__/dotnet-workspaces/no-solution/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/utils/__fixtures__/dotnet-workspaces/no-solution/empty.txt b/src/utils/__fixtures__/dotnet-workspaces/no-solution/empty.txt deleted file mode 100644 index d6519166..00000000 --- a/src/utils/__fixtures__/dotnet-workspaces/no-solution/empty.txt +++ /dev/null @@ -1 +0,0 @@ -This directory has no solution file. diff --git a/src/utils/__tests__/dotnetWorkspaces.test.ts b/src/utils/__tests__/dotnetWorkspaces.test.ts index e2247d27..bb6fd6dc 100644 --- a/src/utils/__tests__/dotnetWorkspaces.test.ts +++ b/src/utils/__tests__/dotnetWorkspaces.test.ts @@ -177,57 +177,49 @@ describe('sortDotnetPackages', () => { }); describe('packageIdToNugetArtifactPattern', () => { - test('converts simple package ID to pattern', () => { - const pattern = packageIdToNugetArtifactPattern('Sentry'); - expect(pattern).toBe('/^Sentry\\.\\d.*\\.nupkg$/'); - }); - - test('escapes dots in package ID', () => { - const pattern = packageIdToNugetArtifactPattern('Sentry.AspNetCore'); - expect(pattern).toBe('/^Sentry\\.AspNetCore\\.\\d.*\\.nupkg$/'); - }); - - test('handles package ID with multiple dots', () => { - const pattern = packageIdToNugetArtifactPattern( - 'Sentry.Extensions.Logging' + test('converts package ID to pattern and escapes dots', () => { + expect(packageIdToNugetArtifactPattern('Sentry')).toBe( + '/^Sentry\\.\\d.*\\.nupkg$/' ); - expect(pattern).toBe('/^Sentry\\.Extensions\\.Logging\\.\\d.*\\.nupkg$/'); - }); -}); - -describe('packageIdToNugetArtifactFromTemplate', () => { - test('replaces {{packageId}} placeholder', () => { - const pattern = packageIdToNugetArtifactFromTemplate( - 'Sentry.Core', - '{{packageId}}.nupkg' + expect(packageIdToNugetArtifactPattern('Sentry.AspNetCore')).toBe( + '/^Sentry\\.AspNetCore\\.\\d.*\\.nupkg$/' ); - expect(pattern).toBe('/^Sentry\\.Core\\.nupkg$/'); - }); - - test('replaces {{version}} with regex pattern', () => { - const pattern = packageIdToNugetArtifactFromTemplate( - 'Sentry.Core', - '{{packageId}}.{{version}}.nupkg' + expect(packageIdToNugetArtifactPattern('Sentry.Extensions.Logging')).toBe( + '/^Sentry\\.Extensions\\.Logging\\.\\d.*\\.nupkg$/' ); - expect(pattern).toBe('/^Sentry\\.Core\\.\\d.*\\.nupkg$/'); }); +}); - test('replaces {{version}} with specific version', () => { - const pattern = packageIdToNugetArtifactFromTemplate( - 'Sentry.Core', - '{{packageId}}.{{version}}.nupkg', - '1.0.0' - ); - expect(pattern).toBe('/^Sentry\\.Core\\.1\\.0\\.0\\.nupkg$/'); - }); +describe('packageIdToNugetArtifactFromTemplate', () => { + test('replaces placeholders and escapes special characters', () => { + // Basic packageId replacement + expect( + packageIdToNugetArtifactFromTemplate('Sentry.Core', '{{packageId}}.nupkg') + ).toBe('/^Sentry\\.Core\\.nupkg$/'); + + // Version with regex pattern + expect( + packageIdToNugetArtifactFromTemplate( + 'Sentry.Core', + '{{packageId}}.{{version}}.nupkg' + ) + ).toBe('/^Sentry\\.Core\\.\\d.*\\.nupkg$/'); + + // Version with specific value + expect( + packageIdToNugetArtifactFromTemplate( + 'Sentry.Core', + '{{packageId}}.{{version}}.nupkg', + '1.0.0' + ) + ).toBe('/^Sentry\\.Core\\.1\\.0\\.0\\.nupkg$/'); - test('handles complex templates', () => { - const pattern = packageIdToNugetArtifactFromTemplate( - 'Sentry.Core', - 'packages/{{packageId}}/{{packageId}}.{{version}}.nupkg' - ); - expect(pattern).toBe( - '/^packages\\/Sentry\\.Core\\/Sentry\\.Core\\.\\d.*\\.nupkg$/' - ); + // Complex template with paths + expect( + packageIdToNugetArtifactFromTemplate( + 'Sentry.Core', + 'packages/{{packageId}}/{{packageId}}.{{version}}.nupkg' + ) + ).toBe('/^packages\\/Sentry\\.Core\\/Sentry\\.Core\\.\\d.*\\.nupkg$/'); }); }); From f0be729794cf80990061bb7ed5351271cb825703 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 30 Dec 2025 21:53:08 +0300 Subject: [PATCH 7/7] feat(nuget): add .snupkg and .slnx support, add escapeRegex tests - Artifact patterns now match both .nupkg and .snupkg (symbol packages) - Add .slnx (XML-based solution) file format support - Add explicit tests for escapeRegex backslash handling - Prefer .slnx over .sln when both exist --- src/targets/__tests__/nuget.test.ts | 4 +- .../slnx-solution/Sentry.slnx | 4 + .../Sentry.AspNetCore.csproj | 13 ++ .../src/Sentry.Core/Sentry.Core.csproj | 9 ++ src/utils/__tests__/dotnetWorkspaces.test.ts | 46 ++++++- src/utils/__tests__/workspaces.test.ts | 28 +++++ src/utils/dotnetWorkspaces.ts | 113 ++++++++++++++++-- 7 files changed, 197 insertions(+), 20 deletions(-) create mode 100644 src/utils/__fixtures__/dotnet-workspaces/slnx-solution/Sentry.slnx create mode 100644 src/utils/__fixtures__/dotnet-workspaces/slnx-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj create mode 100644 src/utils/__fixtures__/dotnet-workspaces/slnx-solution/src/Sentry.Core/Sentry.Core.csproj diff --git a/src/targets/__tests__/nuget.test.ts b/src/targets/__tests__/nuget.test.ts index afccb7b6..3027fb1a 100644 --- a/src/targets/__tests__/nuget.test.ts +++ b/src/targets/__tests__/nuget.test.ts @@ -53,10 +53,10 @@ describe('NugetTarget.expand', () => { expect(result).toHaveLength(2); expect(result[0].id).toBe('Sentry.Core'); - expect(result[0].includeNames).toBe('/^Sentry\\.Core\\.\\d.*\\.nupkg$/'); + expect(result[0].includeNames).toBe('/^Sentry\\.Core\\.\\d.*\\.s?nupkg$/'); expect(result[1].id).toBe('Sentry.AspNetCore'); expect(result[1].includeNames).toBe( - '/^Sentry\\.AspNetCore\\.\\d.*\\.nupkg$/' + '/^Sentry\\.AspNetCore\\.\\d.*\\.s?nupkg$/' ); }); diff --git a/src/utils/__fixtures__/dotnet-workspaces/slnx-solution/Sentry.slnx b/src/utils/__fixtures__/dotnet-workspaces/slnx-solution/Sentry.slnx new file mode 100644 index 00000000..971556ad --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/slnx-solution/Sentry.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/utils/__fixtures__/dotnet-workspaces/slnx-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj b/src/utils/__fixtures__/dotnet-workspaces/slnx-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj new file mode 100644 index 00000000..4bb85d7a --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/slnx-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + Sentry.AspNetCore + true + + + + + + + diff --git a/src/utils/__fixtures__/dotnet-workspaces/slnx-solution/src/Sentry.Core/Sentry.Core.csproj b/src/utils/__fixtures__/dotnet-workspaces/slnx-solution/src/Sentry.Core/Sentry.Core.csproj new file mode 100644 index 00000000..b220bc53 --- /dev/null +++ b/src/utils/__fixtures__/dotnet-workspaces/slnx-solution/src/Sentry.Core/Sentry.Core.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + Sentry.Core + true + + + diff --git a/src/utils/__tests__/dotnetWorkspaces.test.ts b/src/utils/__tests__/dotnetWorkspaces.test.ts index bb6fd6dc..58e07bf7 100644 --- a/src/utils/__tests__/dotnetWorkspaces.test.ts +++ b/src/utils/__tests__/dotnetWorkspaces.test.ts @@ -3,6 +3,7 @@ import { resolve } from 'path'; import { discoverDotnetPackages, parseSolutionFile, + parseSlnxFile, parseCsprojFile, sortDotnetPackages, packageIdToNugetArtifactPattern, @@ -39,6 +40,31 @@ describe('parseSolutionFile', () => { }); }); +describe('parseSlnxFile', () => { + test('parses slnx file and extracts project paths', () => { + const solutionPath = resolve(fixturesDir, 'slnx-solution/Sentry.slnx'); + const projectPaths = parseSlnxFile(solutionPath); + + expect(projectPaths).toHaveLength(2); + expect(projectPaths).toContain( + resolve(fixturesDir, 'slnx-solution/src/Sentry.Core/Sentry.Core.csproj') + ); + expect(projectPaths).toContain( + resolve( + fixturesDir, + 'slnx-solution/src/Sentry.AspNetCore/Sentry.AspNetCore.csproj' + ) + ); + }); + + test('returns empty array for non-existent slnx file', () => { + const solutionPath = resolve(fixturesDir, 'does-not-exist.slnx'); + const projectPaths = parseSlnxFile(solutionPath); + + expect(projectPaths).toHaveLength(0); + }); +}); + describe('parseCsprojFile', () => { test('parses csproj file and extracts package info', () => { const projectPath = resolve( @@ -177,15 +203,23 @@ describe('sortDotnetPackages', () => { }); describe('packageIdToNugetArtifactPattern', () => { - test('converts package ID to pattern and escapes dots', () => { + test('converts package ID to pattern matching both .nupkg and .snupkg', () => { + const pattern = packageIdToNugetArtifactPattern('Sentry.AspNetCore'); + expect(pattern).toBe('/^Sentry\\.AspNetCore\\.\\d.*\\.s?nupkg$/'); + + // Verify the pattern matches both .nupkg and .snupkg + const regex = new RegExp(pattern.slice(1, -1)); + expect(regex.test('Sentry.AspNetCore.1.0.0.nupkg')).toBe(true); + expect(regex.test('Sentry.AspNetCore.1.0.0.snupkg')).toBe(true); + expect(regex.test('Other.Package.1.0.0.nupkg')).toBe(false); + }); + + test('escapes dots in package ID', () => { expect(packageIdToNugetArtifactPattern('Sentry')).toBe( - '/^Sentry\\.\\d.*\\.nupkg$/' - ); - expect(packageIdToNugetArtifactPattern('Sentry.AspNetCore')).toBe( - '/^Sentry\\.AspNetCore\\.\\d.*\\.nupkg$/' + '/^Sentry\\.\\d.*\\.s?nupkg$/' ); expect(packageIdToNugetArtifactPattern('Sentry.Extensions.Logging')).toBe( - '/^Sentry\\.Extensions\\.Logging\\.\\d.*\\.nupkg$/' + '/^Sentry\\.Extensions\\.Logging\\.\\d.*\\.s?nupkg$/' ); }); }); diff --git a/src/utils/__tests__/workspaces.test.ts b/src/utils/__tests__/workspaces.test.ts index e6dfd9a5..be2fe3a1 100644 --- a/src/utils/__tests__/workspaces.test.ts +++ b/src/utils/__tests__/workspaces.test.ts @@ -3,6 +3,7 @@ import { resolve } from 'path'; import { discoverWorkspaces, + escapeRegex, filterWorkspacePackages, packageNameToArtifactPattern, packageNameToArtifactFromTemplate, @@ -264,3 +265,30 @@ describe('topologicalSortPackages', () => { ]); }); }); + +describe('escapeRegex', () => { + test('escapes all regex special characters including backslash', () => { + // All special regex chars: . * + ? ^ $ { } ( ) | [ ] \ / + expect(escapeRegex('.')).toBe('\\.'); + expect(escapeRegex('*')).toBe('\\*'); + expect(escapeRegex('+')).toBe('\\+'); + expect(escapeRegex('?')).toBe('\\?'); + expect(escapeRegex('^')).toBe('\\^'); + expect(escapeRegex('$')).toBe('\\$'); + expect(escapeRegex('{')).toBe('\\{'); + expect(escapeRegex('}')).toBe('\\}'); + expect(escapeRegex('(')).toBe('\\('); + expect(escapeRegex(')')).toBe('\\)'); + expect(escapeRegex('|')).toBe('\\|'); + expect(escapeRegex('[')).toBe('\\['); + expect(escapeRegex(']')).toBe('\\]'); + expect(escapeRegex('\\')).toBe('\\\\'); + expect(escapeRegex('/')).toBe('\\/'); + }); + + test('handles complex strings with multiple special chars', () => { + expect(escapeRegex('file.name(v1).txt')).toBe('file\\.name\\(v1\\)\\.txt'); + expect(escapeRegex('path\\to\\file')).toBe('path\\\\to\\\\file'); + expect(escapeRegex('a+b*c?')).toBe('a\\+b\\*c\\?'); + }); +}); diff --git a/src/utils/dotnetWorkspaces.ts b/src/utils/dotnetWorkspaces.ts index 42b40686..0aad99bb 100644 --- a/src/utils/dotnetWorkspaces.ts +++ b/src/utils/dotnetWorkspaces.ts @@ -54,6 +54,13 @@ interface CsprojProject { }; } +/** Parsed .slnx structure */ +interface SlnxSolution { + Solution?: { + Project?: Array<{ '@_Path': string }> | { '@_Path': string }; + }; +} + /** * Parse a .sln file and extract all .csproj project paths. * Uses static regex parsing - no code execution. @@ -93,6 +100,67 @@ export function parseSolutionFile(solutionPath: string): string[] { return projectPaths; } +/** + * Parse a .slnx (XML-based solution) file and extract all .csproj project paths. + * Uses XML parsing - no code execution. + * + * @param solutionPath Absolute path to the .slnx file + * @returns Array of absolute paths to .csproj files + */ +export function parseSlnxFile(solutionPath: string): string[] { + let content: string; + try { + content = readFileSync(solutionPath, 'utf-8'); + } catch (err) { + if (isNotFoundError(err)) { + logger.warn(`Solution file not found: ${solutionPath}`); + return []; + } + throw err; + } + + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + }); + + let parsed: SlnxSolution; + try { + parsed = parser.parse(content); + } catch (err) { + logger.warn(`Failed to parse slnx file ${solutionPath}:`, err); + return []; + } + + const solution = parsed.Solution; + if (!solution) { + logger.warn( + `Invalid slnx structure in ${solutionPath}: missing Solution element` + ); + return []; + } + + const solutionDir = path.dirname(solutionPath); + const projectPaths: string[] = []; + + const projects = solution.Project + ? Array.isArray(solution.Project) + ? solution.Project + : [solution.Project] + : []; + + for (const project of projects) { + const projectPath = project['@_Path']; + if (projectPath && projectPath.endsWith('.csproj')) { + const normalizedPath = projectPath.replace(/\\/g, '/'); + const absolutePath = path.resolve(solutionDir, normalizedPath); + projectPaths.push(absolutePath); + } + } + + return projectPaths; +} + /** * Parse a .csproj file and extract package information. * Uses XML parsing - no code execution. @@ -194,38 +262,56 @@ export function parseCsprojFile(projectPath: string): DotnetPackage | null { } /** - * Find a .sln file in the given directory + * Find a solution file (.sln or .slnx) in the given directory. + * Prefers .slnx over .sln if both exist. * * @param rootDir Directory to search in - * @returns Path to the first .sln file found, or null + * @returns Path to the solution file found, or null */ export async function findSolutionFile( rootDir: string ): Promise { - const matches = await glob('*.sln', { + // Check for .slnx first (newer XML format) + const slnxMatches = await glob('*.slnx', { + cwd: rootDir, + absolute: true, + }); + + if (slnxMatches.length > 0) { + if (slnxMatches.length > 1) { + logger.warn( + `Multiple .slnx files found in ${rootDir}, using first one: ${slnxMatches[0]}` + ); + } + return slnxMatches[0]; + } + + // Fall back to .sln + const slnMatches = await glob('*.sln', { cwd: rootDir, absolute: true, }); - if (matches.length === 0) { + if (slnMatches.length === 0) { return null; } - if (matches.length > 1) { + if (slnMatches.length > 1) { logger.warn( - `Multiple solution files found in ${rootDir}, using first one: ${matches[0]}` + `Multiple .sln files found in ${rootDir}, using first one: ${slnMatches[0]}` ); } - return matches[0]; + return slnMatches[0]; } /** * Discover all NuGet packages in a .NET solution. + * Supports both .sln and .slnx solution formats. * Uses only static file parsing - no code execution. * * @param rootDir Root directory of the repository - * @param solutionPath Optional path to .sln file (relative to rootDir or absolute) + * @param solutionPath Optional path to solution file (relative to rootDir or absolute) * @returns Discovery result with packages and solution path */ export async function discoverDotnetPackages( @@ -249,8 +335,10 @@ export async function discoverDotnetPackages( logger.debug(`Using solution file: ${resolvedSolutionPath}`); - // Parse solution file to get project paths - const projectPaths = parseSolutionFile(resolvedSolutionPath); + // Parse solution file to get project paths (use appropriate parser based on extension) + const projectPaths = resolvedSolutionPath.endsWith('.slnx') + ? parseSlnxFile(resolvedSolutionPath) + : parseSolutionFile(resolvedSolutionPath); if (projectPaths.length === 0) { logger.warn('No projects found in solution file'); return null; @@ -330,15 +418,16 @@ export function sortDotnetPackages(packages: DotnetPackage[]): DotnetPackage[] { /** * Convert a NuGet package ID to an artifact filename pattern. + * Matches both .nupkg (package) and .snupkg (symbols) files. * * @param packageId The NuGet package ID (e.g., "Sentry.AspNetCore") * @returns A regex pattern string to match the artifact */ export function packageIdToNugetArtifactPattern(packageId: string): string { // NuGet package artifacts are named: {PackageId}.{Version}.nupkg - // Escape all regex special characters in the package ID + // Symbol packages are named: {PackageId}.{Version}.snupkg const escaped = escapeRegex(packageId); - return `/^${escaped}\\.\\d.*\\.nupkg$/`; + return `/^${escaped}\\.\\d.*\\.s?nupkg$/`; } /**