Skip to content

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

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

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

Conversation

@T-Gro

@T-Gro T-Gro commented Aug 7, 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, or if it is not a plain netN.0 framework.

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.

net10.0 is a package-only asset: it is built, packed, and API-validated, but in-repo .NET consumers are pinned back to the netstandard2.1 asset by a central Directory.Build.targets rule. The in-box compiler and tools — and their IL and surface-area baselines — stay byte-for-byte unchanged, so adding the target is inert for this repo. External consumers on modern .NET get lib/net10.0 through normal nearest-TFM resolution; that the two assets are interchangeable is proven by the identical public surface, the uniform AssemblyVersion, and the e2e resolution witness.

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). 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 12 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 7, 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

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

T-Gro commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

@T-Gro

T-Gro commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

@T-Gro

T-Gro commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

…chronouslyImmediate

The merge from main introduced the new public member
Async.RunSynchronouslyImmediate. The dedicated net SurfaceArea baselines
(created before that merge) did not include it; regenerate both the debug
and release net baselines. The netstandard2.1 baseline already carried the
member via the merge, and the ApiCompat net10-vs-netstandard2.1 identity
gate still passes, so the surfaces remain identical.

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

T-Gro commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI added 4 commits August 7, 2026 11:14
…ding (NoBloat)

Remove step-by-step and design-justification narration from the new e2e
scripts, the ApiCompat project, the #ifdef audit, and the SurfaceArea
platform-selection comment; keep only the non-obvious 'why' (the
ApiCompat-vs-package-validation trap, the macOS git-grep portability note,
the net<->baseline mapping invariant). No functional change; all gates
still pass locally.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
Drop the fsproj header that duplicated the driver script's intent and a
redundant inline comment in the runtime smoke. The remaining comments are
non-obvious MSBuild 'why' (CPM/RollForward/implicit-ref opt-outs). No
functional change; e2e-2 still passes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
Removes an agent-plan breadcrumb (files/plan.md 4e) from the ApiCompat
proj header comment and a (Milestone B) phase tag from a
VerifyNetResolution.fsx error string, per NoBloat. No behavior change.

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

- VerifyFSharpCorePackage.fsx: checkAsset helper collapses the duplicated
  dll/xml exists+size-floor assertions.
- VerifyNetResolution.fsx: close a fail-open in the structural witness
  (empty dllPaths without _._ now fails).
- AuditFSharpCoreTfmGuards.fsx: self-test now drives the real tfmGuardsIn
  extractor (was bypassing the #if/#elif line parser) + #elif coverage.
- SurfaceArea.fs: drop the unreachable #elif NETCOREAPP netstandard21 branch
  (a modern host defines NET); delete the now-orphaned netstandard21 baselines
  (ns2.1 surface is covered by the FSharp.Core.ApiCompat gate).
- Program.fs / ApiCompat proj / PR yaml: comment accuracy + trims.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: dd0ca93f-cf73-450a-8a5a-f9e59841802e
Copilot AI added 5 commits August 7, 2026 11:52
…sts job

The AOT Windows trimming job (Build.cmd -ci -pack) does not leave a shipping
FSharp.Core.*.nupkg in artifacts/packages/Release/Shipping, so the e2e-2
asset-resolution witness failed there with 'no FSharp.Core.*.nupkg ... pack
first'. Move the step (and its project.assets.json artifact publish) into the
EndToEndBuildTests job right after e2e-1, where Build.cmd -c Release -pack
provably produces that nupkg (e2e-1 reads it successfully). Drop the now
-undefined $(_kind) matrix suffix from the artifact name.

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

Expert-review r1 fail-closed nits:
- eng/TargetFrameworks.props: validate FSharpCoreShippedNetTargetFramework matches
  ^netN.0$ before the numeric strictly-less-than compare, so a malformed override
  (e.g. net10.0-windows) fails with a clear message instead of an opaque
  System.Version parse error. Verified: net10.0 passes; net10.0-windows -> shape
  error; net11.0/net12.0 -> strictly-less error.
- VerifyNetResolution.fsx: an entirely-absent compile/runtime section for
  FSharp.Core now fails the witness (fail-closed) instead of silently passing.

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

Adding net10.0 to FSharp.Core's TargetFrameworks made MSBuild nearest-TFM
resolution bind the net10.0 asset for in-repo .NET Core ProjectReferences
(the compiler graph, ComponentTests, FSharp.Core.UnitTests). net10.0
FSharp.Core references System.Runtime instead of the netstandard facade, so
128 EmittedIL baselines lost their 5-line `.assembly extern netstandard`
block and the SurfaceArea baseline shifted -- CI failed on MacOS/Linux Batch.

net10.0 is intended as a package-only asset (the maintainer idiom already
present in FSharp.Compiler.Service.Tests.fsproj, PR #18840). Pin FSharp.Core
ProjectReferences back to netstandard2.1 for .NET Core consumers via a central
Directory.Build.targets rule (exact pre-net10.0 binding). .NET Framework
consumers keep netstandard2.0; Proto is excluded (ships only ns2.0); any
pre-existing explicit SetTargetFramework pin is preserved.

net10.0 is still built by FSharp.Core.fsproj, packed into lib/net10.0, and its
surface is validated by the ApiCompat net10.0-vs-ns2.1 identity gate + e2e
pack-and-consume tests -- none of which use a ProjectReference, so the pin
leaves them intact.

Reverts the now-dead dedicated `net` SurfaceArea baseline (C1): under the pin
the UnitTests host loads ns2.1, so the netstandard21 baseline is restored.

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

The shipped FSharp.Core nupkg lands in different sub-lanes depending on the
pack: top-level Release/Shipping locally, Release/Dependency/Shipping on CI.
e2e-2 hard-coded Release/Shipping in both its version lookup and its
NuGet.Config, so on CI it failed with "no FSharp.Core.*.nupkg ... pack first"
(e2e-1, which also searches Dependency/Shipping, passed). The prior green
EndToEndBuildTests run was canceled before e2e-2 ever executed.

Point the NetTfmResolution NuGet source at the packages root
(artifacts/packages/Release), which always exists after a pack; a folder
source recurses into the sub-lanes and packageSourceMapping keeps FSharp.Core
local-only. Listing both explicit lanes instead is fragile because NuGet
errors NU1301 when a configured local source directory is absent. The fsx
version lookup searches both candidate lanes and takes the newest.

Verified: local pack + run -> restore OK, compile+runtime both bind
lib/net10.0/FSharp.Core.dll, widened IAsyncDisposable member executes.

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

Pointing NuGet at the packages root (artifacts/packages/Release) resolved the
FSharp.Core nupkg locally (depth-1 Release/Shipping) but failed on CI with
NU1101 -- NuGet's folder source does not reliably recurse into the depth-2
Dependency/Shipping sub-lane where the package lands on CI.

Stage the freshly built nupkg into a clean flat feed (obj/localfeed, gitignored)
and point the consumer NuGet.Config at it. A depth-0 flat folder is resolved
deterministically on every OS/CI. The negative control is preserved:
packageSourceMapping keeps FSharp.Core local-only, and a missing build output
still fails closed (exit 2 before restore).

Verified: local run -> restore OK, compile+runtime both bind
lib/net10.0/FSharp.Core.dll, widened IAsyncDisposable member executes.

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