-
Notifications
You must be signed in to change notification settings - Fork 0
0012 per package versioning
Status: accepted · Date: 2026-08-05
Directory.Build.props declared one <Version> for the repository, inherited by
DataNet.Text, DataNet.Embeddings and DataNet.Fuzzy alike, and both release
workflows packed all three from a single v* tag. A patch touching only
DataNet.Fuzzy therefore republished the other two at a number describing no
change in them, and each package's version stopped saying anything about that
package.
There was also exactly one inter-project reference under src/:
DataNet.Fuzzy → DataNet.Text, by ProjectReference.
Worth stating plainly, because it changes what this decision is for: a
ProjectReference between two packable projects already becomes a NuGet
<dependency> in the produced .nuspec. Consumers of DataNet.Fuzzy were
already pulling DataNet.Text as an ordinary package dependency. The shipped
graph was never wrong. What was wrong was that the build graph and the
release graph disagreed, and that disagreement is what forced the lockstep.
Each publishable project declares its own version in a sibling
Version.props, and DataNet.Fuzzy reaches DataNet.Text through a
PackageReference on the published package.
The first exercise of the split ships with this change: DataNet.Text and
DataNet.Fuzzy both go to 0.3.0 — the first has gained stop-word lists, the
second has changed how it declares its dependency. DataNet.Embeddings followed
to 0.3.0 shortly after, on its own account, for the vocabulary loaders.
That all three ended up on the same number is not a dilution of the point. Independent versioning is not a rule that numbers must differ; it is the removal of the rule that they must match. What was previously impossible is a package holding still while its neighbours move — and each of these three moved because of something in it, which is the property the split buys.
Nerdbank.GitVersioning and MinVer both support what is needed here, and GitVersion can be configured into it. All three were declined, for the same reason: they derive a version from git height and tag topology, and nothing in this repository wants a derived version. The number is a deliberate semantic statement about a public API. Writing it down makes it reviewable in the diff that changes it, costs no package — not even a build-time one — and leaves the decision open. The day the release cadence genuinely diverges and hand-editing three files becomes the bottleneck, MinVer is the smallest step from here.
The file holds a named property (DataNetTextVersion) rather than plain
<Version>, because three places need the number for three different reasons:
the csproj (its own identity), src/Directory.Packages.props (the floor
DataNet.Fuzzy depends on) and samples/DataNet.Sample.csproj (the version that
was just packed). One source of truth, imported where needed.
src/Directory.Packages.props pins DataNet.Text to a version written out in
full, deliberately not $(DataNetTextVersion). It answers a different question:
the minimum DataNet.Text a consumer of DataNet.Fuzzy must take. Raising it is
a semver decision, not a side effect of DataNet.Text moving.
That decoupling is also what keeps the build honest on a fresh clone. Had the
floor tracked DataNet.Text's current version, every checkout where that version
is not yet published would fail to restore until someone ran dotnet pack into a
local feed first — the chicken-and-egg this arrangement is otherwise prone to.
Because the floor always names something already on nuget.org,
git clone && dotnet build works with no pack step at all.
The cost is real and is the point: when DataNet.Fuzzy needs new DataNet.Text
API, DataNet.Text must be released first, and only then can the floor rise.
Two packages that release independently cannot also be edited as one.
DataNet.Fuzzy.csproj carries both references under a condition:
<ItemGroup Condition="'$(DataNetUseProjectRefs)' == 'true'">
<ProjectReference Include="../DataNet.Text/DataNet.Text.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(DataNetUseProjectRefs)' != 'true'">
<PackageReference Include="DataNet.Text" />
</ItemGroup>The default — property unset — is the path CI packs and ships. ADR 0009 made
this point about the sample: inside the solution, ProjectReference resolution
"would quietly satisfy the references and the sample would prove nothing while
appearing to work." The stakes are higher here, because the quiet path is also
the convenient one.
Three things keep it from becoming the accidental default:
- The property is only ever set by a person, as an environment variable.
MSBuild reads environment variables as properties, so one
exportcoversdotnet build,dotnet testand the IDE — no flag to remember per command, which is what makes the loop survivable. A workflow requiring a pack-and-restore cycle per edit would be abandoned within a week. - The build prints a high-importance message whenever it is on.
- CI asserts the shipped path directly, by evaluating MSBuild rather than
grepping. A text search for
<ProjectReferencecannot distinguish the two paths — it matches the conditional group either way — so the check asks MSBuild what the project actually resolved:dotnet msbuild src/DataNet.Fuzzy -getItem:ProjectReferencemust come back empty.
The .nuspec check is a second, independent net under the same failure. Both
paths do produce a <dependency> element on DataNet.Text, so the id cannot
tell them apart — but the version can, and they differ: a PackageReference
emits the floor from src/Directory.Packages.props, while a ProjectReference
emits DataNet.Text's own current version, which the floor is deliberately
decoupled from. tools/check_nuspec_dependencies.py therefore asserts ranges as
well as ids, and a package built with the escape hatch left on fails it —
verified by packing with DataNetUseProjectRefs=true, which emits
DataNet.Text 0.3.0 where the floor says 0.2.0.
Note its one blind spot, so nobody mistakes it for the primary guard: the two
paths are distinguishable only while the floor and DataNet.Text's own version
differ. They are equal on the release commit that raises the floor to a
just-published version, and on that commit this check would pass either way. The
MSBuild-evaluated check above has no such window, which is why it, and not this
one, is the guard that matters.
This one was found by measurement, not by reading, and it is the sharpest edge in the whole change.
The *.NetStandard.Tests mirrors replay a suite against the netstandard2.0
build by pinning ProjectReference … SetTargetFramework="TargetFramework=netstandard2.0".
That pin does not travel through a package: NuGet resolves package assets against
the consuming project's framework, which for these test projects is net10.0.
After the migration, DataNet.Fuzzy.NetStandard.Tests was running the
netstandard2.0 DataNet.Fuzzy against the net10.0 DataNet.Text — half a
mirror — and every test stayed green, because NetStandardAssemblyGuardTests only
ever inspected the DataNet.Fuzzy assembly. Precisely the false confidence that
guard exists to prevent.
The fix: DataNet.Fuzzy.NetStandard.Tests names DataNet.Text in an explicit
pinned ProjectReference of its own (tests → src references are unaffected by
this ADR and stay ProjectReference), and the guard gained a second assertion
covering it. Removing the pin now fails that assertion with
Expected ".NETStandard,Version=v2.0" / Actual ".NETCoreApp,Version=v10.0" —
verified, not assumed.
bench/DataNet.NetStandard.Benchmarks was already immune, and by luck rather
than design: it names all three projects directly, and a direct
ProjectReference silently takes precedence over a PackageReference of the
same id. No warning is emitted. That precedence is load-bearing in two places
now, so it is written down here rather than left to be rediscovered.
Tags become <PackageId>/v<Version> — DataNet.Fuzzy/v0.3.0. release.yml
triggers on DataNet.*/v*, parses both halves out of GITHUB_REF_NAME, and packs
and pushes that package alone.
The umbrella v* tag is retired. There is no repository-wide version left for
it to designate, and "release everything at its current version" is a batch
operation better expressed as three tags than as one ambiguous name.
The tag no longer sets the version; it says which declared version to release.
The workflow compares it against src/<Package>/Version.props and refuses the job
on a mismatch. This strengthens the existing discipline rather than relaxing it:
the previous workflows kept the tag in the environment because a ref name is
untrusted input to a job that can push packages. Now nothing derived from the tag
reaches dotnet pack at all — -p:Version is gone from every workflow.
Release order follows from the floor being a published version: packing
DataNet.Fuzzy only ever needs a DataNet.Text that already exists, so no job
sequencing is required. It matters only when raising the floor, and there the
rule is the ordinary one — publish DataNet.Text, then raise the floor, then
release DataNet.Fuzzy.
-
A change to
DataNet.Textis not seen byDataNet.Fuzzyuntil it is published.DataNet.Fuzzy.Teststherefore exercisesDataNet.Fuzzycompiled against the releasedDataNet.Text, not the one in the working tree. That is the intended semantics — it is what a consumer gets — but it means a cross-package change is two pull requests and a release, not one branch.DataNetUseProjectRefs=truecovers the editing, never the merging. -
CHANGELOG.mdstays one file, with per-package version headings. The repository remains a monorepo; only the release cadence decouples, and a single chronological file still reads better than three. -
The sample needs one version property per package, and its feed had to be
pinned by identity to keep working. Reading three
Version.propsinstead of one$(Version)was the easy half. The half that was nearly missed: packingDataNet.Fuzzynow restoresDataNet.Textfrom nuget.org, and the global packages folder is consulted ahead of every source — so once a package is declared at a version that is also published, the sample resolves the released assembly and ADR 0009's guarantee inverts silently, validating what already shipped while appearing to validate what is about to. Per-package versioning makes this the normal case rather than a fluke, because a package that did not change keeps its published version indefinitely instead of being swept along by a repository-wide bump. Two things restore the guarantee:packageSourceMappinginsamples/NuGet.configconfinesDataNet.*to the local feed, and the CI job gives the pack and the sample their ownNUGET_PACKAGESdirectories, because no mapping can override the global folder. Keeping a declared version off the feed is the third, andtools/check_version_floor.pyis where that is enforced. -
dotnet packoutput must be checked, not assumed. A package's dependency graph is a build output nobody writes down.tools/check_nuspec_dependencies.pyis where it is now written down, and it runs in CI and in both release jobs. - Splitting the repository remains out of scope. This buys decoupled cadence and a build graph that matches the release graph — nothing more.
- 0001-target-framework
- 0002-unicode-comparison-unit
- 0003-provenance-and-licensing
- 0004-levenshtein-myers-backlog
- 0005-hamming-jellyfish-divergence
- 0006-ratcliff-autojunk
- 0007-metaphone-scope
- 0008-italian-enza-nltk-divergence
- 0009-sample-consumes-a-local-feed
- 0010-stop-word-list-provenance
- 0011-persistence-format
- 0012-per-package-versioning
- 0013-sentencepiece-parity-scope
- 0014-precompiled-normalizer
- 0015-sonar-rules-in-the-build
- 0016-metrics-package-placement
- 0017-bpe-parity-scope
- 0018-multiclass-roc-auc-parallelism-is-opt-in
- 0019-the-net-analysers-run-in-the-build-too
- 0020-normalize-is-a-projection-not-a-parameter
- 0021-multioutput-is-a-method-not-an-enum
- 0022-added-token-matching-flags
- 0023-byte-level-decode-substitutes
- 0024-weighted-median-averages-within-scikit-learns-epsilon
- 0025-quickselect-replaces-a-full-sort-for-the-median
- 0026-r2-and-explainedvariance-split-their-undefined-cases-differently
- 0027-r2-and-explainedvariance-vectorize-only-a-single-output
- 0028-log1p-is-kahans-identity-not-math-log-1-plus-x
- 0029-balanced-accuracy-adjusted-is-left-to-ieee-754-at-the-edge
- 0030-cohen-kappa-keeps-scikit-learns-expected-matrix-orientation
- 0031-nosamplecorrect-mirrors-numpys-float64-upcast
- 0032-fbeta-substitutes-tp-predicted-and-support-algebraically
- 0033-compensated-sum-is-neumaiers-variant
- 0034-dropout-is-refused-for-want-of-a-user
- 0035-a-null-pre-split-is-removed-with-invert-not-isolated
- 0036-a-member-may-ship-without-an-oracle-if-it-says-so
- 0037-the-guards-run-before-the-commit
- 0038-the-gate-confronts-an-exception-tag-with-the-page-that-documents-it
- 0039-mutual-information-returns-zero-on-an-empty-input
- 0040-a-curve-is-a-sealed-class-per-curve
- 0041-one-sample-file-per-public-class
- 0042-phonetic-encoders-refuse-a-null-word
- 0043-the-equality-table-is-sized-to-the-pattern
- 0044-compression-belongs-to-the-caller
- 0045-a-console-call-carries-its-reason-on-the-line
- 0046-check-adr-immutable-runs-in-ci-only
- 0047-one-gate-per-kernel-not-one-per-alphabet
- 0048-the-gate-depends-on-the-kernel-and-the-alphabet
- 0049-two-gates-per-kernel-tested-where-the-width-is-known
- 0050-the-sentencepiece-bpe-lineage-stays-a-bpe-model
- benchmark_latest
- decisions
- equivalence
- matplotlib
- migration
- nightly_run
- numpy
- pandas
- performance
- pytorch
- seaborn
- sklearn
- statsmodels