Un cadenas scelle une pile de paquets NuGet versionnés, avec en arrière-plan un pipeline CI produisant des builds identiques
Retour au blog
NuGet.NETGestion des dépendances

NuGet Version Locking : pourquoi votre build ne devrait jamais dépendre du jour de la semaine

Sascha KieferDéveloppement

Un restore NuGet peut récupérer des versions de paquets différentes le lundi par rapport au vendredi, sans qu'une seule ligne de votre code n'ait changé. Les fichiers de verrouillage (packages.lock.json) et le mode verrouillé résolvent ce problème. Nous verrons ce qu'est le version locking, pourquoi il compte pour des builds reproductibles et sûrs, et pourquoi presque tous les autres écosystèmes le font depuis des années.

Imaginez deux builds du même commit. Même code source, même branche, même .csproj. L'un s'exécute le vendredi après-midi sur le portable d'un développeur, l'autre le lundi matin sur le serveur CI. Ils produisent des binaires différents, parce que, pendant le week-end, une dépendance transitive située trois niveaux plus bas a publié une nouvelle version corrective et que NuGet l'a allègrement récupérée.

Rien dans votre dépôt n'a changé. Pourtant, ce que vous livrez a changé. C'est précisément cette faille que le version locking comble.

Ce n'est pas un cas particulier exotique, mais le comportement par défaut de tout gestionnaire de paquets qui résout les versions au moment du restore, et NuGet ne fait pas exception. La bonne nouvelle : .NET dispose depuis des années d'une solution intégrée et propre. Ce qui surprend, c'est le peu d'équipes qui l'utilisent réellement.

Ce que signifie vraiment le « version locking »

Lorsque vous référencez un paquet, vous ne figez généralement pas tout sur une version exacte. Vous écrivez quelque chose comme :

<PackageReference Include="Serilog" Version="3.1.1" />

Cela paraît précis. Mais Serilog lui-même dépend d'autres paquets, qui dépendent à leur tour d'autres encore. NuGet résout ce graphe transitif complet au moment du restore et retient en général, pour les dépendances transitives, la version la plus basse qui satisfait toutes les contraintes, ce qui peut changer dès qu'un paquet de ce graphe publie une nouvelle version. Ajoutez une version flottante comme Version="3.*" et même votre dépendance directe devient une cible mouvante.

Le version locking consiste à figer ce graphe entièrement résolu, chaque paquet direct et transitif, sur une version exacte, dans un fichier qui réside dans votre dépôt. À partir de là, le restore lit ce fichier au lieu de résoudre à nouveau depuis le feed. Même entrée, même sortie, à chaque fois.

Dans NuGet, ce fichier est le packages.lock.json.

Comment cela fonctionne dans NuGet

Activer les fichiers de verrouillage tient à une seule propriété dans votre projet (ou dans Directory.Build.props pour toute la solution) :

<PropertyGroup>
  <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>

Le prochain dotnet restore génère un packages.lock.json à côté de votre projet. Versionnez-le dans votre gestionnaire de sources. Il consigne trois éléments qui comptent :

  • Chaque dépendance directe dans sa version résolue
  • Chaque dépendance transitive : les éléments que vous n'avez jamais écrits mais que vous livrez assurément
  • Un hash de contenu (SHA-512) pour chaque paquet, afin qu'un paquet restauré puisse être vérifié par rapport à ce qui a été figé

Voici un exemple raccourci :

{
  "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": "..."
      }
    }
  }
}

Le mode verrouillé : faire échouer la CI bruyamment

Par défaut, NuGet met à jour silencieusement le fichier de verrouillage lorsque le graphe de dépendances change. Sur une machine de développeur, c'est acceptable, mais sur un serveur de build, cela détruit tout l'intérêt de la démarche. Là, vous voulez que le restore reproduise exactement les versions figées ou bien qu'il échoue, sans jamais dériver silencieusement.

C'est le mode verrouillé :

dotnet restore --locked-mode

Ou, plus proprement, en le limitant à la CI via MSBuild, afin que le développement local reste fluide :

<PropertyGroup>
  <RestoreLockedMode Condition="'$(ContinuousIntegrationBuild)' == 'true'">true</RestoreLockedMode>
</PropertyGroup>

Désormais, si un packages.lock.json n'est plus synchronisé avec les fichiers de projet, le build CI s'interrompt avec une erreur au lieu de construire quelque chose que personne n'a relu. Lorsque vous modifiez intentionnellement une dépendance, vous régénérez le fichier de verrouillage en local avec dotnet restore --force-evaluate, vous examinez le diff dans la pull request et vous le commitez. Chaque changement de version devient une ligne visible et vérifiable dans votre historique.

Les fichiers de verrouillage sont pris en charge depuis NuGet 4.9 / .NET SDK 2.1.500 / Visual Studio 2017 15.9 : ce n'est donc pas un nouvel outillage qu'il faudrait attendre. Sources : Microsoft .NET Blog · NuGet Wiki

Le piège des versions flottantes

Il vaut la peine de dire clairement pourquoi « il suffit de figer chaque version » ne suffit pas à lui seul. Les versions flottantes (*, 3.*, [3.1,4.0)) sont pratiques, elles récupèrent automatiquement les corrections de bugs, mais elles rendent le restore non déterministe par conception. Pire encore : les caches HTTP et no-op de NuGet font que deux machines peuvent résoudre la même référence flottante vers des versions différentes, selon ce que chacune a mis en cache. On rapporte depuis longtemps que Version="*" se résout vers une version obsolète plutôt que vers la plus récente.

La recommandation de NuGet lui-même est d'éviter les versions flottantes, précisément parce que « chaque mise à niveau d'un paquet devrait être attestée par un commit dans votre dépôt ». Un fichier de verrouillage impose cette discipline même lorsqu'une plage flottante se glisse dans le projet : le flottant est résolu une fois, figé, et ne change que lorsque vous procédez délibérément à une nouvelle évaluation. Source : Microsoft Learn

En complément du Central Package Management

Depuis NuGet 6.2, le Central Package Management (CPM) vous permet de déclarer chaque version de paquet une seule fois, dans un unique Directory.Packages.props à la racine du dépôt :

<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>

Les fichiers de projet référencent alors les paquets sans version :

<PackageReference Include="Serilog" />

Le CPM et les fichiers de verrouillage se complètent, ils ne sont pas en concurrence. Le CPM vous donne un seul endroit pour définir et contrôler les versions à travers des dizaines de projets (fini le même-paquet-en-trois-versions). Les fichiers de verrouillage vous donnent le graphe transitif figé et les hashs d'intégrité. À noter : le CPM n'autorise pas du tout les versions flottantes, il est explicitement conçu pour des restores déterministes et sûrs, et CentralPackageTransitivePinningEnabled vous permet de figer un paquet transitif sur une version fixe sans ajouter de référence directe (pratique pour imposer une version corrigée de quelque chose situé en profondeur dans le graphe). Sources : Microsoft .NET Blog · Bart Wullems

À quoi bon : trois bénéfices concrets

1. Des builds reproductibles

C'est l'essentiel. Un graphe figé signifie que le build du commit abc123 produit aujourd'hui le même ensemble de dépendances que le build de abc123 dans un an, sur n'importe quelle machine, dans n'importe quel pipeline. C'est le fondement d'une CI digne de confiance, de rollbacks fiables et du débogage d'un problème de production contre le code exact qui a été livré, paquets transitifs inclus.

2. L'intégrité de la chaîne d'approvisionnement

Le contentHash de chaque paquet transforme le fichier de verrouillage en contrôle anti-falsification. Si les octets d'un paquet restauré ne correspondent pas au hash enregistré, parce qu'un feed a été compromis, qu'une version a été republiée ou qu'un miroir livre autre chose, le restore échoue au lieu de l'intégrer. Associé au mode verrouillé, un attaquant ne peut pas faufiler un paquet transitif nouveau ou modifié dans votre build en le publiant en amont ; le graphe est figé et vérifié.

Un fichier de verrouillage n'est pas une défense complète : il ne peut pas vous dire qu'une version figée est elle-même vulnérable (c'est le rôle de NuGetAudit et d'outils comme Dependabot), et il ne protège pas le fichier de verrouillage lui-même si votre dépôt est compromis. Mais il élimine la principale source de « nous avons livré une dépendance que personne n'a choisie ». Source : Endor Labs

3. Des mises à jour contrôlées et visibles

Sans fichier de verrouillage, les mises à jour de dépendances sont invisibles, elles surviennent simplement. Avec un fichier de verrouillage, chaque changement de version est un diff explicite dans une pull request. Vous décidez quand prendre une mise à jour, vous la testez délibérément, et si un build casse, vous pouvez pointer le commit exact qui a modifié le paquet exact. Les mises à jour deviennent une décision d'ingénierie réfléchie plutôt qu'un effet secondaire du calendrier.

Là où c'est déjà la norme

Voici la partie un peu inconfortable pour le monde .NET : les fichiers de verrouillage sont la norme presque partout ailleurs.

  • npm dispose de package-lock.json, versionné par défaut depuis npm 5.
  • Yarn a yarn.lock ; pnpm a pnpm-lock.yaml.
  • Rust/Cargo a Cargo.lock.
  • Ruby/Bundler a Gemfile.lock.
  • Python a poetry.lock / Pipfile.lock.
  • Go fige via go.sum avec des sommes de contrôle.

Dans tous ces écosystèmes, versionner le fichier de verrouillage et installer à partir de lui dans la CI est tout simplement la façon de travailler des équipes responsables : c'est la valeur par défaut, souvent le chemin de moindre résistance. Source : Snyk

NuGet, en revanche, a rendu les fichiers de verrouillage opt-in, et le taux d'adoption le montre : une analyse d'environ 2,5 millions de projets C# sur GitHub n'en a trouvé qu'environ 4 900 (0,2 %) utilisant des fichiers de verrouillage. Donc, si vous adoptez cette pratique, vous ne courez pas après une mode : vous amenez .NET au niveau de base sur lequel le reste de l'industrie s'est accordé il y a des années. Source : Endor Labs

L'adopter : une checklist sans drame

  1. Activer les fichiers de verrouillage à l'échelle du dépôt via Directory.Build.props : <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  2. Faire un restore une fois pour générer le packages.lock.json, puis versionner chacun d'eux.
  3. N'activer le mode verrouillé que dans la CI, conditionné à ContinuousIntegrationBuild, afin que le développement local reste rapide et indulgent.
  4. Adopter le Central Package Management pour le bénéfice des versions-en-un-seul-endroit, et activer le pinning transitif.
  5. Faire des mises à jour du fichier de verrouillage une étape relue : régénérer avec --force-evaluate, examiner le diff, le merger comme n'importe quel autre changement.
  6. Ajouter NuGetAudit par-dessus, afin que les versions figées continuent d'être contrôlées pour les vulnérabilités connues : le locking concerne le contrôle, pas l'immunité.

Aucune de ces étapes n'est risquée ni difficile à annuler. Le coût se résume à quelques propriétés et à un fichier supplémentaire par projet dans le dépôt. Le gain : des builds qui signifient la même chose sur chaque machine, chaque jour, pour toujours.

Conclusion

Un build est censé être une fonction : mêmes entrées, mêmes sorties. Une résolution de dépendances non verrouillée brise silencieusement ce contrat : elle laisse le calendrier, un cache ou un éditeur en amont décider de ce que vous livrez. Le version locking rétablit le contrat. packages.lock.json fige le graphe transitif complet, les hashs de contenu le rendent vérifiable, et le restore en mode verrouillé fait refuser à votre CI toute dérive.

Le reste du monde logiciel l'a compris depuis longtemps. Pour .NET, l'outillage est intégré, mature et gratuit. La seule vraie question est de savoir pourquoi votre packages.lock.json n'est pas encore dans votre dépôt.

Besoin d'aide ?

Vos builds .NET sont-ils reproductibles, ou dépendent-ils silencieusement du jour, de la machine et d'un cache quelque part ? Nous aidons les équipes à mettre leur gestion des dépendances sur des bases solides : fichiers de verrouillage, Central Package Management, CI en mode verrouillé et une posture de chaîne d'approvisionnement qui résiste à un audit. Contactez-nous simplement et nous rendrons vos builds déterministes, auditables et ennuyeux, dans le meilleur sens du terme.

Nous contacter