Skip to content

Give the assemblies a coherent, binding-stable version - #351

Merged
lahma merged 1 commit into
mainfrom
dll-versioning
Aug 9, 2026
Merged

Give the assemblies a coherent, binding-stable version#351
lahma merged 1 commit into
mainfrom
dll-versioning

Conversation

@lahma

@lahma lahma commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Three problems in how versions reached MSBuild, all fixed by deriving the facets once and applying them to both Compile and Pack.

What was wrong

1. The two halves of the build disagreed. Compile passed no version properties at all, so VersionPrefix fell back to the SDK default and every DLL under build/$(Configuration)/ was stamped 1.0.0.0 — including the ones PackBinaries copies into bin/net for the distribution and the examples. The same assemblies inside the packages were stamped correctly, because only Pack set anything.

build/Release/Spring.Core/net462/Spring.Core.dll   AssemblyVersion=1.0.0.0   <- compile path
lib/net462/Spring.Core.dll (in Spring.Core.3.0.3)  AssemblyVersion=3.0.3.0   <- pack path

2. The preview suffix was inert. Pack used .SetVersion(...), which emits -p:Version=. That is a global override of MSBuild's VersionPrefix-plus-VersionSuffix composition, so the --version-suffix argument next to it did nothing. Untagged CI and local packs produced a bare 3.1.0 instead of 3.1.0-preview-20260809-0945 — indistinguishable from a real release once it lands in a local feed.

3. AssemblyVersion moved on every patch3.0.1.0, 3.0.2.0, 3.0.3.0. The assemblies are strong-named, so AssemblyVersion is part of their binding identity, and consumers on .NET Framework needed a fresh binding redirect for every fix release.

What it does now

DetermineVersion() derives three distinct facets from the tag:

Facet Value Rationale
AssemblyVersion major.minor.0.0 Binding identity. Stable across patches, so 3.1.0 → 3.1.1 → 3.1.2 are drop-in replacements.
FileVersion exact numeric version Where the precise build identity lives instead.
InformationalVersion left to MSBuild So SourceLink appends +<sha>.

Prerelease tags are split (v3.1.0-rc.1 → prefix 3.1.0, suffix rc.1), since only the numeric part is a valid AssemblyVersion.

major.minor.0.0 rather than the more common major.0.0.0: the latter would mean 3.0.0.0, which is lower than the already-shipped 3.0.3.0 and would force a backward redirect at that hop. This choice is monotonic against what is on nuget.org today.

Verification

Against a throwaway v9.9.0-rc.1 tag, deliberately mismatched with the ProjectVersion placeholder:

package        Spring.Core.9.9.0-rc.1.nupkg (+ .snupkg)
nuspec         <version>9.9.0-rc.1</version>
AssemblyVersion 9.9.0.0                                   <- numeric, prerelease stripped
FileVersion     9.9.0
Informational   9.9.0-rc.1+3173ccdd6c079b991f9ab7a6c1dcf1a6169ff710
PublicKeyToken  65e474d141e25e07                          <- strong name unchanged

build/Release/** assemblies carry the same versions as the packaged ones — 60 DLLs checked, no 1.0.0.0 left. Untagged on this branch, Pack produces 3.1.0-preview-20260809-0945 with AssemblyVersion 3.1.0.0. Build project compiles clean. The throwaway tag was deleted and never pushed.

Note

TagVersion still uses SingleOrDefault(x => x.StartsWith("v")), which throws if HEAD ever carries two v* tags. Left alone deliberately — picking one arbitrarily would be worse than failing loudly — but worth a separate issue.

Three problems, all in how versions reached MSBuild.

Compile passed no version properties at all, so every DLL under
build/$(Configuration)/ - including the ones PackBinaries copies into bin/net
for the distribution and the examples - was stamped 1.0.0.0 from the SDK
default, while the same assemblies inside the packages were stamped correctly
by Pack. The two halves of the build disagreed.

Pack set -p:Version, which is a global override of MSBuild's
VersionPrefix-plus-VersionSuffix composition, so --version-suffix was inert.
Untagged CI and local packs therefore produced a bare 3.1.0 rather than
3.1.0-preview-20260809-0945, indistinguishable from a real release in a local
feed.

AssemblyVersion tracked the full package version, so it moved on every patch:
3.0.1.0, 3.0.2.0, 3.0.3.0. The assemblies are strong-named, which makes
AssemblyVersion part of their binding identity, so consumers on .NET Framework
needed a new binding redirect for every fix release.

DetermineVersion() now derives three separate facets from the tag and applies
them to both Compile and Pack: AssemblyVersion pinned to major.minor.0.0 so
patch releases are drop-in replacements, FileVersion carrying the exact
numeric version, and InformationalVersion left to MSBuild so SourceLink can
append +<sha>. Prerelease tags are split, since only the numeric part is a
valid AssemblyVersion.

Verified against a throwaway v9.9.0-rc.1 tag: package 9.9.0-rc.1,
AssemblyVersion 9.9.0.0, FileVersion 9.9.0, InformationalVersion
9.9.0-rc.1+<sha>, PublicKeyToken unchanged, and build/Release assemblies
carrying the same versions as the packaged ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marko Lahma <marko.lahma@gmail.com>
@lahma
lahma merged commit 5503f6f into main Aug 9, 2026
2 of 3 checks passed
@lahma
lahma deleted the dll-versioning branch August 9, 2026 09:49
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