Skip to content

Ship FSharp.Core with a net10.0 target framework - #20224

Draft
T-Gro wants to merge 12 commits into
dotnet:mainfrom
T-Gro:t-gro-fsharpcore-net10-tfm-plan
Draft

Ship FSharp.Core with a net10.0 target framework#20224
T-Gro wants to merge 12 commits into
dotnet:mainfrom
T-Gro:t-gro-fsharpcore-net10-tfm-plan

Conversation

@T-Gro

@T-Gro T-Gro commented Aug 6, 2026

Copy link
Copy Markdown
Member

Ships FSharp.Core with an additional net10.0 target framework alongside netstandard2.0 and netstandard2.1. Consumers on modern .NET now bind a net-TFM assembly directly instead of the netstandard2.1 fallback.

The net version is a pinned knob (FSharpCoreShippedNetTargetFramework, default net10.0) that deliberately lags the in-development product TFM. It does not move automatically when the product advances: F# built on net11 still ships lib/net10.0, and the pin bumps only as an explicit, reviewed step. A build-time guard fails if the pin is ever >= the product TFM.

The net10.0 public surface is identical to the netstandard2.1 one — the only difference is the BCL reference set. That invariant is enforced by a blocking, strict/bidirectional assembly-level ApiCompat gate with an empty suppression set, plus a dedicated net SurfaceArea baseline and a TFM #if-guard audit.

Proto stays a single netstandard2.0 bootstrap; the extra target is added to non-Proto builds only.

Not done here (cross-repo, must follow this PR): the shipped-in-SDK path flows dotnet/fsharp → dotnet/dotnet (VMR) → dotnet/sdk, and full source-build validation needs a real VMR leg (DotNetBuildFromVMR). Those cannot be exercised from this repo.

Verified locally on macOS: the 3-TFM FSharp.Core build, pack producing lib/net10.0, the ApiCompat identity gate (passes with empty suppressions, fails on an injected member diff in either direction), the #if audit self-test, and the e2e pack + net10.0-consumer resolution witness (compile and runtime both bind lib/net10.0; a net8.0 control falls back to lib/netstandard2.1). The Windows-only trim/AOT legs and the full CI matrix run in the pipeline.

Draft: opening for CI signal and review of the pinned-TFM policy before finalizing.

T-Gro and others added 11 commits August 6, 2026 14:57
…b (net10.0) + lag guard

The shipped FSharp.Core net TFM is a pinned-stable knob that lags the in-dev
product TFM (FSharpNetCoreProductTargetFramework=net11.0) and tracks the latest
stable released .NET. A build/pack-time guard scoped to the FSharp.Core project
fails if the pin is not strictly less than the product TFM.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add $(FSharpCoreShippedNetTargetFramework) (net10.0) to the non-Proto TFMs and
make FSharp.Core actually compile against the net10 BCL:

- prim-types.fs: move the #endif of the '#if !NET5_0_OR_GREATER' block so the
  structural 'namespace Microsoft.FSharp.Core' + opens are unconditional (they
  were trapped inside the pre-.NET5 polyfill guard and vanished on any net TFM,
  causing ~100 'System type not defined' errors). ns2.x output is unchanged.
- prim-types.fsi: guard the System.Diagnostics.CodeAnalysis polyfill with the
  matching '#if !NET5_0_OR_GREATER' so the signature drops it on net (the impl
  already did), fixing the sig/impl mismatch (FS0193).
- prim-types.{fs,fsi}: floor the .NET8+ CollectionBuilder/ScopedRef polyfills
  with '#if !NET8_0_OR_GREATER' (net BCL provides them).
- Widen collection-expression / IAsyncDisposable guards to '|| NET'
  (set.{fs,fsi}, tasks.{fs,fsi}, prim-types.{fs,fsi} List builders).
- local.fs / Query.fs: disambiguate net10 BCL overloads
  (String.Format ReadOnlySpan<obj>, ElementAt/Take Index/Range) by pinning the
  original obj[]/int overloads. No public-surface change.

Builds clean on netstandard2.0, netstandard2.1 and net10.0 (no warnings).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- FSharp.Core.nuspec: add a net10.0 dependency group and lib/net10.0 file
  entries (dll, xml, satellite resources), all driven by a
  $FSharpCoreShippedNetTargetFramework$ token so the package layout tracks the
  single pinned knob rather than hardcoding the TFM in the manifest.
- FSharp.Core.fsproj: surface the knob to the nuspec via
  <NuspecProperty Include="FSharpCoreShippedNetTargetFramework=..."> (non-Proto).
- Microsoft.FSharp.Compiler.fsproj: include the pinned TFM in the shipping-path
  pack override so the shipped FSharp.Core is packed for net10.0 too, not just
  netstandard2.0/2.1.

Verified via Arcade pack: nupkg contains lib/{netstandard2.0,netstandard2.1,net10.0}
with dll+xml+satellites and a <group targetFramework="net10.0" /> dependency group.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Modern .NET hosts (net11+) now bind lib/net10.0, so the SurfaceArea test
must verify against a net baseline (references System.Runtime, not
netstandard). Reorder platform detection to '#if NET' first, add
FSharp.Core.SurfaceArea.net.{debug,release}.bsl, and document that the
netstandard2.1 baselines are intentionally retained but no longer
exercised (ns2.1 coverage moves to the ApiCompat identity gate).

The net member surface is byte-identical to ns2.1 (only assembly-
reference lines differ), confirming the net10.0 == ns2.1 invariant.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
Add a dedicated FSharp.Core.ApiCompat project that runs the SDK's
Microsoft.DotNet.ApiCompat.Task.ValidateAssembliesTask (registered by
Sdk.targets, not Arcade, not package validation) comparing the freshly
built netstandard2.1 (contract) and net10.0 implementation assemblies in
strict/bidirectional mode with an empty suppression set. This enforces
the net10.0 == netstandard2.1 public-surface invariant.

Wire it as a required, blocking FSharpCore_ApiCompat job (no
continueOnError) in azure-pipelines-PR.yml.

Verified locally: passes clean on identically-built DLLs; strict mode
correctly FAILS on injected additions (ns2.0-vs-net10), non-strict
silently passes them (why strict is mandated).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
Allow-list audit of every TFM-discriminating #if/#elif guard in
src/FSharp.Core/**/*.{fs,fsi}: each must be a reviewed <file>:<expr>
pair, so a bare '#if NETSTANDARD2_1' (excludes the net TFM) or bare
'#if !NET' (drops a BCL polyfill on every net TFM) or any un-reviewed
new TFM guard fails.

Implemented as a portable F# fsx (git ls-files + .NET regex, not
'git grep -P' which needs PCRE) and wired as a blocking step in the
required CheckCodeFormatting job, matching the repo's FSCompCheck.fsx /
TestSplit.fsx source-hygiene idiom.

Verified: self-test OK; passes on the shipped tree (10 allow-listed
guards); FAILS on an injected bare '#if NETSTANDARD2_1'.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
…rifier

Portable fsx that discovers the shipped net TFM lib folder from the produced
FSharp.Core nupkg (never a hard-coded literal) and asserts lib/{netstandard2.0,
netstandard2.1,<pin>} DLL+XML present and non-degenerate, satellites present,
a <group targetFramework> dependency group in the nuspec, and a UNIFORM
AssemblyVersion across all three lib assemblies.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
Isolated net-TFM consumer (targets $(FSharpCoreShippedNetTargetFramework)) that
source-maps FSharp.Core to the locally built package and STRUCTURALLY witnesses,
from obj/project.assets.json, that both compile and runtime bind to
lib/<pin>/FSharp.Core.dll and NOT to any netstandard asset. Driver pins the exact
built version, purges only the cached fsharp.core/<ver>, and runs a widened
IAsyncDisposable task member as the runtime smoke (roll-forward for the net run).

Verified locally: net10.0 -> lib/net10.0 (compile+runtime) + smoke green;
net8.0 control -> lib/netstandard2.1 (witness bites).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
Multi-target the AheadOfTime Trimming (x3) and NativeAOT consumers on
net9.0;$(FSharpCoreShippedNetTargetFramework) so the shipped net-TFM lib is
exercised under PublishTrimmed/PublishAot. The gate is publish SUCCESS under
TreatWarningsAsErrors=true (IL2xxx/IL3050 => errors); trimmed sizes for the net
pin are report-only (-expected_len -1) since byte counts churn per preview. Both
check.ps1 scripts derive the pin from the knob (no literal net10.0 to drift).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
…nto PR pipeline

- e2e-1 runs in EndToEndBuildTests right after the -pack step.
- e2e-2 runs in Build_And_Test_AOT_Windows after the AheadOfTime checks, with the
  consumer's project.assets.json published as always()/continueOnError failure
  evidence.
e2e-5 (trim/AOT net-TFM legs) needs no new wiring: the existing AheadOfTime
check.ps1 chain already runs the multi-targeted projects.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`src/FSharp.Core` docs/release-notes/.FSharp.Core/11.0.100.md

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

Labels

None yet

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

2 participants