Skip to content

Test the package against every supported .NET SDK and FSharp.Core - #12

Merged
vbfox merged 10 commits into
mainfrom
feature/docker-compat-matrix
Jul 31, 2026
Merged

Test the package against every supported .NET SDK and FSharp.Core#12
vbfox merged 10 commits into
mainfrom
feature/docker-compat-matrix

Conversation

@vbfox

@vbfox vbfox commented Jul 28, 2026

Copy link
Copy Markdown
Owner

What

Adds a compatibility test suite that installs the built .nupkg into small consumer programs inside Docker containers with pinned .NET SDK and FSharp.Core versions, and compares their output against golden files.

The existing Expecto suite uses a ProjectReference, so it only ever exercises whatever FSharp.Core the repository itself resolves. This covers the other axis: what a real consumer's toolchain produces.

Each case formats a value twice — once through the library, once through the FSharp.Core of that combination — so FSharp.Core is the oracle and any divergence is marked MISMATCH. Golden files then pin the output so silent drift also shows up.

Combination SDK FSharp.Core TFM
floor-3.1 3.1 4.5.0 netcoreapp3.1
interp-5.0 5.0 5.0.2 net5.0
binary-6.0 6.0 6.0.7 net6.0
lts-8.0 8.0 8.0.403 net8.0
modern-sdk-old-core 10.0 4.5.0 net10.0
current-repo 10.0 10.1.302 net10.0

Three program variants, each a superset of the last: Floor45 (every specifier, flag, width/precision and star form), Interpolation50 (string interpolation), Binary60 (%B). A combination only runs the variants its era supports.

The bug it found

The package could not be consumed at all by SDK 3.1, 5.0 or 6.0. NuGet resolved it and passed -r:BlackFox.MasterOfFoo.dll to the compiler, but compilation failed with The namespace or module 'BlackFox' is not defined.

Since F# 8 the compiler compresses its metadata resource by default, naming it FSharpSignatureCompressedData. Older compilers only look for the uncompressed FSharpSignatureData, find nothing, and conclude the assembly contains no F# metadata. Building with --compressmetadata- fixes every failing row, at a cost of ~29 KB of assembly size (84 KB → 113 KB).

Notably, shipping multiple DLLs would not have helped: NuGet selects by the consumer's target framework, not their compiler version, and every target framework in one build is compiled by the same fsc anyway.

Result

All 13 checks pass, with byte-identical output across FSharp.Core 4.5.0 through 10.1.302 — no behavioural drift at all in ~70 format cases.

Notes

  • Runs locally and in CI with ./build.sh DockerCompat; writes artifacts/CompatReport.md.
  • Runs as its own workflow job, not part of CI, since it builds several images.
  • Every docker call pins linux/amd64 because the old SDK images are only published for that architecture.
  • The existing linux and windows jobs are untouched.

🤖 Generated with Claude Code

vbfox and others added 10 commits July 28, 2026 23:49
The Expecto suite uses a project reference, so it only ever exercises the
FSharp.Core the repository itself resolves. This adds a second suite that
consumes the packed nupkg from inside containers with pinned SDK and
FSharp.Core versions, which is what a real consumer's toolchain looks like.

Each case formats a value through the library and through the FSharp.Core of
that combination, so FSharp.Core is the oracle and any divergence is marked
MISMATCH; golden files pin the output so silent drift also shows up. One image
is built per combination and the program variant is chosen at run time.

Run it with `./build.sh DockerCompat`. Only the combination the repository
builds against fails the build; the rest are reported in artifacts/CompatReport.md.

The matrix shows SDK 3.1, 5.0 and 6.0 cannot consume the package at all: built
by the .NET 10 SDK, it carries its F# metadata only in the compressed form F# 8
introduced, so older compilers see no F# metadata in the assembly. That puts the
real consumer floor at SDK 8.0 rather than at the declared FSharp.Core 4.5.0.
Those rows are left failing and tolerated so the boundary stays measured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since F# 8 the compiler compresses its metadata resource by default, naming it
FSharpSignatureCompressedData. Compilers older than that only look for the
uncompressed FSharpSignatureData, find nothing, and conclude the assembly has no
F# metadata at all — so consumers on SDK 3.1, 5.0 and 6.0 could not see the
namespace even though NuGet resolved and passed them the assembly.

Emitting the uncompressed resource fixes every failing row of the compatibility
matrix: it now passes from SDK 3.1 with FSharp.Core 4.5.0 through SDK 10 with
FSharp.Core 10.1.302, with byte-identical output across all of them. All rows
are promoted to mandatory so any regression fails the build.

Shipping several DLLs would not have worked instead: NuGet selects by the
consumer's target framework rather than their compiler version, and every target
framework in one build is compiled by the same fsc anyway.

Costs about 29 KB of assembly size (84 KB to 113 KB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It existed so combinations that could not work yet would be reported without
blocking, but every combination passes now, so the flag was uniformly true and
the two failure categories only added noise to the report and the summary.

Any failure now fails the build. A combination that stops working should be
fixed or removed rather than quietly downgraded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
They were string constants, so nothing stopped a typo or an arbitrary string
from reaching the matrix, and the compiler could not tell that the three
variants are the whole set.

Variant.name converts to the string the file names and entrypoint.sh use, so
that spelling now lives in exactly one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The build context is the repository root because the image needs both
compat-tests/ and the packed nupkg, which forced the ignore file to the
repository root where it was the only Docker-related file outside compat-tests/.

Docker prefers an ignore file named after its Dockerfile and sitting beside it
over a .dockerignore at the context root, so it can live in compat-tests/. The
patterns are unchanged: they are relative to the context root either way.

Verified by copying the whole context into a throwaway image: 228 KB with the
ignore file against 95 MB without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Markdown report and its CI artifact were an extra hop for information a CI
log shows directly. The summary now carries the SDK and FSharp.Core versions of
each row, and a failure prints the captured compiler output or the differing
lines, which is what the report existed to preserve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vbfox
vbfox merged commit 6bf1873 into main Jul 31, 2026
3 checks passed
@vbfox
vbfox deleted the feature/docker-compat-matrix branch July 31, 2026 23:02
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.

1 participant