Skip to content

Adopt .NET 11 previews without retargeting (keep shipping net10.0 packages) #633

Description

@petrsvihlik

Goal

Start exercising .NET 11 previews in this repo now, while every NuGet-published library keeps
targeting net10.0. No retargeting, no consumer impact, no preview dependency forced on
contributors.

What is and isn't possible

"Use .NET 11 features" bundles three independent things. They have different answers:

Axis Controlled by Usable while targeting net10.0?
New C# language features Roslyn version (SDK) + LangVersion Yes, for syntax-only features. A feature that needs a new BCL attribute works if the attribute type is available (or declared internally); a feature that needs new CLR support (ref-struct/runtime-shaped features) hard-errors on a downlevel target.
New BCL / ASP.NET Core APIs The TFM's targeting pack No. Compiling against net10.0 means the net11.0 reference assemblies are not on the compile surface. The only way to use them without dropping net10.0 consumers is to multi-target net10.0;net11.0 and guard with #if NET11_0_OR_GREATER.
Running on the .NET 11 runtime Host roll-forward at launch Yes, today, with zero source changes. net10.0 assemblies execute on the .NET 11 runtime via roll-forward. This is where behavioral regressions show up.

So: language features and runtime validation are available immediately; new BCL APIs need
multi-targeting, and that's the one step worth deferring until there's a concrete API that earns it.

Pre-work: two things are floating today

Both should land before anyone installs a preview SDK, because both change behavior silently the
moment one is present on a machine.

  1. There is no global.json. SDK selection is "highest installed". A contributor who installs an
    .NET 11 preview for an unrelated project immediately builds this repo with it — different Roslyn,
    different analyzer wave, different MSBuild — and with TreatWarningsAsErrors +
    EnforceCodeStyleInBuild that surfaces as build breaks that reproduce on one machine and not
    another.

  2. Directory.Build.props sets <LangVersion>latest</LangVersion>. latest is not a pinned
    value — it resolves to the newest language version the current SDK's compiler fully supports. With
    an .NET 11 SDK present, latest silently becomes the next C#, including its changed defaults and
    new analyzer diagnostics. The language version should be an explicit, reviewed decision, not a
    side effect of what someone happened to install.

Plan

Phase 0 — Pin the toolchain (no .NET 11 yet)

  • Add a repo-root global.json pinning the current .NET 10 SDK band, with
    "rollForward": "latestFeature" and — importantly — "allowPrerelease": false, so an
    installed preview SDK is never picked for ordinary builds.
  • Replace <LangVersion>latest</LangVersion> with the explicit current version (14.0) in
    Directory.Build.props.
  • Document in CONTRIBUTING.md that a preview SDK may be installed side by side and will not
    affect this repo.

Net effect: installing preview SDKs becomes a no-op for everyone else. This is the enabling step for
everything below.

Phase 1 — Run the existing net10.0 binaries on the .NET 11 runtime (non-blocking CI lane)

Cheapest, highest-signal step. No TFM change, no source change.

  • New workflow (.github/workflows/net11-preview.yml), schedule: nightly +
    workflow_dispatch, continue-on-error: true and not wired into required checks —
    preview churn must never red-gate a PR.

  • setup-dotnet with both 10.0.x and 11.0.x (wopi-validator.yml already does exactly this
    two-SDK pattern for its net8 tool, so there's precedent to copy).

  • Build normally, then run dotnet test with:
    - DOTNET_ROLL_FORWARD=LatestMajor
    - DOTNET_ROLL_FORWARD_TO_PRERELEASE=1

    ⚠️ LatestMajor, not Major. Roll-forward policies below LatestMajor only engage when the
    requested runtime is absent. Since CI installs the .NET 10 runtime, Major would quietly keep
    running on .NET 10 and the whole lane would be a no-op that reports green. LatestMajor rolls
    forward even when the requested version is present. _TO_PRERELEASE is required on top, because a
    preview runtime is otherwise not a roll-forward candidate.

  • Add an assertion step that the lane actually landed on 11 (print dotnet --list-runtimes and
    RuntimeInformation.FrameworkDescription from a running sample) — a silently-not-rolling-forward
    job is worse than no job.

  • Consider extending to the E2E suites later. .runsettings filters them out of default runs, so
    the nightly lane would need an explicit filter override; the Collabora / ONLYOFFICE lanes are
    where runtime behavior differences (HTTP stack, TLS, JSON) would actually bite.

Phase 2 — Build with the .NET 11 SDK, still targeting net10.0 (same lane)

New SDK + old TFM: new Roslyn, new analyzer wave, new MSBuild, unchanged output target.

  • Add a matrix leg to the Phase 1 workflow that overwrites global.json in-job (a visible,
    deliberate jq step) with "allowPrerelease": true + "rollForward": "latestMajor", then
    builds.
  • Expect findings. With TreatWarningsAsErrors globally on, every new default-on analyzer
    diagnostic is a build break — that's the point of finding them nightly instead of on the
    retarget branch.
  • Highest-risk component: infra/WopiHost.AppHost. It pins Aspire.AppHost.Sdk 13.4.6 as an
    MSBuild SDK, so it's coupled to SDK internals more tightly than a normal PackageReference.
    If one leg breaks first, this is it. Acceptable to exclude the AppHost from the Phase 2 leg
    initially and track its Aspire-side support separately.

Phase 3 — Opt into new C# language features (still net10.0 everywhere)

Only once Phase 2 is reliably green, and ideally once the SDK is at RC/GA rather than early preview.

The important design decision is not to apply LangVersion uniformly:

  • Packaged libraries (src/**) stay on a released LangVersion. A preview language feature
    can change shape or be pulled before GA; that risk does not belong in code that ships to
    NuGet consumers, and it would force every contributor onto a preview SDK to build the
    packages at all.
  • Non-shipped code (sample/**, test/**, infra/**) may move to preview first. sample/ and
    test/ already have their own Directory.Build.props chaining up to the root, so this is a
    one-property change in each. src/ and infra/ currently have none — adding an
    src/Directory.Build.props (using the same GetPathOfFileAbove chaining idiom already used
    twice in this repo) is what makes the split expressible.
  • Bumping src/** to a new released LangVersion requires pinning that SDK in global.json,
    i.e. it makes the .NET 11 SDK mandatory for all contributors and all CI. That's a deliberate
    cutover, not a drive-by.

Phase 4 — Multi-target net10.0;net11.0 (only when a specific API justifies it)

Deferred by default. Worth doing when there's a named .NET 11 API that measurably improves a hot path
— not speculatively.

Constraints to respect when it happens:

  • Keep the public API surface identical across both TFMs. Package validation's
    compatible-framework check compares the assets in the package against each other; an API that
    exists only under #if NET11_0_OR_GREATER fails it. Use the #if for implementation only.
  • Adding a TFM is additive and does not trip baseline validation against
    PackageValidationBaselineVersion (currently 9.1.0) — removing net10.0 later is the
    breaking one, and per this repo's own precedent (v8 was the last with net8.0/net9.0) that
    belongs in a major.
  • Build/CI time roughly doubles for src/**.
  • CLAUDE.md states the repo is single-targeted on net10.0; that and the README need updating
    in the same PR (the docs-drift workflow will flag it otherwise).
  • tools/wopi-validator stays on net8.0 regardless — it's pinned by the upstream
    Microsoft.Office.WopiValidator package.

Phase 5 — Retarget at .NET 11 GA

Standard major-version cutover: flip TFMs to net11.0, drop net10.0, bump global.json, bump all
nine workflows' dotnet-version, ship as the next major.

Notes / non-goals

  • Dependabot needs no guard. It doesn't propose prerelease versions while the current version is
    stable, so the daily grouped-auto-merge flow won't start pulling 11.0.0-preview.* packages on its
    own. That changes only if a preview package is deliberately pinned somewhere — then that project
    starts receiving preview bumps.
  • Nothing preview goes into pull_request.yml / integrate.yml. Preview lanes are nightly,
    separate, and non-blocking, for the whole life of this effort.
  • Not proposing any TFM change, any src/** language change, or any package-shape change as part
    of Phases 0–2. Those phases are purely additive: one new file, one property made explicit, one new
    non-blocking workflow.

Metadata

Metadata

Assignees

No one assigned

    Labels

    .NETPull requests that update .NET codeciContinuous Integration / Continuous Deploymentenhancement

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions