Central Package Management: le versioni dei pacchetti NuGet in un solo file, e come migrare una soluzione esistente
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:
<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>
<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 pacchettiCentral 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)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:
<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:
<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
<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:
- un
PackageReferenceconVersiondà l’errore NU1008: la versione sta solo nel file centrale; - un
PackageReferencesenza il suoPackageVersiondà NU1010: il pacchetto va prima dichiarato al centro.
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.
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:
<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:
<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:
IncludeAssetssenzaCompile: il pacchetto lavora nella build, ma non diventa un riferimento per il codice;PrivateAssets="All": non passa a chi usa i tuoi progetti, né come progetto né come pacchetto.
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:
<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:
- solo al rialzo: una versione più vecchia di quella che EF Core chiede, come 6.1.5, è l’errore NU1109;
- senza la proprietà, un
PackageVersionper un pacchetto transitivo non fa niente: SqlClient resta a 6.1.6, in silenzio; - se il progetto diventa un pacchetto NuGet, le dipendenze fissate entrano nel suo elenco di dipendenze. Per una libreria pubblicata va valutato con attenzione.
È 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.
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:
<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.
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.
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.
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:
- il transitive pinning, per le dipendenze di seconda mano da fissare;
- i
GlobalPackageReferenceper gli analyzer; CentralPackageVersionOverrideEnabledafalse, se il team non vuole eccezioni.
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.
Il file finale
<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
- Le versioni sparse nei
.csprojsi separano senza avvisi, anche tra pacchetti della stessa famiglia. Directory.Packages.propstiene una versione per pacchetto, per tutta la soluzione; i progetti indicano solo il nome.- NU1008 e NU1010 tengono le versioni al centro.
VersionOverrideper le eccezioni, visibili nel progetto; si possono vietare.GlobalPackageReferenceper analyzer e strumenti di build.- Il transitive pinning fissa anche le dipendenze di seconda mano, solo al rialzo.
- 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ì.