Stell dir zwei Builds desselben Commits vor. Gleicher Quellcode, gleicher Branch, gleiche .csproj. Der eine läuft am Freitagnachmittag auf dem Laptop eines Entwicklers, der andere am Montagmorgen auf dem CI-Server. Sie erzeugen unterschiedliche Binaries – weil übers Wochenende eine transitive Abhängigkeit drei Ebenen tiefer eine neue Patch-Version veröffentlicht hat und NuGet sie bereitwillig mitgenommen hat.
Nichts in deinem Repository hat sich geändert. Trotzdem hat sich geändert, was du auslieferst. Genau diese Lücke schließt Version Locking.
Das ist kein exotischer Sonderfall, sondern das Standardverhalten jedes Paketmanagers, der Versionen erst beim Restore auflöst – und NuGet ist da keine Ausnahme. Die gute Nachricht: .NET hat seit Jahren eine saubere, eingebaute Lösung. Erstaunlich ist nur, wie wenige Teams sie tatsächlich nutzen.
Was „Version Locking" wirklich bedeutet
Wenn du ein Paket referenzierst, pinnst du normalerweise nicht alles auf eine exakte Version. Du schreibst etwas wie:
<PackageReference Include="Serilog" Version="3.1.1" />
Das sieht präzise aus. Aber Serilog selbst hängt von anderen Paketen ab, die wiederum von anderen abhängen. NuGet löst diesen gesamten transitiven Graphen beim Restore auf und nimmt für transitive Abhängigkeiten in der Regel die niedrigste Version, die alle Constraints erfüllt – was sich in dem Moment ändern kann, in dem irgendein Paket in diesem Graphen ein neues Release veröffentlicht. Kommt noch eine Floating Version wie Version="3.*" dazu, wird sogar deine direkte Abhängigkeit zum beweglichen Ziel.
Version Locking heißt, diesen vollständig aufgelösten Graphen einzufrieren – jedes direkte und transitive Paket, auf eine exakte Version – in einer Datei, die in deinem Repository liegt. Ab dann liest der Restore aus dieser Datei, statt neu vom Feed aufzulösen. Gleicher Input, gleicher Output, jedes Mal.
In NuGet ist diese Datei die packages.lock.json.
Wie es in NuGet funktioniert
Lock-Dateien zu aktivieren ist eine einzige Property im Projekt (oder in Directory.Build.props für die ganze Solution):
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
Der nächste dotnet restore erzeugt eine packages.lock.json neben deinem Projekt. Checke sie in die Versionsverwaltung ein. Sie hält drei Dinge fest, auf die es ankommt:
- Jede direkte Abhängigkeit in ihrer aufgelösten Version
- Jede transitive Abhängigkeit – die Teile, die du nie aufgeschrieben hast, aber definitiv auslieferst
- Einen Content-Hash (SHA-512) für jedes Paket, sodass sich ein wiederhergestelltes Paket gegen das Eingefrorene prüfen lässt
Hier ein gekürztes Beispiel:
{
"version": 1,
"dependencies": {
"net8.0": {
"Serilog": {
"type": "Direct",
"requested": "[3.1.1, )",
"resolved": "3.1.1",
"contentHash": "tk..."
},
"Serilog.Sinks.File": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "..."
}
}
}
}
Locked Mode: CI laut scheitern lassen
Standardmäßig aktualisiert NuGet die Lock-Datei still, wenn sich der Abhängigkeitsgraph ändert. Auf einem Entwicklerrechner ist das in Ordnung, auf einem Build-Server zerstört es aber den ganzen Sinn. Dort willst du, dass der Restore die eingefrorenen Versionen entweder exakt reproduziert oder scheitert – niemals still abdriftet.
Das ist der Locked Mode:
dotnet restore --locked-mode
Oder, sauberer, auf die CI beschränkt per MSBuild, damit die lokale Entwicklung reibungslos bleibt:
<PropertyGroup>
<RestoreLockedMode Condition="'$(ContinuousIntegrationBuild)' == 'true'">true</RestoreLockedMode>
</PropertyGroup>
Ist eine packages.lock.json jetzt nicht mehr mit den Projektdateien synchron, bricht der CI-Build mit einem Fehler ab, statt etwas zu bauen, das niemand geprüft hat. Wenn du eine Abhängigkeit bewusst änderst, erzeugst du die Lock-Datei lokal mit dotnet restore --force-evaluate neu, prüfst den Diff im Pull Request und checkst ihn ein. Jede Versionsänderung wird zu einer sichtbaren, prüfbaren Zeile in deiner Historie.
Lock-Dateien werden seit NuGet 4.9 / .NET SDK 2.1.500 / Visual Studio 2017 15.9 unterstützt – das ist also kein neues Tooling, auf das du warten müsstest. Quelle: Microsoft .NET Blog · NuGet Wiki
Die Floating-Version-Falle
Es lohnt sich, deutlich zu sagen, warum „einfach jede Version pinnen" allein nicht reicht. Floating Versions (*, 3.*, [3.1,4.0)) sind bequem – sie ziehen Bugfixes automatisch – aber sie machen den Restore per Design nicht-deterministisch. Schlimmer noch: NuGets HTTP- und No-op-Caches sorgen dafür, dass zwei Rechner dieselbe Floating-Referenz zu unterschiedlichen Versionen auflösen können, je nachdem, was jeder gecacht hat. Es gibt langjährige Berichte, dass Version="*" auf eine veraltete statt auf die neueste Version auflöst.
NuGets eigene Empfehlung ist, Floating Versions zu vermeiden – genau deshalb, weil „jedes Upgrade eines Pakets durch einen Commit in deinem Repository belegt sein sollte". Eine Lock-Datei erzwingt diese Disziplin selbst dann, wenn sich eine Floating-Range einschleicht: Der Float wird einmal aufgelöst, eingefroren und ändert sich nur, wenn du bewusst neu evaluierst. Quelle: Microsoft Learn
Im Zusammenspiel mit Central Package Management
Seit NuGet 6.2 kannst du mit Central Package Management (CPM) jede Paketversion einmalig deklarieren, in einer einzigen Directory.Packages.props im Repo-Root:
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
</ItemGroup>
</Project>
Die Projektdateien referenzieren Pakete dann ohne Version:
<PackageReference Include="Serilog" />
CPM und Lock-Dateien ergänzen sich, sie konkurrieren nicht. CPM gibt dir einen Ort, um Versionen über dutzende Projekte hinweg zu setzen und zu prüfen (Schluss mit demselben-Paket-in-drei-Versionen). Lock-Dateien geben dir den eingefrorenen transitiven Graphen und die Integritäts-Hashes. Bemerkenswert: CPM erlaubt überhaupt keine Floating Versions – es ist ausdrücklich für deterministische, sichere Restores gebaut – und CentralPackageTransitivePinningEnabled erlaubt es dir, ein transitives Paket auf eine feste Version zu pinnen, ohne eine direkte Referenz hinzuzufügen (praktisch, um eine gepatchte Version von etwas tief im Graphen zu erzwingen).
Quelle: Microsoft .NET Blog · Bart Wullems
Wozu der Aufwand: drei konkrete Vorteile
1. Reproduzierbare Builds
Das ist die Hauptsache. Ein eingefrorener Graph bedeutet, dass der Build von Commit abc123 heute denselben Abhängigkeitssatz erzeugt wie der Build von abc123 in einem Jahr – auf jeder Maschine, in jeder Pipeline. Das ist die Grundlage für vertrauenswürdige CI, verlässliche Rollbacks und das Debuggen eines Produktionsproblems gegen den exakten Code, der ausgeliefert wurde, transitive Pakete inklusive.
2. Supply-Chain-Integrität
Der contentHash für jedes Paket macht aus der Lock-Datei eine Manipulationsprüfung. Stimmen die Bytes eines wiederhergestellten Pakets nicht mit dem festgehaltenen Hash überein – weil ein Feed kompromittiert wurde, eine Version neu veröffentlicht wurde oder ein Mirror etwas anderes ausliefert – scheitert der Restore, statt es einzubauen. Zusammen mit dem Locked Mode kann ein Angreifer kein neues oder verändertes transitives Paket in deinen Build schmuggeln, indem er es upstream veröffentlicht; der Graph ist eingefroren und verifiziert.
Eine Lock-Datei ist keine vollständige Verteidigung – sie kann dir nicht sagen, dass eine eingefrorene Version selbst verwundbar ist (dafür sind NuGetAudit und Tools wie Dependabot da), und sie schützt die Lock-Datei selbst nicht, wenn dein Repo kompromittiert wird. Aber sie eliminiert die größte Einzelquelle für „wir haben eine Abhängigkeit ausgeliefert, die niemand ausgewählt hat".
Quelle: Endor Labs
3. Kontrollierte, sichtbare Updates
Ohne Lock-Datei sind Abhängigkeits-Updates unsichtbar – sie passieren einfach. Mit einer Lock-Datei ist jeder Versionssprung ein expliziter Diff in einem Pull Request. Du entscheidest, wann du ein Update nimmst, testest es bewusst, und wenn ein Build bricht, kannst du auf genau den Commit zeigen, der genau das Paket geändert hat. Updates werden zur bewussten Engineering-Entscheidung statt zum Nebeneffekt des Kalenders.
Wo das längst Standard ist
Hier der etwas unbequeme Teil für die .NET-Welt: Lock-Dateien sind fast überall sonst die Norm.
- npm hat
package-lock.json, seit npm 5 standardmäßig eingecheckt. - Yarn hat
yarn.lock; pnpm hatpnpm-lock.yaml. - Rust/Cargo hat
Cargo.lock. - Ruby/Bundler hat
Gemfile.lock. - Python hat
poetry.lock/Pipfile.lock. - Go pinnt über
go.summit Checksummen.
In all diesen Ökosystemen ist es schlicht die Arbeitsweise verantwortungsvoller Teams, die Lock-Datei einzuchecken und in der CI daraus zu installieren – es ist der Standard, oft der Weg des geringsten Widerstands. Quelle: Snyk
NuGet hat Lock-Dateien dagegen opt-in gemacht, und die Verbreitung zeigt es: Eine Analyse von rund 2,5 Millionen C#-Projekten auf GitHub fand nur etwa 4.900 (0,2 %), die Lock-Dateien nutzen. Wenn du das also einführst, jagst du keinem Trend hinterher – du holst .NET auf ein Basisniveau, auf das sich der Rest der Branche vor Jahren geeinigt hat. Quelle: Endor Labs
Einführen: eine entspannte Checkliste
- Lock-Dateien repo-weit aktivieren über
Directory.Build.props:<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> - Einmal restoren, um die
packages.lock.jsonzu erzeugen, dann jede davon einchecken. - Locked Mode nur in der CI aktivieren, bedingt über
ContinuousIntegrationBuild, damit die lokale Entwicklung schnell und nachsichtig bleibt. - Central Package Management einführen für den Versionen-an-einem-Ort-Vorteil, und Transitive Pinning aktivieren.
- Lock-Datei-Updates zu einem geprüften Schritt machen: mit
--force-evaluateneu erzeugen, den Diff anschauen, wie jede andere Änderung mergen. NuGetAuditdraufsetzen, damit eingefrorene Versionen weiterhin auf bekannte Schwachstellen geprüft werden – Locking geht um Kontrolle, nicht um Immunität.
Keiner dieser Schritte ist riskant oder schwer rückgängig zu machen. Der Preis sind ein paar Properties und eine zusätzliche Datei pro Projekt im Repo. Der Gewinn sind Builds, die auf jeder Maschine, an jedem Tag, für immer dasselbe bedeuten.
Fazit
Ein Build soll eine Funktion sein: gleiche Eingaben, gleiche Ausgaben. Ungesperrte Abhängigkeitsauflösung bricht diesen Vertrag still – sie lässt den Kalender, einen Cache oder einen Upstream-Publisher entscheiden, was du auslieferst. Version Locking stellt den Vertrag wieder her. packages.lock.json friert den vollständigen transitiven Graphen ein, Content-Hashes machen ihn verifizierbar, und der Locked-Mode-Restore lässt deine CI sich weigern, abzudriften.
Der Rest der Softwarewelt hat das längst verstanden. Für .NET ist das Tooling eingebaut, ausgereift und kostenlos. Die einzige echte Frage ist, warum deine packages.lock.json noch nicht in deinem Repo liegt.
