Skip to content

feat(dotnet): opt-in per-target-framework target variants for multi-targeted projects - #36680

Draft
Redth wants to merge 3 commits into
nrwl:masterfrom
Redth:redth-dotnet-tfm-variants
Draft

feat(dotnet): opt-in per-target-framework target variants for multi-targeted projects#36680
Redth wants to merge 3 commits into
nrwl:masterfrom
Redth:redth-dotnet-tfm-variants

Conversation

@Redth

@Redth Redth commented Aug 15, 2026

Copy link
Copy Markdown

Draft / exploratory — opening for design feedback per discussion #36676. PR 1 of a small stack. The follow-up #36681 adds per-RID release-build/publish variants on top of this branch (review this one first).

Current Behavior

Multi-targeted .NET projects (those declaring <TargetFrameworks>) only get unqualified inferred targets. A single nx build invokes dotnet build across every target framework at once, which:

  • has no single host that can build every framework (an iOS + Windows project can't build on one machine),
  • collapses the outputs and cache identity of each framework's build together, and
  • gives nx affected / distributed execution no way to select a single framework.

Expected Behavior

An opt-in frameworkVariants plugin option. When enabled, the MSBuild analyzer enumerates the evaluated ProjectGraph inner builds (one per target framework — real evaluated MSBuild nodes, not XML parsing) and emits per-framework build variants alongside the existing unqualified targets:

  • build-<tfm> — build a single framework (Debug by default, with debug/release configurations)
  • build-<tfm>-release — build a single framework in Release

For net10.0;net10.0-ios you get build-net10.0, build-net10.0-ios, build-net10.0-release, build-net10.0-ios-release.

Each variant passes --framework <tfm>, scopes its outputs and cache identity to that framework's evaluated output/intermediate directories (both Debug and Release forms), and records the framework in target metadata.

Design notes

  • Opt-in / non-disruptive. Off by default. When disabled the generated targets are byte-for-byte identical, and single-targeted projects are never expanded.
  • Self-contained variants. A variant does not depend on the unqualified ^build and does not pass --no-dependencies. Depending on the aggregate build would rebuild every framework of every dependency and reintroduce the host-compatibility problem the feature solves; instead MSBuild builds each referenced project's framework-compatible inner build directly. Tradeoff: coarser task-level caching of dependencies — ^production stays an input so a dependency source change still invalidates the variant.
  • Honors build config and disabling. Variants aren't generated when build is disabled, are removed if build is disabled, and the user's build configuration is merged into each variant. Variants carry a frameworkVariantOf metadata marker so the plugin matches them to their base target.
  • Colon-safe, deterministic, collision-aware names. Discussion #35837 noted build:release triggers Nx's "Ambiguous target specifier" warning because : collides with project:target:configuration. Variant names join the framework to the configured (possibly renamed) target name with a hyphen, normalize deterministically, and any collision is reported and skipped rather than overwriting a target.
  • Evaluated state, not XML. Per-framework paths come from the evaluated inner-build ProjectInstance.

No nx core API changes are required.

Out of scope (follow-ups)

Testing

  • C# analyzer unit tests (TargetBuilderFrameworkVariantsTests) — opt-in guard, build-only generation, self-contained (no aggregate dep, no --no-dependencies), framework-scoped outputs, metadata (targetFramework + frameworkVariantOf), colon-safe naming, configured-name derivation, collision handling.
  • create-nodes spec — option pass-through, disable gating, variant config merge, variant removal on disable.
  • e2e (dotnet-framework-variants.test.ts) — variants through the real plugin + analyzer, self-contained check, and building a single framework in isolation.
  • Docs: astro-docs dotnet introduction.

⚠️ The JS-side nx build/lint/jest and e2e were not run in my sandbox: pnpm install cannot complete because the registry returns 404 for an unpublished Nx beta (@nx/devkit@23.2.0-beta.7, via @nx/graph) behind an auth-gated proxy. The C# analyzer — which contains all of the target-generation logic — builds and its unit tests pass (43), and the generated target JSON was validated end-to-end by running the analyzer against a real multi-targeted project. CI should exercise the JS/e2e paths.

Related Issue(s)

Refs discussion #36676, discussion #35837. Related: #33474, #33662.

…argeted projects

Multi-targeted .NET projects (those declaring `<TargetFrameworks>`) expose a
correctness gap that project-level targets can't express: an unqualified
`dotnet build` has no single host that can build every framework (an iOS +
Windows project is the canonical case), and the outputs and cache identity of
each framework's build are collapsed together.

This adds an opt-in `frameworkVariants` plugin option. When enabled, the
MSBuild analyzer enumerates the evaluated ProjectGraph inner builds (one per
target framework) and emits per-framework target variants alongside the
existing unqualified targets:

- `build-<tfm>` / `build-<tfm>-release`
- `test-<tfm>` (test projects)
- `publish-<tfm>` (executable projects)

Each variant passes `--framework` to the CLI, scopes its outputs and cache
identity to the framework's evaluated output/intermediate directories, wires
the correct dependency edges, and records the framework in target metadata.

Variant names join the framework to the configured target name with a hyphen
(never a colon), so they can't be mistaken for a configuration in Nx's
`project:target:configuration` syntax. Names derive from the configured target
names, normalize deterministically, and collisions are reported and skipped
rather than silently overwriting a target.

The option is off by default and leaves the generated targets byte-for-byte
unchanged; single-targeted projects are never expanded.

Refs discussion nrwl#36676, discussion nrwl#35837.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@netlify

netlify Bot commented Aug 15, 2026

Copy link
Copy Markdown

👷 Deploy request for nx-docs pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 7568650

@netlify

netlify Bot commented Aug 15, 2026

Copy link
Copy Markdown

👷 Deploy request for nx-dev pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 7568650

…f-contained

Addresses review feedback on the initial framework-variants commit:

- Reduce scope to build variants only (build-<tfm> / build-<tfm>-release).
  The test/publish variants keyed off project-level isTest/isExe, which do not
  necessarily apply per framework; test variants are deferred to a separate
  design and RID publish is handled in a follow-up.

- Make build variants self-contained: drop the `^build` dependency and the
  `--no-dependencies` flag so a single-framework build lets MSBuild build each
  referenced project's compatible framework directly, instead of triggering an
  all-framework dependency build that reintroduces the host-compatibility
  failure these variants exist to avoid. `^production` remains an input so a
  dependency source change still invalidates the variant.

- Honor a disabled build target and stop ignoring user config: variants are
  not generated when `build` is disabled, are removed if `build` is later
  disabled, and the user's `build` configuration is merged into each variant.
  Variants carry a `frameworkVariantOf` target-metadata marker so the plugin
  can match them to their base target.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ariants

Adds an integration test that runs the real analyzer executable against a
temporary multi-targeted project and asserts on the public JSON the Nx plugin
consumes. This closes the gap left by the unit tests, which cover only the pure
TargetBuilder logic: it proves the MSBuild ProjectGraph inner-build enumeration
in Analyzer actually surfaces per-framework build variants (with framework
args, self-contained dependencies, and framework-scoped outputs) when enabled,
and none when disabled.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@AgentEnder

Copy link
Copy Markdown
Member

This is the one I'm most excited about, and the problem is real. We've settled the open design questions on our side rather than leaving you to guess. The reasoning is in #36676, and the change list is below.

Headline: your original build:net10.0 from the discussion was right. The ambiguity warning in #35837 only fires when the alternate parse's configuration genuinely exists (split-target.ts:96-99: the three-part parse is only recorded if configurations[candidate] is present). build has debug and release, not net10.0-ios, so app:build:net10.0-ios has one interpretation and warns about nothing. #35837 was a real report precisely because release is a configuration name.

Blocked on #36527

This needs targetGroups in the analyzer, which @nx/dotnet doesn't emit today and #36527 introduces (ProjectMetadata.TargetGroups, BuildTargetsResult.TargetGroups). Two things need it: .env resolution walks metadata.targetGroups to find a target's owner (task-env.ts:225-242), and without grouping the project details view is a flat wall of variant targets. We're reviewing #36527 first. That delay is on us.

Design changes

  • Separator back to :. build:net10.0-ios. Not -, which is also the separator inside a TFM, so build-net10.0-ios-release can't be parsed back into parts. Not -- either. That one means "atomized shard" everywhere else in Nx and would signal the wrong thing, since variants aren't shards.
  • Drop build-<tfm>-release. The variant already carries a release configuration, and @nx/dotnet maps Nx configurations 1:1 onto dotnet --configuration. A separate -release target re-flattens a dimension that was cleanly separated, and it's the source of two bugs below.
  • build becomes an nx:noop aggregate with dependsOn every TFM variant, and cache: false. This is what makes nx affected -t build fan out so CI doesn't enumerate TFMs by hand. On a Mac you get three green cached tasks and one red one, which is better than today's atomic all-TFM failure. cache: false matters because dependsOn isn't part of the project config hash, so two hosts would otherwise produce the same build hash for different work.
  • Rename frameworkVariants to tfmVariants, and default it on. "Framework" reads as .NET Framework to a .NET audience. Single-TFM projects generate no variants and see no change, so the blast radius is projects that are already broken under the all-TFM build.
  • Add transitive: true to dependentTasksOutputFiles on every build target. With a noop aggregate the direct ^build has no outputs, so a consumer silently loses the dependency's artifacts from its hash. transitive makes the BFS walk through the no-output aggregate to the variants underneath (dep_outputs.rs:10-45).
  • Use metadata.variantOf, not frameworkVariantOf. We're adding that key to core, so it's our change rather than yours. It can't be nonAtomizedTarget: that one ends in process.exit(1) without Nx Cloud (task-graph-utils.ts:161), and your variants must stay runnable locally.
  • Scope inputs per TFM. BuildVariantInputs currently returns identical inputs for every variant, so an Android source change invalidates the iOS build. Per-TFM inputs are the main reason we chose targets over configurations here. Configurations can't vary inputs at all, since the hasher only expands {projectRoot} and {workspaceRoot}. Not exploiting it forfeits the reason for the design.

Bugs, independent of the above

  • WithConfiguration rewrites every matching path segment, not the first (continue, not break). A workspace path containing a Debug or Release directory gets mangled.
  • User config clobbers the variant. In create-nodes.ts, mergeTargetConfigurations(userSpecifiedConfig, variant) means anyone setting build.options.args shallow-overrides the variant's --framework <tfm> and silently turns it back into an all-framework build. The base target has the same hazard, but there it only drops --no-restore.
  • Both output bugs I'd flagged (declaring Debug and Release paths on one target, and two spellings with different cache identities) disappear once build-<tfm>-release is gone.

Worth keeping

Plenty here should survive the rework. The self-contained reasoning in the header comment is right, and the tradeoff is stated honestly. Deriving per-framework paths from the evaluated inner-build ProjectInstance rather than reconstructing them is correct. Collision handling that warns and skips rather than overwriting a real target is the right failure mode.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants