Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 63 additions & 6 deletions docs/src/content/docs/targets/nuget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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'
```
13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -92,6 +92,7 @@
"yarn": "1.22.19"
},
"dependencies": {
"marked": "^17.0.1"
"marked": "^17.0.1",
"p-limit": "^7.2.0"
}
}
256 changes: 256 additions & 0 deletions src/targets/__tests__/nuget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
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');

expect(result).toHaveLength(2);
expect(result[0].id).toBe('Sentry.Core');
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.*\\.s?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'
);
});
});
Loading
Loading