Central Package Management: le versioni dei pacchetti NuGet in un solo file, e come migrare una soluzione esistente

· 11 min di lettura

Scritto per .NET 10

#tech#dotnet#nuget

Nell’articolo sulla modularità il modulo di firma digitale era diviso in un progetto per livello, più tre progetti di test: unit, integrazione e architettura. Tanti progetti hanno un costo che non si vede subito: ogni .csproj porta le sue versioni dei pacchetti.

I tre progetti di test usano gli stessi pacchetti: xunit, il runner, il Test SDK, coverlet. Prima o poi qualcuno aggiorna i test di integrazione, gli altri due restano indietro, e nessuno se ne accorge.

Questo articolo ha due parti: prima lo strumento, il Central Package Management di NuGet, poi la migrazione della soluzione del modulo di firma, passo per passo.

Il problema: le versioni sparse nei .csproj

Ecco i pacchetti di due dei tre progetti di test, prima della migrazione:

UnitTests.csproj
<ItemGroup>
  <PackageReference Include="coverlet.collector" Version="6.0.4" />
  <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
  <PackageReference Include="xunit" Version="2.9.3" />
  <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
IntegrationTests.csproj
<ItemGroup>
  <PackageReference Include="coverlet.collector" Version="6.0.4" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
  <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
  <PackageReference Include="xunit" Version="2.9.3" />
  <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>

Il Test SDK è a 17.14.1 in un progetto e a 18.10.1 nell’altro. E c’è un caso più sottile: i test di integrazione usano il provider Sqlite 10.0.11, ma riferiscono Infrastructure, che porta EF Core SqlServer 10.0.12. Ecco cosa finisce davvero nel progetto:

$ dotnet package list --project tests/DocumentaleInventato.FirmaDigitale.IntegrationTests --include-transitive

   Top-level Package                         Requested   Resolved
   > Microsoft.EntityFrameworkCore.Sqlite      10.0.11     10.0.11

   Transitive Package                                           Resolved
   > Microsoft.EntityFrameworkCore                              10.0.12
   > Microsoft.EntityFrameworkCore.Relational                   10.0.12
   > Microsoft.EntityFrameworkCore.Sqlite.Core                  10.0.11

Qui NuGet sceglie, per ogni pacchetto, la versione più bassa che soddisfa tutte le richieste: EF Core sale a 10.0.12 perché lo chiede SqlServer, il provider Sqlite resta a 10.0.11 perché nessuno chiede di più. Il risultato è legale e la build passa senza un avviso. Però EF Core e il suo provider vengono rilasciati e provati insieme, alla stessa versione: tenerli disallineati è un rischio che nessuno ha scelto.

Come NuGet risolve le dipendenze dei pacchettiDocumentazione ufficiale · learn.microsoft.com

Central Package Management

Da NuGet 6.2, cioè Visual Studio 2022 17.2 e .NET SDK 6.0.300, le versioni possono stare in un solo file alla radice della soluzione: Directory.Packages.props. I progetti dicono solo quali pacchetti usano. Per aggiornare un pacchetto si cambia una riga, e cambia per tutti.

Gli strumenti più vecchi ignorano questa configurazione. Tutti, compresi gli agenti della pipeline di CI, devono avere almeno quelle versioni.

Gestione pacchetti centrale (CPM)Documentazione ufficiale · learn.microsoft.com

Il file centrale

Si crea dalla cartella della soluzione:

dotnet new packagesprops

Il file nasce con la proprietà che accende il CPM e un ItemGroup vuoto. Le versioni si aggiungono con un PackageVersion per pacchetto:

Directory.Packages.props
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>

  <ItemGroup Label="Applicazione">
    <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
    <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.12" />
    <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.12" />
  </ItemGroup>

  <ItemGroup Label="Test">
    <PackageVersion Include="coverlet.collector" Version="6.0.4" />
    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
    <PackageVersion Include="NetArchTest.Rules" Version="1.3.2" />
    <PackageVersion Include="xunit" Version="2.9.3" />
    <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
  </ItemGroup>
</Project>

I gruppi con Label sono facoltativi: servono solo a chi legge.

Una trappola: il valore della proprietà va scritto su una riga. MSBuild non toglie gli spazi, e con true su una riga a sé tra due a capo il valore non è più true. Il CPM resta spento senza dirlo, e l’errore arriva dopo, nei progetti: NU1015, “non hanno una versione”.

MSBuild cerca Directory.Packages.props partendo dalla cartella del progetto e salendo, e si ferma al primo che trova. Un secondo file in tests/ prende il posto di quello alla radice, non si somma. Se serve un file per una sottocartella, deve importare il padre:

tests/Directory.Packages.props
<Project>
  <Import Project="$([MSBuild]::GetPathOfFileAbove(Directory.Packages.props, $(MSBuildThisFileDirectory)..))" />
  <ItemGroup>
    <PackageVersion Update="xunit.runner.visualstudio" Version="3.1.4" />
  </ItemGroup>
</Project>

Update, e non Include, perché la voce esiste già nel file padre.

I progetti indicano solo il nome

IntegrationTests.csproj
<ItemGroup>
  <PackageReference Include="coverlet.collector" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
  <PackageReference Include="Microsoft.NET.Test.Sdk" />
  <PackageReference Include="xunit" />
  <PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>

Il restore ora controlla due cose:

Gli altri attributi, come PrivateAssets o IncludeAssets, restano sul PackageReference: al centro va solo la versione.

Aggiornare un pacchetto

Si cambia la riga in Directory.Packages.props. Per sapere cosa c’è di nuovo:

dotnet package list --outdated

Con il CPM, dotnet package add scrive nel file centrale. Un pacchetto nuovo riceve il suo PackageVersion e un PackageReference senza versione nel progetto. Su un pacchetto che esiste già, --version cambia la versione centrale, quindi per tutti i progetti, anche se il comando ne nomina uno solo. Questo sembra riguardare solo i test di integrazione, ma riporta Sqlite a 10.0.11 in tutta la soluzione:

dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.11 \
  --project tests/DocumentaleInventato.FirmaDigitale.IntegrationTests

Di solito è quello che si vuole, ma conviene saperlo prima. Da NuGet 6.5, cioè Visual Studio 2022 17.5, anche il gestore dei pacchetti di Visual Studio installa e aggiorna i pacchetti in Directory.Packages.props.

Comando dotnet package addDocumentazione ufficiale · learn.microsoft.com Note sulla versione di NuGet 6.5Documentazione ufficiale · learn.microsoft.com

Niente versioni fluttuanti come 10.0.*: con il CPM sono un errore, NU1011. È voluto: ogni aggiornamento passa da un commit, e se qualcosa si rompe si sa quale. Si possono riattivare con CentralPackageFloatingVersionsEnabled, ma si perde proprio questo.

L’eccezione: VersionOverride

A volte un solo progetto deve usare un’altra versione, per esempio mentre prova un aggiornamento:

IntegrationTests.csproj
<PackageReference Include="xunit.runner.visualstudio" VersionOverride="3.1.4" />

Vale solo per quel progetto, e soprattutto si vede: chi legge il .csproj sa che lì c’è un’eccezione. Se il team preferisce non averne, si possono vietare nel file centrale:

<CentralPackageVersionOverrideEnabled>false</CentralPackageVersionOverrideEnabled>

Da quel momento un VersionOverride dà l’errore NU1013.

Se la soluzione ha progetti con più target framework, il file centrale può avere due PackageVersion dello stesso pacchetto con una Condition su $(TargetFramework).

Un pacchetto per tutti: GlobalPackageReference

Alcuni pacchetti servono in ogni progetto: gli analyzer, gli strumenti che intervengono nella build. Invece di aggiungerli a ogni .csproj:

Directory.Packages.props
<ItemGroup Label="Per tutti i progetti">
  <GlobalPackageReference Include="Roslynator.Analyzers" Version="5.0.1" />
</ItemGroup>

NuGet lo aggiunge a tutti i progetti con due impostazioni precise:

Per questo va bene per gli analyzer e non per una libreria da usare nel codice. Richiede NuGet 6.4, cioè Visual Studio 2022 17.4 o .NET SDK 7.0.100.

Le dipendenze di seconda mano: transitive pinning

EF Core SqlServer 10.0.12 si porta dietro Microsoft.Data.SqlClient 6.1.6. Il percorso si vede con dotnet nuget why:

$ dotnet nuget why src/DocumentaleInventato.FirmaDigitale.Infrastructure Microsoft.Data.SqlClient

  [net10.0]
  └── Microsoft.EntityFrameworkCore.SqlServer (v10.0.12)
      └── Microsoft.Data.SqlClient (v6.1.6)

Quando esce una correzione, come la 6.1.7, non serve aspettare una nuova versione di EF Core. Con il transitive pinning il file centrale vale anche per i pacchetti che arrivano di seconda mano:

Directory.Packages.props
<PropertyGroup>
  <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>

<ItemGroup Label="Dipendenze transitive fissate">
  <PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.7" />
</ItemGroup>

Ora dotnet nuget why mostra Microsoft.Data.SqlClient (v6.1.7), e solo nei progetti che lo ricevono già: Domain e Application non lo vedono comparire. Tre dettagli:

È anche uno dei rimedi che la documentazione suggerisce quando il controllo delle vulnerabilità segnala un pacchetto transitivo. Con .NET 10 quel controllo guarda anche le dipendenze transitive per default, nei progetti con target net10.0 o successivo: gli avvisi da NU1901 a NU1904 arrivano durante il restore. La documentazione consiglia di provare prima ad aggiornare il pacchetto più vicino ai riferimenti diretti, qui EF Core, e di fissare la transitiva quando questo non basta.

Controllo delle dipendenze dei pacchetti per le vulnerabilità di sicurezzaDocumentazione ufficiale · learn.microsoft.com

Più sorgenti di pacchetti: NU1507

Con il CPM e più di una sorgente di pacchetti, per esempio nuget.org e un feed aziendale, il restore dà l’avviso NU1507. NuGet chiede di dire da dove arriva ogni pacchetto, con il package source mapping:

nuget.config
<packageSourceMapping>
  <packageSource key="nuget.org">
    <package pattern="*" />
  </packageSource>
  <packageSource key="aziendale">
    <package pattern="DocumentaleInventato.*" />
  </packageSource>
</packageSourceMapping>

Vince il pattern più specifico: i pacchetti DocumentaleInventato.* arrivano solo dal feed aziendale, tutto il resto da nuget.org. Così un pacchetto con lo stesso nome pubblicato su nuget.org non può prendere il posto di quello interno.

Mappatura fonte pacchettoDocumentazione ufficiale · learn.microsoft.com

Migrare una soluzione esistente

La soluzione del modulo di firma è quella dell’inizio: le versioni nei .csproj, e le differenze nei test. La migrazione si fa in un ramo a parte e porta a un solo commit, facile da rivedere.

1. Gli strumenti

Prima di tutto le versioni minime: Visual Studio 2022 17.2 e .NET SDK 6.0.300 per il CPM, 17.4 e 7.0.100 se servono i pacchetti globali. Vale per tutto il team e per gli agenti della CI: uno strumento troppo vecchio ignora il file centrale.

2. La fotografia di partenza

Prima di toccare qualcosa si salva l’elenco completo dei pacchetti risolti, transitivi compresi:

dotnet restore
dotnet package list --include-transitive --no-restore > prima.txt

Alla fine servirà per il confronto. --no-restore tiene fuori dal file le righe del restore, che cambiano a ogni esecuzione.

3. L’inventario

Serve l’elenco dei pacchetti con le versioni usate. In Visual Studio, la scheda Consolida di “Gestisci pacchetti NuGet per la soluzione” mostra i pacchetti che hanno versioni diverse in progetti diversi. Da riga di comando, con l’output JSON e jq:

dotnet package list --format json | jq -r '
  [.projects[].frameworks[].topLevelPackages[]?]
  | group_by(.id)[]
  | "\(.[0].id) \(map(.requestedVersion) | unique | join(", "))"'
Microsoft.AspNetCore.Authentication.JwtBearer 10.0.12
Microsoft.EntityFrameworkCore.SqlServer 10.0.12
Microsoft.EntityFrameworkCore.Sqlite 10.0.11
Microsoft.Extensions.Hosting.Abstractions 10.0.12
Microsoft.Extensions.Http 10.0.12
Microsoft.NET.Test.Sdk 17.14.1, 18.10.1
NetArchTest.Rules 1.3.2
coverlet.collector 6.0.4
xunit 2.9.3
xunit.runner.visualstudio 3.1.4, 3.1.5

È già la lista da mettere nel file centrale. Le righe con due versioni sono quelle da decidere. Attenzione però: sia la scheda Consolida sia questo comando confrontano lo stesso pacchetto. Il provider Sqlite a 10.0.11 accanto a EF Core 10.0.12 non compare come differenza, perché sono pacchetti diversi. Le famiglie che escono insieme, come EF Core, ASP.NET Core o Microsoft.Extensions.*, vanno controllate a occhio.

Installare e gestire pacchetti in Visual Studio usando il Package Manager NuGetDocumentazione ufficiale · learn.microsoft.com

4. Il file centrale

Si crea con dotnet new packagesprops nella cartella della soluzione, accanto al file .slnx o .sln, e si riempie con un PackageVersion per riga dell’inventario. Per le righe con più versioni di solito si sceglie la più alta, perché il codice potrebbe già usare qualcosa che nella più bassa non c’è.

Nel nostro caso: il runner a 3.1.5, Sqlite a 10.0.12 come il resto di EF Core, il Test SDK a 18.10.1. Quest’ultimo è un salto di versione major, da 17 a 18. Se preferisci tenere la migrazione separata dagli aggiornamenti, scegli 17.14.1 e aggiorna dopo, con un commit a parte.

5. Togliere le versioni dai progetti

Un Version per riga, in tutti i .csproj. Con Visual Studio: Sostituisci nei file, espressioni regolari attive, filtro *.csproj:

Cerca:      (<PackageReference Include="[^"]+") Version="[^"]+"
Sostituisci: $1

Visual Studio usa le espressioni regolari di .NET, e $1 è il primo gruppo tra parentesi, cioè tutto tranne la versione. L’espressione copre la forma su una riga. Se un progetto scrive la versione come elemento figlio, <Version>…</Version>, quella va tolta a mano: il restore la segnala comunque con NU1008.

Usare espressioni regolari in Visual StudioDocumentazione ufficiale · learn.microsoft.com

6. Build e test

dotnet build
dotnet test

Gli errori di questo momento sono quasi sempre due: NU1008, un Version rimasto in un progetto, e NU1010, un pacchetto che manca nel file centrale.

7. Il confronto

Si rifà la fotografia e si confronta con quella di partenza:

dotnet package list --include-transitive --no-restore > dopo.txt
git diff --no-index prima.txt dopo.txt

git diff --no-index confronta due file qualunque, anche fuori da un repository, e funziona uguale su Windows, macOS e Linux. Nel nostro caso le differenze sono esattamente le scelte del passo 4:

-   > Microsoft.NET.Test.Sdk         17.14.1     17.14.1
+   > Microsoft.NET.Test.Sdk         18.10.1     18.10.1
-   > xunit.runner.visualstudio      3.1.4       3.1.4
+   > xunit.runner.visualstudio      3.1.5       3.1.5
-   > Microsoft.EntityFrameworkCore.Sqlite      10.0.11     10.0.11
+   > Microsoft.EntityFrameworkCore.Sqlite      10.0.12     10.0.12
-   > Microsoft.EntityFrameworkCore.Sqlite.Core                  10.0.11
+   > Microsoft.EntityFrameworkCore.Sqlite.Core                  10.0.12
-   > Newtonsoft.Json                                            13.0.3

Con le transitive che le seguono: il Test SDK 18 si porta dietro le sue dipendenze alla 18, il provider Sqlite le sue alla 10.0.12. E una riga sparisce: Newtonsoft.Json, che nella 17 arrivava con il Test SDK, attraverso Microsoft.TestPlatform.TestHost, e nella 18 no. Il confronto serve proprio a questo: ogni riga deve avere una spiegazione. Una differenza che non si spiega va capita prima del commit.

8. Dopo la migrazione

Con il file centrale al suo posto, il resto si aggiunge quando serve, ognuno con il suo commit:

Se i .csproj ripetono anche TargetFramework, Nullable e ImplicitUsings, lo stesso meccanismo esiste per le proprietà: Directory.Build.props. MSBuild lo cerca nello stesso modo, salendo dalla cartella del progetto.

Personalizzare la compilazione per cartellaDocumentazione ufficiale · learn.microsoft.com

Il file finale

Directory.Packages.props
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>

  <ItemGroup Label="Applicazione">
    <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
    <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.12" />
    <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.12" />
  </ItemGroup>

  <ItemGroup Label="Dipendenze transitive fissate">
    <PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.7" />
  </ItemGroup>

  <ItemGroup Label="Test">
    <PackageVersion Include="coverlet.collector" Version="6.0.4" />
    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
    <PackageVersion Include="NetArchTest.Rules" Version="1.3.2" />
    <PackageVersion Include="xunit" Version="2.9.3" />
    <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
  </ItemGroup>

  <ItemGroup Label="Per tutti i progetti">
    <GlobalPackageReference Include="Roslynator.Analyzers" Version="5.0.1" />
  </ItemGroup>
</Project>

In breve

  1. Le versioni sparse nei .csproj si separano senza avvisi, anche tra pacchetti della stessa famiglia.
  2. Directory.Packages.props tiene una versione per pacchetto, per tutta la soluzione; i progetti indicano solo il nome.
  3. NU1008 e NU1010 tengono le versioni al centro.
  4. VersionOverride per le eccezioni, visibili nel progetto; si possono vietare.
  5. GlobalPackageReference per analyzer e strumenti di build.
  6. Il transitive pinning fissa anche le dipendenze di seconda mano, solo al rialzo.
  7. Per migrare: fotografia, inventario, file centrale, via le versioni dai progetti, build, confronto. Ogni differenza deve avere una spiegazione.

Codice semplificato a scopo di esempio: nomi e struttura servono a dare l'idea, non sono da copiare così.