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
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,37 @@ nx build my-app --configuration release
nx build my-app --configuration debug
```

## Multi-targeted projects

A project that declares `<TargetFrameworks>` (plural) builds more than one target framework. An unqualified `nx build` builds them all, but a single host often cannot — an iOS + Windows project has no machine that can build every framework at once. The `frameworkVariants` option lets you opt in to per-target-framework target variants so you can build, cache, and route one framework in isolation.

```json {% meta="{6}" %}
// nx.json
{
"plugins": [
{
"plugin": "@nx/dotnet",
"options": {
"frameworkVariants": true
}
}
]
}
```

With the option enabled, a project declaring `<TargetFrameworks>net10.0;net10.0-ios</TargetFrameworks>` gets these additional build targets alongside the unqualified ones:

- `build-net10.0`, `build-net10.0-ios` - build a single framework (Debug by default)
- `build-net10.0-release`, `build-net10.0-ios-release` - build a single framework in Release

Each variant passes `--framework` to the .NET CLI, scopes its outputs and cache identity to that framework, and records the framework in its target metadata. The framework is joined to the target name with a hyphen (never a colon), so `nx run my-app:build-net10.0-ios` is unambiguous.

Variants are self-contained: they don't depend on the unqualified build and don't pass `--no-dependencies`, so building one framework lets MSBuild build each referenced project's compatible framework directly instead of triggering an all-framework build. The tradeoff is coarser task-level caching of dependencies; `^production` stays an input so a dependency's source change still invalidates the variant.

Any configuration you set on the `build` target is applied to its variants too, and disabling `build` removes them.

This option is off by default and never changes the unqualified targets. Single-targeted projects are unaffected.

## Set up CI for your .NET monorepo

In CI, Nx runs [`nx affected`](/docs/features/ci-features/affected) to rebuild and retest only the projects a change touches, and [caches](/docs/features/cache-task-results) results to skip repeated work.
Expand Down
136 changes: 136 additions & 0 deletions e2e/dotnet/src/dotnet-framework-variants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
cleanupProject,
newProject,
runCLI,
tmpProjPath,
updateJson,
checkFilesMatchingPatternExist,
} from '@nx/e2e-utils';

import {
createDotNetProject,
enableMultiTargeting,
} from './utils/create-dotnet-project';

/**
* Exercises the opt-in per-target-framework target variants for multi-targeted
* projects (https://github.com/nrwl/nx/discussions/36676).
*
* The graph assertions go through the real plugin + MSBuild analyzer, so they
* validate that a multi-targeted project produces correctly-named, correctly-wired
* framework variants while leaving the unqualified targets in place. Frameworks
* are chosen so evaluation does not require an extra workload.
*/
describe('.NET Plugin - Framework Variants', () => {
beforeAll(() => {
newProject({ packages: [] });
runCLI(`add @nx/dotnet`);

// Opt in to framework variants by configuring the plugin.
updateJson('nx.json', (nxJson) => {
nxJson.plugins = (nxJson.plugins ?? []).map((p: unknown) =>
p === '@nx/dotnet'
? { plugin: '@nx/dotnet', options: { frameworkVariants: true } }
: p
);
return nxJson;
});

createDotNetProject({ name: 'MultiApp', type: 'console' });
enableMultiTargeting('MultiApp', ['net9.0', 'net10.0']);

createDotNetProject({ name: 'SingleApp', type: 'console' });

runCLI('run-many -t restore');
});

afterAll(() => cleanupProject());

it('should generate a build variant per target framework', () => {
const details = JSON.parse(runCLI(`show project MultiApp --json`));

expect(details.targets['build-net9.0']).toBeDefined();
expect(details.targets['build-net10.0']).toBeDefined();

// Unqualified targets are preserved.
expect(details.targets.build).toBeDefined();
expect(details.targets['build:release']).toBeDefined();
});

it('should pass --framework to the variant command', () => {
const details = JSON.parse(runCLI(`show project MultiApp --json`));

const args = details.targets['build-net10.0'].options.args;
expect(args).toEqual(
expect.arrayContaining(['--framework', 'net10.0'])
);
});

it('should scope variant outputs to the framework', () => {
const details = JSON.parse(runCLI(`show project MultiApp --json`));

expect(details.targets['build-net10.0'].outputs).toEqual(
expect.arrayContaining([
expect.stringMatching(/net10\.0/),
])
);
// A different framework's directory must not appear in this variant.
for (const output of details.targets['build-net10.0'].outputs) {
expect(output).not.toMatch(/net9\.0/);
}
});

it('should generate a self-contained build variant (no aggregate build dependency)', () => {
const details = JSON.parse(runCLI(`show project MultiApp --json`));

const variant = details.targets['build-net10.0'];
// Self-contained: no dependsOn on the aggregate build, and no --no-dependencies.
expect(variant.dependsOn ?? []).not.toContain('^build');
expect(variant.options.args).not.toContain('--no-dependencies');
});

it('should record the target framework in variant metadata', () => {
const details = JSON.parse(runCLI(`show project MultiApp --json`));

expect(details.targets['build-net10.0'].metadata.targetFramework).toBe(
'net10.0'
);
expect(details.targets['build-net10.0'].metadata.frameworkVariantOf).toBe(
'build'
);
});

it('should never use a colon-ambiguous variant target name', () => {
const details = JSON.parse(runCLI(`show project MultiApp --json`));

const variantNames = Object.keys(details.targets).filter((name) =>
name.includes('net10.0')
);
expect(variantNames.length).toBeGreaterThan(0);
for (const name of variantNames) {
expect(name).not.toContain(':');
}
});

it('should not generate variants for single-targeted projects', () => {
const details = JSON.parse(runCLI(`show project SingleApp --json`));

const variantNames = Object.keys(details.targets).filter((name) =>
/^build-net/.test(name)
);
expect(variantNames).toEqual([]);
});

it('should build a single framework variant in isolation', () => {
const output = runCLI('build-net10.0 MultiApp', {
verbose: true,
env: { NX_DAEMON: 'false' },
});
expect(output).toContain('Build succeeded');

checkFilesMatchingPatternExist(
'.*/MultiApp.dll',
tmpProjPath('MultiApp/bin/Debug/net10.0')
);
});
});
141 changes: 141 additions & 0 deletions packages/dotnet/analyzer.Tests/AnalyzerIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using System.Diagnostics;
using System.Text.Json;
using Xunit;

namespace MsbuildAnalyzer.Tests;

/// <summary>
/// Analyzer-level integration tests that run the real analyzer executable
/// against a temporary multi-targeted project and assert on the public JSON it
/// emits. Unlike the <c>TargetBuilder</c> unit tests, these exercise the full
/// path — MSBuild registration, <c>ProjectGraph</c> inner-build enumeration in
/// <see cref="Analyzer"/>, and serialization — proving the framework variants
/// actually surface (and don't, when disabled) through the contract the Nx
/// plugin consumes.
/// </summary>
public class AnalyzerIntegrationTests : IDisposable
{
private readonly string _workspace;

public AnalyzerIntegrationTests()
{
_workspace = Path.Combine(Path.GetTempPath(), "nx-dotnet-it-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(_workspace, "App"));
File.WriteAllText(
Path.Combine(_workspace, "App", "App.csproj"),
"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
</PropertyGroup>
</Project>
""");
File.WriteAllText(
Path.Combine(_workspace, "nx.json"),
"""{ "namedInputs": { "default": ["{projectRoot}/**/*"], "production": ["default"] } }""");
}

public void Dispose()
{
try { Directory.Delete(_workspace, recursive: true); } catch { /* best effort */ }
}

private static string AnalyzerDll =>
Path.Combine(AppContext.BaseDirectory, "MsbuildAnalyzer.dll");

private JsonElement RunAnalyzer(string? optionsJson)
{
Assert.True(File.Exists(AnalyzerDll), $"Analyzer not found at {AnalyzerDll}");

var psi = new ProcessStartInfo("dotnet")
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
WorkingDirectory = _workspace,
};
psi.ArgumentList.Add(AnalyzerDll);
psi.ArgumentList.Add(_workspace);
if (optionsJson is not null)
{
psi.ArgumentList.Add(optionsJson);
}

using var proc = Process.Start(psi)!;
proc.StandardInput.WriteLine("App/App.csproj");
proc.StandardInput.Close();

var stdout = proc.StandardOutput.ReadToEnd();
var stderr = proc.StandardError.ReadToEnd();
Assert.True(proc.WaitForExit(180_000), "Analyzer timed out");
Assert.True(proc.ExitCode == 0, $"Analyzer exited {proc.ExitCode}. stderr:\n{stderr}");

using var doc = JsonDocument.Parse(stdout);
return doc.RootElement
.GetProperty("nodesByFile")
.GetProperty("App/App.csproj")
.GetProperty("targets")
.Clone();
}

private static string[] TargetNames(JsonElement targets) =>
targets.EnumerateObject().Select(p => p.Name).ToArray();

[Fact]
public void Enabled_RealMultiTargetProject_EmitsBuildVariants()
{
var targets = RunAnalyzer("""{"frameworkVariants":true}""");
var names = TargetNames(targets);

Assert.Contains("build-net8.0", names);
Assert.Contains("build-net9.0", names);
Assert.Contains("build-net8.0-release", names);
Assert.Contains("build-net9.0-release", names);

// Unqualified targets are preserved.
Assert.Contains("build", names);
Assert.Contains("build:release", names);
}

[Fact]
public void Enabled_Variant_HasFrameworkArgsSelfContainedDepsAndScopedOutputs()
{
var targets = RunAnalyzer("""{"frameworkVariants":true}""");
var variant = targets.GetProperty("build-net8.0");

var args = variant.GetProperty("options").GetProperty("args")
.EnumerateArray().Select(a => a.GetString()).ToArray();
Assert.Contains("--framework", args);
Assert.Contains("net8.0", args);
Assert.DoesNotContain("--no-dependencies", args);

// Self-contained: no dependency on the aggregate build.
var dependsOn = variant.TryGetProperty("dependsOn", out var d)
? d.EnumerateArray().Select(x => x.GetString()).ToArray()
: Array.Empty<string>();
Assert.DoesNotContain("^build", dependsOn);

// Outputs are scoped to this framework and no other.
var outputs = variant.GetProperty("outputs")
.EnumerateArray().Select(o => o.GetString()!).ToArray();
Assert.NotEmpty(outputs);
Assert.All(outputs, o => Assert.Contains("net8.0", o));
Assert.DoesNotContain(outputs, o => o.Contains("net9.0"));

Assert.Equal(
"net8.0",
variant.GetProperty("metadata").GetProperty("targetFramework").GetString());
}

[Fact]
public void Disabled_RealMultiTargetProject_EmitsNoVariants()
{
var targets = RunAnalyzer(optionsJson: null);
var names = TargetNames(targets);

Assert.DoesNotContain(names, n => n.StartsWith("build-net"));
Assert.Contains("build", names);
}
}
Loading